From 2f6048174cae1956b33722931ebd5e9762808dd2 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 17 Mar 2026 22:41:21 +0800 Subject: [PATCH] fix(release): stage canary publish before latest --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 179 +++++++++++++++--- package.json | 3 +- packages/cli/package.json | 3 +- .../cli/src/__tests__/publish-package.test.ts | 83 +++++++- packages/core/package.json | 1 + scripts/set-package-versions.mjs | 74 ++++++++ scripts/verify-no-workspace-protocol.mjs | 77 +++++++- 8 files changed, 388 insertions(+), 34 deletions(-) create mode 100644 scripts/set-package-versions.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f663af5..7b4be676 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test + - run: pnpm verify:publish-manifests verify-pack: runs-on: ubuntu-latest @@ -46,6 +47,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm verify:publish-manifests - name: Verify no workspace:* in tarballs run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d00f3517..a3d696fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ jobs: - run: pnpm --filter @actalk/inkos-core typecheck - run: pnpm --filter @actalk/inkos typecheck - run: pnpm test + - run: pnpm verify:publish-manifests smoke-test: runs-on: ubuntu-latest @@ -50,6 +51,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm verify:publish-manifests - name: Smoke test — CLI help and version run: | @@ -79,9 +81,12 @@ jobs: cd - >/dev/null done - publish: + publish-canary: runs-on: ubuntu-latest needs: smoke-test + outputs: + release_version: ${{ steps.versions.outputs.release_version }} + canary_version: ${{ steps.versions.outputs.canary_version }} steps: - uses: actions/checkout@v4 @@ -97,41 +102,171 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm verify:publish-manifests - # Use npm publish (not pnpm) to ensure prepack hook replaces workspace:* - - name: Publish core - working-directory: packages/core - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Publish CLI - working-directory: packages/cli - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # Post-publish verification: install from npm and check it works - - name: Verify published package + - name: Derive release versions + id: versions run: | + RELEASE_VERSION="${GITHUB_REF_NAME#v}" + CANARY_VERSION="${RELEASE_VERSION}-canary.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "canary_version=$CANARY_VERSION" >> "$GITHUB_OUTPUT" + + - name: Rewrite package versions for canary publish + run: | + node scripts/set-package-versions.mjs "${{ steps.versions.outputs.canary_version }}" --root . + pnpm verify:publish-manifests + + - name: Publish core canary + working-directory: packages/core + run: pnpm publish --tag canary --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish CLI canary + working-directory: packages/cli + run: pnpm publish --tag canary --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + verify-canary: + runs-on: ubuntu-latest + needs: publish-canary + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Verify canary dist-tag and installation + run: | + set -euo pipefail + EXPECTED_CANARY="${{ needs.publish-canary.outputs.canary_version }}" + + ACTUAL_CANARY="" + for _ in 1 2 3 4 5 6; do + ACTUAL_CANARY=$(npm view @actalk/inkos@canary version 2>/dev/null || true) + if [ "$ACTUAL_CANARY" = "$EXPECTED_CANARY" ]; then + break + fi + sleep 10 + done + + if [ "$ACTUAL_CANARY" != "$EXPECTED_CANARY" ]; then + echo "FATAL: canary dist-tag mismatch: expected $EXPECTED_CANARY got ${ACTUAL_CANARY:-}" + exit 1 + fi + TMPDIR=$(mktemp -d) cd "$TMPDIR" npm init -y - npm install @actalk/inkos@latest - # Must not contain workspace: in installed package.json + npm install "@actalk/inkos@$EXPECTED_CANARY" + if grep -q '"workspace:' node_modules/@actalk/inkos/package.json; then - echo "FATAL: published package still contains workspace: protocol" + echo "FATAL: canary CLI package still contains workspace: protocol" cat node_modules/@actalk/inkos/package.json | grep workspace exit 1 fi - # Must be runnable + + if grep -q '"workspace:' node_modules/@actalk/inkos-core/package.json; then + echo "FATAL: canary core package still contains workspace: protocol" + cat node_modules/@actalk/inkos-core/package.json | grep workspace + exit 1 + fi + npx inkos --version - echo "Post-publish verification passed" + echo "Canary verification passed" + rm -rf "$TMPDIR" + + publish-release: + runs-on: ubuntu-latest + needs: [publish-canary, verify-canary] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm verify:publish-manifests + + - name: Rewrite package versions for final publish + run: | + node scripts/set-package-versions.mjs "${{ needs.publish-canary.outputs.release_version }}" --root . + pnpm verify:publish-manifests + + - name: Publish core latest + working-directory: packages/core + run: pnpm publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish CLI latest + working-directory: packages/cli + run: pnpm publish --access public --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + verify-release: + runs-on: ubuntu-latest + needs: [publish-canary, publish-release] + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Verify latest dist-tag and installation + run: | + set -euo pipefail + EXPECTED_RELEASE="${{ needs.publish-canary.outputs.release_version }}" + + ACTUAL_LATEST="" + for _ in 1 2 3 4 5 6; do + ACTUAL_LATEST=$(npm view @actalk/inkos@latest version 2>/dev/null || true) + if [ "$ACTUAL_LATEST" = "$EXPECTED_RELEASE" ]; then + break + fi + sleep 10 + done + + if [ "$ACTUAL_LATEST" != "$EXPECTED_RELEASE" ]; then + echo "FATAL: latest dist-tag mismatch: expected $EXPECTED_RELEASE got ${ACTUAL_LATEST:-}" + exit 1 + fi + + TMPDIR=$(mktemp -d) + cd "$TMPDIR" + npm init -y + npm install "@actalk/inkos@$EXPECTED_RELEASE" + + if grep -q '"workspace:' node_modules/@actalk/inkos/package.json; then + echo "FATAL: released CLI package still contains workspace: protocol" + cat node_modules/@actalk/inkos/package.json | grep workspace + exit 1 + fi + + if grep -q '"workspace:' node_modules/@actalk/inkos-core/package.json; then + echo "FATAL: released core package still contains workspace: protocol" + cat node_modules/@actalk/inkos-core/package.json | grep workspace + exit 1 + fi + + npx inkos --version + echo "Release verification passed" rm -rf "$TMPDIR" github-release: runs-on: ubuntu-latest - needs: publish + needs: verify-release steps: - uses: actions/checkout@v4 with: diff --git a/package.json b/package.json index dd676b66..668fcb2d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test": "pnpm -r test", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", - "release": "pnpm build && pnpm test && pnpm -r --filter './packages/*' publish --no-git-checks" + "verify:publish-manifests": "node scripts/verify-no-workspace-protocol.mjs packages/core packages/cli", + "release": "pnpm build && pnpm test && pnpm verify:publish-manifests" }, "engines": { "node": ">=20.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index c39b444c..fdead6e8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,13 +19,14 @@ "scripts": { "prepack": "node ../../scripts/prepare-package-for-publish.mjs", "postpack": "node ../../scripts/restore-package-json.mjs", + "prepublishOnly": "node ../../scripts/verify-no-workspace-protocol.mjs .", "build": "tsc", "dev": "tsc --watch", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit" }, "dependencies": { - "@actalk/inkos-core": "workspace:*", + "@actalk/inkos-core": "0.4.6", "commander": "^13.0.0", "dotenv": "^16.4.0", "epub-gen-memory": "^1.0.10", diff --git a/packages/cli/src/__tests__/publish-package.test.ts b/packages/cli/src/__tests__/publish-package.test.ts index 8c834e1e..c01902f4 100644 --- a/packages/cli/src/__tests__/publish-package.test.ts +++ b/packages/cli/src/__tests__/publish-package.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,6 +28,87 @@ async function extractPackedPackageJson(packDir: string) { } describe("publish packaging", () => { + it("rewrites workspace package versions for canary publishing", async () => { + const tempRoot = await mkdtemp(join(tmpdir(), "inkos-version-script-")); + const tempPackagesDir = join(tempRoot, "packages"); + const tempCoreDir = join(tempPackagesDir, "core"); + const tempCliDir = join(tempPackagesDir, "cli"); + + try { + await mkdir(tempCoreDir, { recursive: true }); + await mkdir(tempCliDir, { recursive: true }); + + await writeFile( + join(tempRoot, "package.json"), + `${JSON.stringify({ name: "inkos", version: "0.4.6" }, null, 2)}\n`, + ); + await writeFile( + join(tempCoreDir, "package.json"), + `${JSON.stringify({ name: "@actalk/inkos-core", version: "0.4.6" }, null, 2)}\n`, + ); + await writeFile( + join(tempCliDir, "package.json"), + `${JSON.stringify( + { + name: "@actalk/inkos", + version: "0.4.6", + dependencies: { + "@actalk/inkos-core": "0.4.6", + commander: "^13.0.0", + }, + }, + null, + 2, + )}\n`, + ); + + execFileSync( + "node", + [resolve(workspaceRoot, "scripts/set-package-versions.mjs"), "0.4.8-canary.7", "--root", tempRoot], + { + cwd: workspaceRoot, + env: process.env, + encoding: "utf-8", + }, + ); + + const rootPackageJson = JSON.parse(await readFile(join(tempRoot, "package.json"), "utf-8")); + const corePackageJson = JSON.parse(await readFile(join(tempCoreDir, "package.json"), "utf-8")); + const cliPackageJson = JSON.parse(await readFile(join(tempCliDir, "package.json"), "utf-8")); + + expect(rootPackageJson.version).toBe("0.4.8-canary.7"); + expect(corePackageJson.version).toBe("0.4.8-canary.7"); + expect(cliPackageJson.version).toBe("0.4.8-canary.7"); + expect(cliPackageJson.dependencies["@actalk/inkos-core"]).toBe("0.4.8-canary.7"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("keeps publishable CLI dependencies installable in source package.json", async () => { + const cliPackageJson = JSON.parse(await readFile(resolve(cliDir, "package.json"), "utf-8")); + const corePackageJson = JSON.parse( + await readFile(resolve(workspaceRoot, "packages/core/package.json"), "utf-8"), + ); + + expect(cliPackageJson.dependencies["@actalk/inkos-core"]).toBe(corePackageJson.version); + expect(cliPackageJson.dependencies["@actalk/inkos-core"]).not.toMatch(/^workspace:/); + }); + + it("verifies publishable manifests before npm publish runs", async () => { + const cliPackageJson = JSON.parse(await readFile(resolve(cliDir, "package.json"), "utf-8")); + const corePackageJson = JSON.parse( + await readFile(resolve(workspaceRoot, "packages/core/package.json"), "utf-8"), + ); + + expect(cliPackageJson.scripts.prepublishOnly).toBe( + "node ../../scripts/verify-no-workspace-protocol.mjs .", + ); + expect(corePackageJson.scripts.prepublishOnly).toBe( + "node ../../scripts/verify-no-workspace-protocol.mjs .", + ); + }); + it("replaces workspace dependencies before npm pack", async () => { const packDir = await mkdtemp(join(tmpdir(), "inkos-cli-pack-")); diff --git a/packages/core/package.json b/packages/core/package.json index a0259219..41a221c8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,7 @@ "scripts": { "prepack": "node ../../scripts/prepare-package-for-publish.mjs", "postpack": "node ../../scripts/restore-package-json.mjs", + "prepublishOnly": "node ../../scripts/verify-no-workspace-protocol.mjs .", "build": "tsc", "dev": "tsc --watch", "test": "vitest run", diff --git a/scripts/set-package-versions.mjs b/scripts/set-package-versions.mjs new file mode 100644 index 00000000..1dd83b9b --- /dev/null +++ b/scripts/set-package-versions.mjs @@ -0,0 +1,74 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +function parseArgs(argv) { + const [version, ...rest] = argv; + if (!version) { + throw new Error("Usage: node scripts/set-package-versions.mjs [--root ]"); + } + + let root = process.cwd(); + for (let i = 0; i < rest.length; i++) { + if (rest[i] === "--root") { + root = rest[i + 1]; + i += 1; + } + } + + return { version, root: resolve(root) }; +} + +async function loadWorkspacePackages(root) { + const packagesDir = join(root, "packages"); + const entries = await readdir(packagesDir); + const packages = []; + + for (const entry of entries) { + try { + const dir = join(packagesDir, entry); + const packageJsonPath = join(dir, "package.json"); + const pkg = JSON.parse(await readFile(packageJsonPath, "utf-8")); + packages.push({ dir, packageJsonPath, pkg }); + } catch { + // ignore non-package directories + } + } + + return packages; +} + +function rewriteDependencyVersions(pkg, workspacePackageNames, version) { + for (const field of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies"]) { + const deps = pkg[field]; + if (!deps) continue; + + for (const name of Object.keys(deps)) { + if (workspacePackageNames.has(name)) { + deps[name] = version; + } + } + } +} + +async function main() { + const { version, root } = parseArgs(process.argv.slice(2)); + const workspacePackages = await loadWorkspacePackages(root); + const workspacePackageNames = new Set(workspacePackages.map(({ pkg }) => pkg.name)); + + const rootPackageJsonPath = join(root, "package.json"); + const rootPackageJson = JSON.parse(await readFile(rootPackageJsonPath, "utf-8")); + rootPackageJson.version = version; + await writeFile(rootPackageJsonPath, `${JSON.stringify(rootPackageJson, null, 2)}\n`, "utf-8"); + + for (const workspacePackage of workspacePackages) { + workspacePackage.pkg.version = version; + rewriteDependencyVersions(workspacePackage.pkg, workspacePackageNames, version); + await writeFile( + workspacePackage.packageJsonPath, + `${JSON.stringify(workspacePackage.pkg, null, 2)}\n`, + "utf-8", + ); + } +} + +await main(); diff --git a/scripts/verify-no-workspace-protocol.mjs b/scripts/verify-no-workspace-protocol.mjs index 138067ec..d472f313 100644 --- a/scripts/verify-no-workspace-protocol.mjs +++ b/scripts/verify-no-workspace-protocol.mjs @@ -1,16 +1,17 @@ /** - * Standalone verification: checks that no workspace: protocol remains in package.json. + * Standalone verification for publishable package manifests. * * Usage: * node scripts/verify-no-workspace-protocol.mjs packages/cli packages/core + * node ../../scripts/verify-no-workspace-protocol.mjs . * - * Used by CI and as a pre-publish sanity check. The prepack script also runs - * this check inline after replacement, but this script catches the case where - * prepack is skipped entirely. + * Checks two invariants before publish: + * 1. publishable dependency fields must not contain workspace: specifiers + * 2. internal workspace dependencies must match the current workspace package version exactly */ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { access, readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; const dirs = process.argv.slice(2); if (dirs.length === 0) { @@ -18,12 +19,59 @@ if (dirs.length === 0) { process.exit(1); } -let failed = false; +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} -for (const dir of dirs) { +async function findWorkspaceRoot(startDir) { + let dir = resolve(startDir); + + for (let i = 0; i < 10; i++) { + if (await exists(join(dir, "packages"))) { + return dir; + } + + const parent = resolve(dir, ".."); + if (parent === dir) break; + dir = parent; + } + + throw new Error(`Could not find workspace root from ${startDir}`); +} + +async function loadWorkspaceVersions(workspaceRoot) { + const packagesDir = join(workspaceRoot, "packages"); + const entries = await readdir(packagesDir); + const versions = new Map(); + + for (const entry of entries) { + try { + const raw = await readFile(join(packagesDir, entry, "package.json"), "utf-8"); + const pkg = JSON.parse(raw); + versions.set(pkg.name, pkg.version); + } catch { + // ignore directories that are not publishable packages + } + } + + return versions; +} + +let failed = false; +const workspaceRoot = await findWorkspaceRoot(process.cwd()); +const workspaceVersions = await loadWorkspaceVersions(workspaceRoot); + +for (const dirArg of dirs) { + const dir = resolve(process.cwd(), dirArg); const packageJsonPath = join(dir, "package.json"); const raw = await readFile(packageJsonPath, "utf-8"); const pkg = JSON.parse(raw); + let dirFailed = false; for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { const deps = pkg[field]; @@ -31,12 +79,23 @@ for (const dir of dirs) { for (const [name, specifier] of Object.entries(deps)) { if (typeof specifier === "string" && specifier.startsWith("workspace:")) { process.stderr.write(`FAIL: ${dir} — ${field}.${name}: ${specifier}\n`); + dirFailed = true; + failed = true; + continue; + } + + const workspaceVersion = workspaceVersions.get(name); + if (workspaceVersion && specifier !== workspaceVersion) { + process.stderr.write( + `FAIL: ${dir} — ${field}.${name}: expected ${workspaceVersion}, got ${specifier}\n`, + ); + dirFailed = true; failed = true; } } } - if (!failed) { + if (!dirFailed) { process.stderr.write(`OK: ${dir}\n`); } }