diff --git a/.changeset/fix-release-tag-shallow.md b/.changeset/fix-release-tag-shallow.md new file mode 100644 index 0000000..5dafead --- /dev/null +++ b/.changeset/fix-release-tag-shallow.md @@ -0,0 +1,5 @@ +--- +"@sfmc-bds/tools": patch +--- + +修复发版编排:浅克隆不再误打全量 tag;CHANGELOG 摘录正则;release-tags 状态 DRY diff --git a/.changeset/pre.json b/.changeset/pre.json index 6b59671..c7a833b 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -13,6 +13,12 @@ "@sfmc-bds/tools": "0.1.1" }, "changesets": [ - "tame-suns-begin" + "bds-tools-0-2-beta", + "cli-0-2-beta", + "db-server-0-2-beta", + "qq-bridge-0-1-1-beta", + "sdk-0-2-beta", + "sfmc-meta-0-2-beta", + "tools-0-2-beta" ] } diff --git a/.changeset/tame-suns-begin.md b/.changeset/tame-suns-begin.md deleted file mode 100644 index be2ce0d..0000000 --- a/.changeset/tame-suns-begin.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@sfmc-bds/bds-tools": patch -"@sfmc-bds/db-server": patch -"@sfmc-bds/sfmc": patch ---- - -- feat(remote-controller): 添加日志功能并更新包依赖 -- chore/fix: -- 在 package.json 中添加 @sfmc-bds/sdk 作为依赖。 -- 引入日志模块(log.ts),用于 remote-controller 的统一日志记录。 -- 将 console.error 和 console.log 语句替换为 log 方法,以改善日志管理。 -- 更新 index.ts,使其在错误和信息提示中利用新的日志功能。 -- 增强 world-packs.ts 中各类操作(包括安装和冲突处理)的日志记录。 -- 改进中英文 i18n 本地化字符串,提升清晰度和一致性。 diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index aa198ac..c81f37b 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -23,7 +23,10 @@ jobs: pull-requests: write id-token: write steps: + # fetch-depth:0:tag-packages 需 HEAD~1 做版本 diff;浅克隆会误触发全量 tag 回退路径 - uses: actions/checkout@v5 + with: + fetch-depth: 0 - uses: actions/setup-node@v5 with: @@ -50,6 +53,7 @@ jobs: with: version: npm run version-packages # CI 不跑交互 changeset/version;只 publish + tag + gh release + # tag-packages 在 CI=true 时默认 --from-existing(publish 已建 tag,避免浅克隆误打全量) publish: npm run ci-release-packages title: "chore(release): version packages" commit: "chore(release): version packages" diff --git a/docs/dev/npm-publish.md b/docs/dev/npm-publish.md index 8a9d9d8..9222b8d 100644 --- a/docs/dev/npm-publish.md +++ b/docs/dev/npm-publish.md @@ -53,7 +53,7 @@ npm run prerelease-packages |------|--------| | `prerelease-packages` | **现在**(pre/beta) | | `release-packages` | 仅 `changeset pre exit` 之后(latest) | -| `ci-release-packages` | CI 专用(无交互) | +| `ci-release-packages` | CI 专用(无交互)。`CI=true` 时 `tag-packages` 默认 `--from-existing`:只收录 `changeset publish` 已创建的 tag,避免浅克隆误为未发版包打 tag / 建 GH Release。 | | `publish-packages` | 仅 npm publish 包装 | 需要:本机已登录 `gh`、npm(或 `NPM_TOKEN`),且对 `origin` 有推送权限。 diff --git a/tools/changeset-github-release.mjs b/tools/changeset-github-release.mjs index f318352..b604f9f 100644 --- a/tools/changeset-github-release.mjs +++ b/tools/changeset-github-release.mjs @@ -14,25 +14,23 @@ import { extractChangelogNotes, gitCapture, listPublishablePackages, + packageDirFor, packageTagName, + readReleaseTagsState, run, } from "./lib/changeset-release.mjs"; -import { NPM_PUBLISH_PACKAGES } from "./lib/npm-publish-packages.mjs"; const forcePre = process.argv.includes("--prerelease"); const forceLatest = process.argv.includes("--latest"); -const statePath = path.join(ROOT, ".sfmc-release-tags.json"); function loadTargets() { - if (fs.existsSync(statePath)) { - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - if (Array.isArray(state.tags) && state.tags.length > 0) { - return state.tags.map((t) => ({ - name: t.name, - version: t.version, - tag: t.tag, - })); - } + const state = readReleaseTagsState(); + if (state && state.tags.length > 0) { + return state.tags.map((t) => ({ + name: t.name, + version: t.version, + tag: t.tag, + })); } const out = []; for (const p of listPublishablePackages()) { @@ -72,8 +70,7 @@ for (const t of targets) { } const pre = isPrereleaseVersion(t.version); - const rel = NPM_PUBLISH_PACKAGES[t.name]; - const pkgDir = rel ? path.join(ROOT, path.dirname(rel)) : ROOT; + const pkgDir = packageDirFor(t.name) ?? ROOT; const notes = extractChangelogNotes(pkgDir, t.version); const notesFile = path.join( ROOT, @@ -81,20 +78,10 @@ for (const t of targets) { ); fs.writeFileSync(notesFile, notes + "\n"); - const args = [ - "release", - "create", - t.tag, - "--title", - t.tag, - "--notes-file", - notesFile, - ]; + const args = ["release", "create", t.tag, "--title", t.tag, "--notes-file", notesFile]; if (pre) args.push("--prerelease"); - console.log( - `[changeset] gh release create ${t.tag}` + (pre ? " --prerelease" : "") - ); + console.log(`[changeset] gh release create ${t.tag}` + (pre ? " --prerelease" : "")); try { run("gh", args); } finally { diff --git a/tools/changeset-publish.mjs b/tools/changeset-publish.mjs index 1d55694..a606d65 100644 --- a/tools/changeset-publish.mjs +++ b/tools/changeset-publish.mjs @@ -5,18 +5,10 @@ * - pre mode 下由 changesets 自动使用 pre.json 的 tag(勿再传 --tag,会报错) */ import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { ROOT, isPreMode, readPreState } from "./lib/changeset-release.mjs"; -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const prePath = path.join(root, ".changeset", "pre.json"); - -let preTag = null; -if (fs.existsSync(prePath)) { - const pre = JSON.parse(fs.readFileSync(prePath, "utf8")); - if (pre.mode === "pre" && pre.tag) preTag = String(pre.tag); -} +const pre = readPreState(); +const preTag = isPreMode() && pre?.tag ? String(pre.tag) : null; const env = { ...process.env, @@ -34,5 +26,5 @@ console.log( `[changeset-publish] running: npx ${args.join(" ")}` + (preTag ? ` (pre mode → npm tag ${preTag})` : "") ); -const r = spawnSync("npx", args, { cwd: root, env, stdio: "inherit", shell: true }); +const r = spawnSync("npx", args, { cwd: ROOT, env, stdio: "inherit", shell: true }); process.exit(r.status === null ? 1 : r.status); diff --git a/tools/changeset-push.mjs b/tools/changeset-push.mjs index b1a57d4..5e56b60 100644 --- a/tools/changeset-push.mjs +++ b/tools/changeset-push.mjs @@ -3,11 +3,12 @@ * 推送当前分支与发版 tag(优先读 .sfmc-release-tags.json)。 * CI 下默认只推 tag(SFMC_PUSH_TAGS_ONLY=1 或 CI=true)。 */ -import fs from "node:fs"; -import path from "node:path"; -import { ROOT, git, gitCapture } from "./lib/changeset-release.mjs"; +import { + git, + gitCapture, + readReleaseTagsState, +} from "./lib/changeset-release.mjs"; -const statePath = path.join(ROOT, ".sfmc-release-tags.json"); const tagsOnly = process.env.SFMC_PUSH_TAGS_ONLY === "1" || process.env.CI === "true" || @@ -27,9 +28,9 @@ if (!tagsOnly) { /** @type {string[]} */ let tags = []; -if (fs.existsSync(statePath)) { - const state = JSON.parse(fs.readFileSync(statePath, "utf8")); - tags = (state.tags ?? []).map((t) => t.tag).filter(Boolean); +const state = readReleaseTagsState(); +if (state && state.tags.length > 0) { + tags = state.tags.map((t) => t.tag).filter(Boolean); } if (tags.length === 0) { diff --git a/tools/changeset-release.test.mjs b/tools/changeset-release.test.mjs new file mode 100644 index 0000000..bafedd0 --- /dev/null +++ b/tools/changeset-release.test.mjs @@ -0,0 +1,48 @@ +/** + * changeset-release 共用库单测(node:test) + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + packageTagName, + extractChangelogNotes, + RELEASE_TAGS_STATE, + PRE_JSON, + ROOT, +} from "./lib/changeset-release.mjs"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +describe("packageTagName", () => { + it("matches changeset publish format (no v prefix)", () => { + assert.equal(packageTagName("@sfmc-bds/db-server", "0.2.0-beta.1"), "@sfmc-bds/db-server@0.2.0-beta.1"); + }); +}); + +describe("extractChangelogNotes", () => { + it("extracts body for a version heading", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-cl-")); + fs.writeFileSync( + path.join(dir, "CHANGELOG.md"), + "# pkg\n\n## 1.2.3\n\n### Patch Changes\n\n- hello\n\n## 1.2.2\n\n- old\n" + ); + const notes = extractChangelogNotes(dir, "1.2.3"); + assert.match(notes, /hello/); + assert.doesNotMatch(notes, /old/); + }); + + it("falls back when version missing", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-cl-")); + fs.writeFileSync(path.join(dir, "CHANGELOG.md"), "# pkg\n\n## 1.0.0\n\n- x\n"); + assert.equal(extractChangelogNotes(dir, "9.9.9"), "Release 9.9.9"); + }); +}); + +describe("release paths (DRY)", () => { + it("RELEASE_TAGS_STATE and PRE_JSON live under ROOT", () => { + assert.ok(RELEASE_TAGS_STATE.startsWith(ROOT)); + assert.ok(PRE_JSON.startsWith(ROOT)); + assert.equal(path.basename(RELEASE_TAGS_STATE), ".sfmc-release-tags.json"); + }); +}); diff --git a/tools/changeset-tag.mjs b/tools/changeset-tag.mjs index 73e5224..9360dfd 100644 --- a/tools/changeset-tag.mjs +++ b/tools/changeset-tag.mjs @@ -2,68 +2,69 @@ /** * 为当前可发包创建 git tag(与 changeset publish 同格式: name@version)。 * 已存在的 tag 跳过。写入 .sfmc-release-tags.json 供后续 push / gh release 使用。 + * + * node tools/changeset-tag.mjs # 按 HEAD~1 版本 diff 打 tag + * node tools/changeset-tag.mjs --from-existing # 仅收录本地已有 name@version(CI publish 之后) + * + * CI=true 且未传 --create 时默认 --from-existing(changeset publish 已建 tag,避免浅克隆误打全量)。 */ -import fs from "node:fs"; import path from "node:path"; import { ROOT, git, gitCapture, - listPublishablePackages, - packageTagName, + resolvePackagesNeedingTags, + writeReleaseTagsState, + RELEASE_TAGS_STATE, } from "./lib/changeset-release.mjs"; -const statePath = path.join(ROOT, ".sfmc-release-tags.json"); +const fromExisting = + process.argv.includes("--from-existing") || + process.env.SFMC_TAG_FROM_EXISTING === "1" || + (process.env.CI === "true" && !process.argv.includes("--create")); -/** 相对上一提交(或 upstream)版本有变化的包;首次则全部打 tag */ -function packagesNeedingTags() { - const pkgs = listPublishablePackages(); - const headParent = gitCapture(["rev-parse", "HEAD~1"]); - if (!headParent) { - return pkgs.map((p) => ({ ...p, tag: packageTagName(p.name, p.version) })); - } - - const needed = []; - for (const p of pkgs) { - const rel = path.relative(ROOT, p.pkgPath).replace(/\\/g, "/"); - let prev = ""; - try { - const raw = gitCapture(["show", `HEAD~1:${rel}`]); - if (raw) prev = String(JSON.parse(raw).version ?? ""); - } catch { - prev = ""; - } - if (prev !== p.version) { - needed.push({ ...p, tag: packageTagName(p.name, p.version), prev }); - } - } - return needed; -} - -const needed = packagesNeedingTags(); +const needed = resolvePackagesNeedingTags({ fromExisting }); if (needed.length === 0) { - console.log("[changeset] 无包版本变化,跳过打 tag"); - fs.writeFileSync(statePath, JSON.stringify({ tags: [], createdAt: new Date().toISOString() }, null, 2)); + console.log( + fromExisting + ? "[changeset] 无已存在的 name@version tag 可收录,跳过" + : "[changeset] 无包版本变化,跳过打 tag" + ); + writeReleaseTagsState([]); process.exit(0); } const created = []; for (const p of needed) { - const exists = gitCapture(["tag", "-l", p.tag]); + const exists = Boolean(gitCapture(["tag", "-l", p.tag])); if (exists) { console.log(`[changeset] tag 已存在,跳过: ${p.tag}`); - created.push({ name: p.name, version: p.version, tag: p.tag, existed: true }); + created.push({ + name: p.name, + version: p.version, + tag: p.tag, + pkgPath: p.pkgPath, + existed: true, + }); + continue; + } + if (fromExisting) { + /* from-existing 契约:不新建 tag(LSP:与「仅收录」语义一致) */ continue; } git(["tag", "-a", p.tag, "-m", `Release ${p.tag}`]); - console.log( - `[changeset] tagged ${p.tag}` + (p.prev ? ` (${p.prev} → ${p.version})` : "") - ); - created.push({ name: p.name, version: p.version, tag: p.tag, existed: false }); + console.log(`[changeset] tagged ${p.tag}` + (p.prev ? ` (${p.prev} → ${p.version})` : "")); + created.push({ + name: p.name, + version: p.version, + tag: p.tag, + pkgPath: p.pkgPath, + existed: false, + }); } -fs.writeFileSync( - statePath, - JSON.stringify({ tags: created, createdAt: new Date().toISOString() }, null, 2) + "\n" +writeReleaseTagsState(created); +console.log( + `[changeset] 写入 ${path.relative(ROOT, RELEASE_TAGS_STATE)} (${created.length} tags)` + + (fromExisting ? " [from-existing]" : "") ); -console.log(`[changeset] 写入 ${path.relative(ROOT, statePath)} (${created.length} tags)`); diff --git a/tools/lib/changeset-release.mjs b/tools/lib/changeset-release.mjs index 9e6fc56..65899a4 100644 --- a/tools/lib/changeset-release.mjs +++ b/tools/lib/changeset-release.mjs @@ -11,6 +11,8 @@ import { NPM_PUBLISH_PACKAGES } from "./npm-publish-packages.mjs"; export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const PRE_JSON = path.join(ROOT, ".changeset", "pre.json"); export const CHANGESET_DIR = path.join(ROOT, ".changeset"); +/** 发版编排中间态:tag-packages → push-release / gh-release(DRY 唯一路径) */ +export const RELEASE_TAGS_STATE = path.join(ROOT, ".sfmc-release-tags.json"); /** @returns {{ mode: string, tag?: string } | null} */ export function readPreState() { @@ -45,11 +47,107 @@ export function listPublishablePackages() { return out; } +/** @returns {string | null} 包目录(package.json 所在目录) */ +export function packageDirFor(name) { + const hit = listPublishablePackages().find((p) => p.name === name); + return hit ? path.dirname(hit.pkgPath) : null; +} + /** changesets 默认 git tag: name@version */ export function packageTagName(name, version) { return `${name}@${version}`; } +/** + * @typedef {{ name: string, version: string, tag: string, pkgPath?: string, prev?: string, existed?: boolean }} ReleaseTagEntry + * @typedef {{ tags: ReleaseTagEntry[], createdAt: string }} ReleaseTagsState + */ + +/** @returns {ReleaseTagsState | null} */ +export function readReleaseTagsState() { + if (!fs.existsSync(RELEASE_TAGS_STATE)) return null; + try { + const state = JSON.parse(fs.readFileSync(RELEASE_TAGS_STATE, "utf8")); + if (!state || !Array.isArray(state.tags)) return null; + return state; + } catch { + return null; + } +} + +/** @param {ReleaseTagEntry[]} tags */ +export function writeReleaseTagsState(tags) { + const state = { + tags, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(RELEASE_TAGS_STATE, JSON.stringify(state, null, 2) + "\n"); + return state; +} + +/** + * 仅收录「当前版本对应 tag 已在本地存在」的可发包。 + * 用于:changeset publish 之后的 CI(publish 已建 tag),或浅克隆无法 diff 时的安全回退。 + * 切勿在「无历史 + 无 tag」时回退为全部打 tag——会误为未发版包建 GH Release。 + * @param {{ name: string, version: string, pkgPath: string }[]} [pkgs] + * @returns {ReleaseTagEntry[]} + */ +export function listPackagesWithExistingVersionTags(pkgs = listPublishablePackages()) { + const out = []; + for (const p of pkgs) { + const tag = packageTagName(p.name, p.version); + if (gitCapture(["tag", "-l", tag])) { + out.push({ name: p.name, version: p.version, pkgPath: p.pkgPath, tag }); + } + } + return out; +} + +/** + * 相对 HEAD~1 版本有变化的可发包;无法解析父提交时安全回退到 listPackagesWithExistingVersionTags。 + * @param {{ fromExisting?: boolean }} [opts] + * @returns {ReleaseTagEntry[]} + */ +export function resolvePackagesNeedingTags(opts = {}) { + const pkgs = listPublishablePackages(); + if (opts.fromExisting) { + return listPackagesWithExistingVersionTags(pkgs); + } + + const headParent = gitCapture(["rev-parse", "--verify", "HEAD~1^{commit}"]); + if (!headParent) { + console.warn( + "[changeset] 无法解析 HEAD~1(浅克隆?),回退为仅收录已存在的 name@version tag,避免误打全量 tag" + ); + return listPackagesWithExistingVersionTags(pkgs); + } + + /** @type {ReleaseTagEntry[]} */ + const needed = []; + for (const p of pkgs) { + const rel = path.relative(ROOT, p.pkgPath).replace(/\\/g, "/"); + let prev = ""; + const raw = gitCapture(["show", `${headParent}:${rel}`]); + if (raw) { + try { + prev = String(JSON.parse(raw).version ?? ""); + } catch { + prev = ""; + } + } + if (prev !== p.version) { + needed.push({ + name: p.name, + version: p.version, + pkgPath: p.pkgPath, + tag: packageTagName(p.name, p.version), + prev, + }); + } + } + return needed; +} + export function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { cwd: ROOT, @@ -62,12 +160,17 @@ export function run(cmd, args, opts = {}) { } } +/** + * @param {string[]} args + * @param {{ capture?: boolean } & import("node:child_process").ExecFileSyncOptions} [opts] + */ export function git(args, opts = {}) { + const { capture = false, ...rest } = opts; return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", - stdio: opts.capture ? ["ignore", "pipe", "pipe"] : "inherit", - ...opts, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + ...rest, }); } @@ -85,10 +188,8 @@ export function extractChangelogNotes(pkgDir, version) { if (!fs.existsSync(changelog)) return `Release ${version}`; const text = fs.readFileSync(changelog, "utf8"); const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp( - `## ${escaped}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`, - "m" - ); + /* 勿用 m 标志:否则 $ 会在每一行行尾匹配,非贪婪捕获只拿到首行 */ + const re = new RegExp(`## ${escaped}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`); const m = text.match(re); if (!m) return `Release ${version}`; const body = m[1].trim();