Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/build-deps-world-packs-solid.md
Original file line number Diff line number Diff line change
@@ -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)。
11 changes: 2 additions & 9 deletions .github/workflows/changeset-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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」时,
Expand Down
9 changes: 3 additions & 6 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
49 changes: 36 additions & 13 deletions bds-tools/src/world-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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<typeof readPackDirOccupancy> = null;
try {
const info = readPackManifestInfo(dir);
if (info) {
Expand All @@ -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;
Expand All @@ -611,32 +638,28 @@ 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,
};
}
return {
kind: "needConfirm",
folderName,
folderName: same.folderName,
dir: same.dir,
incoming: opts.incoming,
existing,
Expand Down
52 changes: 52 additions & 0 deletions bds-tools/world-packs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion docs/dev/npm-publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>]`。changeset-release 全量、npm-publish 应急补发(含依赖闭包)共用,勿在 YAML 再抄 workspace/依赖列表。 |
| `publish-packages` | 仅 npm publish 包装 |

需要:本机已登录 `gh`、npm(或 `NPM_TOKEN`),且对 `origin` 有推送权限。
Expand Down Expand Up @@ -96,6 +97,7 @@ npx changeset publish # → latest

- 默认 `dist_tag=beta`
- 若仍处于 pre mode,选择 `latest` 会被 workflow 拒绝
- build 步骤调用 `build-publishable.mjs --workspace <pkg>`:先按 `listPublishableBuildDeps` 构建可发包依赖(如 `@sfmc-bds/cli` → sdk + bds-tools),再构建目标;勿再硬编码只 build SDK

## 本地验证(发布前)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions tools/build-publishable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* 按 NPM_PUBLISH_PACKAGES 构建可发包。
* - 无参数:全量拓扑 build(changeset-release / 本地发版)
* - --workspace <pkg>:先 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 <pkg>]");
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`);
}
16 changes: 4 additions & 12 deletions tools/changeset-push.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import {
git,
gitCapture,
listUnpushedExistingVersionTags,
readReleaseTagsState,
resolveReleaseTagEntries,
} from "./lib/changeset-release.mjs";
Expand All @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions tools/changeset-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof listUnpushedExistingVersionTags> | 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)", () => {
Expand Down
14 changes: 14 additions & 0 deletions tools/lib/changeset-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading