diff --git a/.changeset/build-deps-world-packs-solid.md b/.changeset/build-deps-world-packs-solid.md new file mode 100644 index 0000000..a0a50ab --- /dev/null +++ b/.changeset/build-deps-world-packs-solid.md @@ -0,0 +1,6 @@ +--- +"@sfmc-bds/tools": patch +"@sfmc-bds/bds-tools": patch +--- + +发版:build-publishable 拓扑 + listPublishableBuildDeps(npm-publish 应急补发不再硬编码只 build SDK);push 缺失态 DRY 对齐 listUnpushedExistingVersionTags。世界包:readPackDirOccupancy DRY,去掉死不变式,occupancy 保留真实 kind(LSP)。 diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index fe03188..9c0a6e7 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -37,15 +37,8 @@ jobs: - run: npm ci --no-audit --no-fund - name: build publishable workspaces - run: | - npm run build --workspace @sfmc-bds/sdk - npm run build --workspace @sfmc-bds/eslint-plugin --if-present - npm run build --workspace @sfmc-bds/bds-tools --if-present - npm run build --workspace @sfmc-bds/db-server --if-present - npm run build --workspace @sfmc-bds/qq-bridge --if-present - npm run build --workspace @sfmc-bds/cli --if-present - npm run build --workspace @sfmc-bds/tools --if-present - npm run build --workspace @sfmc-bds/sfmc --if-present + # DRY:包清单与拓扑权威在 tools/lib/npm-publish-packages.mjs,勿在此再抄 workspace 列表 + run: node tools/build-publishable.mjs # Version PR 需要「创建 PR」权限。仅用 GITHUB_TOKEN 且仓库关闭 # Settings→Actions→General→「Allow GitHub Actions to create and approve pull requests」时, diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d3eb166..b639a08 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -67,13 +67,10 @@ jobs: - run: npm ci --no-audit --no-fund if: steps.meta.outputs.already_published != 'true' - - name: build SDK (dependency) - if: steps.meta.outputs.already_published != 'true' && steps.meta.outputs.workspace != '@sfmc-bds/sdk' - run: npm run build --workspace @sfmc-bds/sdk - - - name: build target workspace + - name: build target + publishable deps if: steps.meta.outputs.already_published != 'true' - run: npm run build --workspace "${{ steps.meta.outputs.workspace }}" --if-present + # DRY/OCP:依赖闭包由 listPublishableBuildDeps 派生,勿硬编码只 build SDK + run: node tools/build-publishable.mjs --workspace "${{ steps.meta.outputs.workspace }}" - name: publish to npm if: steps.meta.outputs.already_published != 'true' diff --git a/bds-tools/src/world-packs.ts b/bds-tools/src/world-packs.ts index db27de4..1ac04ba 100644 --- a/bds-tools/src/world-packs.ts +++ b/bds-tools/src/world-packs.ts @@ -543,6 +543,8 @@ export type DestOccupancy = { version: [number, number, number] | null; /** 可读时的展示名;决策不依赖,仅供 conflict UI */ name?: string; + /** 可读时的包类型;决策展示优先用真实 kind,避免伪造 incoming.kind(LSP) */ + kind?: WorldPackKind; }; export type PackInstallPlan = @@ -562,13 +564,37 @@ export type PackInstallPlan = existing: PackManifestInfo & { dir: string }; }; +/** + * 读单目录占用事实(info 优先,否则 header)。 + * Utf8BomError 上抛,由调用方决定跳过或仍占文件夹名。 + */ +export function readPackDirOccupancy(dir: string): { + uuid: string; + version: [number, number, number]; + name?: string; + kind?: WorldPackKind; +} | null { + const info = readPackManifestInfo(dir); + if (info) { + return { + uuid: info.uuid, + version: info.version, + name: info.name, + kind: info.kind, + }; + } + const header = readPackManifestHeader(dir); + if (header) { + return { uuid: header.uuid, version: header.version }; + } + return null; +} + /** 扫描 destParent 下含 manifest 的目录占用 */ export function scanDestOccupancy(destParent: string): DestOccupancy[] { const out: DestOccupancy[] = []; for (const dir of listPackDirsIn(destParent)) { - let uuid: string | null = null; - let version: [number, number, number] | null = null; - let name: string | undefined; + let facts: ReturnType = null; try { const info = readPackManifestInfo(dir); if (info) { @@ -588,9 +614,10 @@ export function scanDestOccupancy(destParent: string): DestOccupancy[] { out.push({ folderName: path.basename(dir), dir, - uuid, - version, - ...(name ? { name } : {}), + uuid: facts?.uuid ?? null, + version: facts?.version ?? null, + ...(facts?.name ? { name: facts.name } : {}), + ...(facts?.kind ? { kind: facts.kind } : {}), }); } return out; @@ -611,24 +638,20 @@ export function decidePackInstallPlan(opts: { const same = opts.occupancy.find((o) => o.uuid != null && o.uuid.toLowerCase() === incomingUuid); if (same) { - const folderName = same.folderName; const existingVersion: [number, number, number] = same.version ?? [0, 0, 0]; const existing: PackManifestInfo & { dir: string } = { name: same.name ?? same.folderName, uuid: same.uuid!, version: existingVersion, - kind: opts.incoming.kind, + kind: same.kind ?? opts.incoming.kind, dir: same.dir, }; const newer = compareSemVer3(opts.incoming.version, existing.version) > 0; if (force || newer) { - if (folderName !== same.folderName) { - throw new Error("invariant: overwriteInPlace folderName must equal existing folder"); - } return { kind: "overwriteInPlace", - folderName, + folderName: same.folderName, dir: same.dir, incoming: opts.incoming, existing, @@ -636,7 +659,7 @@ export function decidePackInstallPlan(opts: { } return { kind: "needConfirm", - folderName, + folderName: same.folderName, dir: same.dir, incoming: opts.incoming, existing, diff --git a/bds-tools/world-packs.test.mjs b/bds-tools/world-packs.test.mjs index 6a1f637..0a734f7 100644 --- a/bds-tools/world-packs.test.mjs +++ b/bds-tools/world-packs.test.mjs @@ -539,6 +539,58 @@ describe("world-packs primitives", () => { assert.equal(still.header.uuid, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); }); + it("installPackDirectory:残缺 manifest 同 uuid → 按版本决策(非旁路新目录)", async () => { + const { installPackDirectory, formatWorldPackFolderName, readPackManifestInfo } = await import( + "./dist/world-packs.js" + ); + const dest = path.join(tmp, "broken-same-uuid"); + const folderName = formatWorldPackFolderName("Legacy", "behavior"); + const existingDir = path.join(dest, folderName); + fs.mkdirSync(existingDir, { recursive: true }); + const uuid = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; + fs.writeFileSync( + path.join(existingDir, "manifest.json"), + JSON.stringify({ + format_version: 2, + header: { name: "Legacy", uuid, version: [1, 0, 0] }, + modules: [], + }) + ); + const srcSame = path.join(tmp, "incoming-same-broken"); + writeManifest(srcSame, { + name: "Fixed", + uuid, + version: [1, 0, 0], + type: "data", + }); + const c = await installPackDirectory({ + srcDir: srcSame, + destParent: dest, + folderName: "OtherHint", + force: false, + }); + assert.equal(c.ok, false); + assert.equal(c.reason, "conflict"); + assert.equal(c.conflict?.existing.dir, existingDir); + + const srcUp = path.join(tmp, "incoming-up-broken"); + writeManifest(srcUp, { + name: "Fixed v2", + uuid, + version: [1, 1, 0], + type: "data", + }); + const up = await installPackDirectory({ + srcDir: srcUp, + destParent: dest, + folderName: "OtherHint", + force: false, + }); + assert.equal(up.ok, true, up.reason); + assert.equal(up.destDir, existingDir); + assert.equal(readPackManifestInfo(existingDir)?.version.join("."), "1.1.0"); + }); + it("uninstallInstalledPack:回收站 / purge / 目录缺失", async () => { const { uninstallInstalledPack, listInstalledWorldPacks } = await import("./dist/world-packs.js"); const bds = path.join(tmp, "uninstall-bds"); diff --git a/docs/dev/npm-publish.md b/docs/dev/npm-publish.md index 23f18f4..19ca4de 100644 --- a/docs/dev/npm-publish.md +++ b/docs/dev/npm-publish.md @@ -66,7 +66,8 @@ npm run prerelease-packages |------|--------| | `prerelease-packages` | **现在**(pre/beta) | | `release-packages` | 仅 `changeset pre exit` 之后(latest) | -| `ci-release-packages` | CI 专用(无交互)。`tag-packages` 默认按 `HEAD~1` 版本 diff;仅显式 `--from-existing` / `SFMC_TAG_FROM_EXISTING=1`(或浅克隆无法解析父提交)才只收录已有 tag。空的 `.sfmc-release-tags.json` 表示本轮无目标,下游不得回退扫全仓。 | +| `ci-release-packages` | CI 专用(无交互)。`tag-packages` 默认按 `HEAD~1` 版本 diff;仅显式 `--from-existing` / `SFMC_TAG_FROM_EXISTING=1`(或浅克隆无法解析父提交)才只收录已有 tag。空的 `.sfmc-release-tags.json` 表示本轮无目标,下游不得回退扫全仓。push / gh-release 缺失态分别回退 `listUnpushedExistingVersionTags` / `listPackagesWithExistingVersionTags`。 | +| `build:publishable` | 按 `NPM_PUBLISH_PACKAGES` 拓扑构建可发包;`node tools/build-publishable.mjs [--workspace ]`。changeset-release 全量、npm-publish 应急补发(含依赖闭包)共用,勿在 YAML 再抄 workspace/依赖列表。 | | `publish-packages` | 仅 npm publish 包装 | 需要:本机已登录 `gh`、npm(或 `NPM_TOKEN`),且对 `origin` 有推送权限。 @@ -96,6 +97,7 @@ npx changeset publish # → latest - 默认 `dist_tag=beta` - 若仍处于 pre mode,选择 `latest` 会被 workflow 拒绝 +- build 步骤调用 `build-publishable.mjs --workspace `:先按 `listPublishableBuildDeps` 构建可发包依赖(如 `@sfmc-bds/cli` → sdk + bds-tools),再构建目标;勿再硬编码只 build SDK ## 本地验证(发布前) diff --git a/package.json b/package.json index 22cf27b..10344b0 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "eslint-plugin:build": "npm run build --workspace @sfmc-bds/eslint-plugin", "eslint-plugin:test": "npm run test --workspace @sfmc-bds/eslint-plugin", "pack:verify": "node tools/pack-verify.mjs", + "build:publishable": "node tools/build-publishable.mjs", "changeset": "changeset", "version-packages": "changeset version", "publish-packages": "node tools/changeset-publish.mjs", diff --git a/tools/build-publishable.mjs b/tools/build-publishable.mjs new file mode 100755 index 0000000..f918fea --- /dev/null +++ b/tools/build-publishable.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * 按 NPM_PUBLISH_PACKAGES 构建可发包。 + * - 无参数:全量拓扑 build(changeset-release / 本地发版) + * - --workspace :先 build 可发包依赖闭包,再 build 目标(npm-publish 应急补发) + * 勿在 workflow YAML 里再抄 workspace / 依赖列表(DRY/OCP)。 + */ +import { spawnSync } from "node:child_process"; +import { + listPublishableBuildDeps, + listPublishableBuildOrder, + resolvePublishPackage, +} from "./lib/npm-publish-packages.mjs"; +import { ROOT } from "./lib/changeset-release.mjs"; + +/** + * @param {string} name + */ +function buildOne(name) { + console.log(`[build-publishable] npm run build --workspace ${name} --if-present`); + const r = spawnSync("npm", ["run", "build", "--workspace", name, "--if-present"], { + cwd: ROOT, + stdio: "inherit", + shell: process.platform === "win32", + }); + if (r.status !== 0) { + process.exit(r.status === null ? 1 : r.status); + } +} + +const args = process.argv.slice(2); +const wsIdx = args.indexOf("--workspace"); +if (wsIdx >= 0) { + const pkg = args[wsIdx + 1]; + if (!pkg) { + console.error("usage: node tools/build-publishable.mjs [--workspace ]"); + process.exit(2); + } + const resolved = resolvePublishPackage(pkg); + if (!resolved) { + console.error(`Unknown publish package: ${pkg}`); + process.exit(2); + } + const deps = listPublishableBuildDeps(resolved); + for (const d of deps) buildOne(d); + buildOne(resolved); + console.log(`[build-publishable] ok — ${resolved} (+${deps.length} deps)`); +} else { + const order = listPublishableBuildOrder(); + for (const name of order) buildOne(name); + console.log(`[build-publishable] ok — ${order.length} workspaces`); +} diff --git a/tools/changeset-push.mjs b/tools/changeset-push.mjs index 2151b9b..0cde5fc 100644 --- a/tools/changeset-push.mjs +++ b/tools/changeset-push.mjs @@ -6,6 +6,7 @@ import { git, gitCapture, + listUnpushedExistingVersionTags, readReleaseTagsState, resolveReleaseTagEntries, } from "./lib/changeset-release.mjs"; @@ -28,19 +29,10 @@ if (!tagsOnly) { } const state = readReleaseTagsState(); +/* DRY/LSP:缺失态与 gh-release 共用「当前可发包 + 当前版本 tag」候选,再过滤未推送 */ const entries = resolveReleaseTagEntries(state, () => { - /* 回退:仅当状态文件缺失时,推送本机尚未在 origin 的 name@version 风格 tag */ - console.warn("[changeset] 无 .sfmc-release-tags.json,回退扫描本地未推送 tag"); - /** @type {{ tag: string }[]} */ - const out = []; - const local = gitCapture(["tag", "-l", "@sfmc-bds/*"]) - .split(/\r?\n/) - .filter(Boolean); - for (const tag of local) { - const remote = gitCapture(["ls-remote", "--tags", "origin", `refs/tags/${tag}`]); - if (!remote) out.push({ tag }); - } - return out; + console.warn("[changeset] 无 .sfmc-release-tags.json,回退为未推送的当前 name@version tag"); + return listUnpushedExistingVersionTags(); }); const tags = entries.map((t) => t.tag).filter(Boolean); diff --git a/tools/changeset-release.test.mjs b/tools/changeset-release.test.mjs index 6fe2296..9fd151b 100644 --- a/tools/changeset-release.test.mjs +++ b/tools/changeset-release.test.mjs @@ -8,11 +8,13 @@ import { extractChangelogNotes, listPendingChangesetFiles, listPackagesWithExistingVersionTags, + listUnpushedExistingVersionTags, resolveReleaseTagEntries, RELEASE_TAGS_STATE, PRE_JSON, ROOT, } from "./lib/changeset-release.mjs"; +import { listPublishableBuildDeps, listPublishableBuildOrder, NPM_PUBLISH_PACKAGES } from "./lib/npm-publish-packages.mjs"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; @@ -81,6 +83,43 @@ describe("resolveReleaseTagEntries (LSP)", () => { assert.equal(e.tag, packageTagName(e.name, e.version)); } }); + + it("push missing-state fallback is listUnpushedExistingVersionTags (DRY/LSP)", () => { + /* 契约:changeset-push 不得再硬编码 @sfmc-bds/* 全量扫描 */ + const allExisting = listPackagesWithExistingVersionTags(); + const existingTags = new Set(allExisting.map((e) => e.tag)); + /** @type {ReturnType | undefined} */ + let fb; + const viaResolver = resolveReleaseTagEntries(null, () => { + fb = listUnpushedExistingVersionTags(); + return fb; + }); + assert.equal(viaResolver, fb); + assert.ok(Array.isArray(fb)); + for (const e of fb) { + assert.equal(e.tag, packageTagName(e.name, e.version)); + assert.ok(existingTags.has(e.tag), "unpushed 必须是 existing 的子集"); + } + }); +}); + +describe("listPublishableBuildOrder / Deps (DRY/OCP)", () => { + it("covers every NPM_PUBLISH_PACKAGES entry once; deps before dependents", () => { + const order = listPublishableBuildOrder(); + const keys = Object.keys(NPM_PUBLISH_PACKAGES); + assert.equal(order.length, keys.length); + assert.deepEqual([...order].sort(), [...keys].sort()); + assert.ok(order.indexOf("@sfmc-bds/sdk") < order.indexOf("@sfmc-bds/bds-tools")); + assert.ok(order.indexOf("@sfmc-bds/bds-tools") < order.indexOf("@sfmc-bds/cli")); + assert.ok(order.indexOf("@sfmc-bds/bds-tools") < order.indexOf("@sfmc-bds/tools")); + }); + + it("listPublishableBuildDeps walks publishable dependency closure", () => { + assert.deepEqual(listPublishableBuildDeps("@sfmc-bds/sdk"), []); + assert.deepEqual(listPublishableBuildDeps("@sfmc-bds/db-server"), ["@sfmc-bds/sdk"]); + assert.deepEqual(listPublishableBuildDeps("@sfmc-bds/cli"), ["@sfmc-bds/sdk", "@sfmc-bds/bds-tools"]); + assert.deepEqual(listPublishableBuildDeps("@sfmc-bds/tools"), ["@sfmc-bds/sdk", "@sfmc-bds/bds-tools"]); + }); }); describe("listPendingChangesetFiles (pre mode LSP)", () => { diff --git a/tools/lib/changeset-release.mjs b/tools/lib/changeset-release.mjs index bbe9ea6..5cf433d 100644 --- a/tools/lib/changeset-release.mjs +++ b/tools/lib/changeset-release.mjs @@ -125,6 +125,20 @@ export function listPackagesWithExistingVersionTags(pkgs = listPublishablePackag return out; } +/** + * 当前版本 tag 已在本地、且 origin 尚无同名 tag 的可发包。 + * push-release 缺失态回退权威实现:候选集与 gh-release 共用 + * listPackagesWithExistingVersionTags(DRY/LSP),再叠加未推送过滤。 + * @param {{ name: string, version: string, pkgPath: string }[]} [pkgs] + * @returns {ReleaseTagEntry[]} + */ +export function listUnpushedExistingVersionTags(pkgs = listPublishablePackages()) { + return listPackagesWithExistingVersionTags(pkgs).filter((e) => { + const remote = gitCapture(["ls-remote", "--tags", "origin", `refs/tags/${e.tag}`]); + return !remote; + }); +} + /** * 相对 HEAD~1 版本有变化的可发包;无法解析父提交时安全回退到 listPackagesWithExistingVersionTags。 * @param {{ fromExisting?: boolean }} [opts] diff --git a/tools/lib/npm-publish-packages.mjs b/tools/lib/npm-publish-packages.mjs index 21c3ce0..7a38a5f 100644 --- a/tools/lib/npm-publish-packages.mjs +++ b/tools/lib/npm-publish-packages.mjs @@ -26,6 +26,97 @@ export function resolvePublishPackage(pkg) { return null; } +/** + * 读取可发包 package.json 中落在 NPM_PUBLISH_PACKAGES 的直接依赖名。 + * @param {keyof typeof NPM_PUBLISH_PACKAGES} pkg + * @param {string} [repoRoot] + * @returns {(keyof typeof NPM_PUBLISH_PACKAGES)[]} + */ +function listDirectPublishableDeps(pkg, repoRoot = process.cwd()) { + const pkgPath = path.join(repoRoot, NPM_PUBLISH_PACKAGES[pkg]); + const pkgJson = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + const deps = { ...(pkgJson.dependencies || {}), ...(pkgJson.peerDependencies || {}) }; + /** @type {(keyof typeof NPM_PUBLISH_PACKAGES)[]} */ + const out = []; + for (const name of Object.keys(deps)) { + if (Object.prototype.hasOwnProperty.call(NPM_PUBLISH_PACKAGES, name) && name !== pkg) { + out.push(/** @type {keyof typeof NPM_PUBLISH_PACKAGES} */ (name)); + } + } + return out; +} + +/** + * 可发包全量 build 顺序:按 dependencies ∩ NPM_PUBLISH_PACKAGES 拓扑排序。 + * OCP:扩包只改清单 + 各包 deps,勿在 workflow 手抄优先级。 + * @param {string} [repoRoot] + * @returns {(keyof typeof NPM_PUBLISH_PACKAGES)[]} + */ +export function listPublishableBuildOrder(repoRoot = process.cwd()) { + const all = /** @type {(keyof typeof NPM_PUBLISH_PACKAGES)[]} */ (Object.keys(NPM_PUBLISH_PACKAGES)); + /** @type {Map} */ + const dependents = new Map(all.map((n) => [n, []])); + /** @type {Map} */ + const indeg = new Map(all.map((n) => [n, 0])); + + for (const name of all) { + for (const d of listDirectPublishableDeps(name, repoRoot)) { + dependents.get(d).push(name); + indeg.set(name, /** @type {number} */ (indeg.get(name)) + 1); + } + } + + /** @type {(keyof typeof NPM_PUBLISH_PACKAGES)[]} */ + const queue = all.filter((n) => indeg.get(n) === 0); + /** @type {(keyof typeof NPM_PUBLISH_PACKAGES)[]} */ + const out = []; + while (queue.length) { + const n = /** @type {keyof typeof NPM_PUBLISH_PACKAGES} */ (queue.shift()); + out.push(n); + for (const m of dependents.get(n) || []) { + const next = /** @type {number} */ (indeg.get(m)) - 1; + indeg.set(m, next); + if (next === 0) queue.push(/** @type {keyof typeof NPM_PUBLISH_PACKAGES} */ (m)); + } + } + if (out.length !== all.length) { + const leftover = all.filter((n) => !out.includes(n)); + throw new Error(`Publishable package dependency cycle: ${leftover.join(", ")}`); + } + return out; +} + +/** + * 构建目标包前必须先 build 的可发包依赖(拓扑序,不含自身)。 + * 权威:package.json dependencies ∩ NPM_PUBLISH_PACKAGES(DRY/DIP,勿硬编码 sdk)。 + * @param {string} pkg + * @param {string} [repoRoot] + * @returns {(keyof typeof NPM_PUBLISH_PACKAGES)[]} + */ +export function listPublishableBuildDeps(pkg, repoRoot = process.cwd()) { + const resolved = resolvePublishPackage(pkg); + if (!resolved) { + throw new Error(`Unknown publish package: ${pkg}`); + } + /** @type {Set} */ + const needed = new Set(); + /** @type {string[]} */ + const stack = [resolved]; + /** @type {Set} */ + const seen = new Set(); + while (stack.length) { + const cur = /** @type {keyof typeof NPM_PUBLISH_PACKAGES} */ (stack.pop()); + if (seen.has(cur)) continue; + seen.add(cur); + for (const d of listDirectPublishableDeps(cur, repoRoot)) { + if (d === resolved) continue; + needed.add(d); + stack.push(d); + } + } + return listPublishableBuildOrder(repoRoot).filter((n) => needed.has(n)); +} + /** * 判断相对仓根的目录是否落在 root workspaces 声明内。 * 支持精确项与末尾 `/*` 一层通配(与 npm workspaces 常见写法一致)。