fix(release): stage canary publish before latest

This commit is contained in:
Ma 2026-03-17 22:41:21 +08:00
parent 816aacb746
commit 2f6048174c
8 changed files with 388 additions and 34 deletions

View file

@ -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: |

View file

@ -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:-<empty>}"
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:-<empty>}"
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:

View file

@ -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",

View file

@ -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",

View file

@ -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-"));

View file

@ -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",

View file

@ -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 <version> [--root <path>]");
}
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();

View file

@ -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`);
}
}