Răsfoiți Sursa

fix(release): stage canary publish before latest

Ma 7 luni în urmă
părinte
comite
2f6048174c

+ 2 - 0
.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: |

+ 149 - 14
.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
+      - 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@$EXPECTED_CANARY"
+
+          if grep -q '"workspace:' node_modules/@actalk/inkos/package.json; then
+            echo "FATAL: canary 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: canary core package still contains workspace: protocol"
+            cat node_modules/@actalk/inkos-core/package.json | grep workspace
+            exit 1
+          fi
+
+          npx inkos --version
+          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: npm publish --access public
+        run: pnpm publish --access public --no-git-checks
         env:
           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
 
-      - name: Publish CLI
+      - name: Publish CLI latest
         working-directory: packages/cli
-        run: npm publish --access public
+        run: pnpm publish --access public --no-git-checks
         env:
           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
 
-      # Post-publish verification: install from npm and check it works
-      - name: Verify published package
+  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@latest
-          # Must not contain workspace: in installed package.json
+          npm install "@actalk/inkos@$EXPECTED_RELEASE"
+
           if grep -q '"workspace:' node_modules/@actalk/inkos/package.json; then
-            echo "FATAL: published package still contains workspace: protocol"
+            echo "FATAL: released 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: released 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 "Release verification passed"
           rm -rf "$TMPDIR"
 
   github-release:
     runs-on: ubuntu-latest
-    needs: publish
+    needs: verify-release
     steps:
       - uses: actions/checkout@v4
         with:

+ 2 - 1
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",

+ 2 - 1
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",

+ 82 - 1
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-"));
 

+ 1 - 0
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",

+ 74 - 0
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 <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();

+ 67 - 8
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);
 }
 
+async function exists(path) {
+  try {
+    await access(path);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+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 dir of dirs) {
+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`);
   }
 }