From 48ce65116015b4510d36f1cc5d1d7f48fb0ea01d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 17:29:49 -0700 Subject: [PATCH] fix(ci): gate new release packages on npm bootstrap --- .github/workflows/pr.yml | 8 ++ .github/workflows/release.yml | 12 ++ doc/PUBLISHING.md | 14 ++ doc/RELEASE-AUTOMATION-SETUP.md | 21 +++ package.json | 2 +- scripts/check-release-package-bootstrap.mjs | 63 +++++++++ scripts/release-package-manifest.json | 92 ++++++++++++ scripts/release-package-map.mjs | 146 +++++++++++++++++--- scripts/release-package-map.test.mjs | 29 ++++ 9 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 scripts/check-release-package-bootstrap.mjs create mode 100644 scripts/release-package-manifest.json create mode 100644 scripts/release-package-map.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ad802136472..5ee1ece454e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -45,6 +45,14 @@ jobs: - name: Validate Dockerfile deps stage run: node ./scripts/check-docker-deps-stage.mjs + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Verify release package bootstrap for changed manifests + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" + node ./scripts/check-release-package-bootstrap.mjs $changed + - name: Validate dependency resolution when manifests change run: | changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b5983ccd1f..c16bad2323d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -89,6 +92,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -139,6 +145,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -177,6 +186,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 784a12fd6a2..7a4602ad3d0 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -176,6 +176,20 @@ That means: See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps. +## Release enrollment for new public packages + +Paperclip does not auto-publish every non-private workspace package anymore. +CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json). + +When you add a new public package: + +1. add it to the manifest and decide whether CI should publish it immediately +2. if CI should publish it, bootstrap the package on npm before merge +3. if CI should not publish it yet, keep `"publishFromCi": false` +4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package + +PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`. + ## Rollback model Rollback does not unpublish anything. diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index 25982892d35..d6e08b9f1a5 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -67,6 +67,27 @@ Why: - the single `release.yml` workflow handles both canary and stable publishing - GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side +### 2.2.1. Newly added public packages need a bootstrap phase + +Trusted publishing is configured on the npm package itself, not at the repo scope. +That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured. + +Repo policy: + +1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json) +2. set `"publishFromCi": true` only when CI is expected to publish that package +3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false` +4. complete the package bootstrap before merging any PR that changes a release-enabled new package + +Bootstrap sequence for a new package: + +1. publish the package once from a trusted maintainer machine using normal npm auth +2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml` +3. rerun or dry-run the release flow as needed to confirm CI publishing now works +4. only then enable `"publishFromCi": true` + +PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases. + ### 2.3. Verify trusted publishing before removing old auth After the workflows are live: diff --git a/package.json b/package.json index 7a9ec0c27f4..9f7186bab2b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs", + "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", diff --git a/scripts/check-release-package-bootstrap.mjs b/scripts/check-release-package-bootstrap.mjs new file mode 100644 index 00000000000..d5ebd603875 --- /dev/null +++ b/scripts/check-release-package-bootstrap.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +import { buildReleasePackagePlan } from "./release-package-map.mjs"; + +function normalizePath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function npmPackageExists(packageName) { + try { + execFileSync("npm", ["view", packageName, "name", "--json"], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + return true; + } catch { + return false; + } +} + +function main(changedPaths) { + const normalizedChangedPaths = changedPaths.map(normalizePath); + const releasePackages = buildReleasePackagePlan(); + const changedReleasePackages = []; + const seen = new Set(); + + for (const pkg of releasePackages) { + if (!pkg.publishFromCi) continue; + const packageJsonPath = `${pkg.dir}/package.json`; + + if (!normalizedChangedPaths.includes(packageJsonPath)) continue; + if (seen.has(pkg.name)) continue; + + changedReleasePackages.push(pkg); + seen.add(pkg.name); + } + + if (changedReleasePackages.length === 0) { + process.stdout.write("No release-enabled package manifests changed in this PR.\n"); + return; + } + + const missingPackages = changedReleasePackages.filter((pkg) => !npmPackageExists(pkg.name)); + + if (missingPackages.length > 0) { + const details = missingPackages + .map( + (pkg) => + `${pkg.name} (${pkg.dir}) is release-enabled but does not exist on npm yet; bootstrap the first publish before merge or keep it out of CI release enrollment`, + ) + .join("\n- "); + + throw new Error(`release package bootstrap check failed:\n- ${details}`); + } + + process.stdout.write( + `Release bootstrap OK for changed manifests: ${changedReleasePackages.map((pkg) => pkg.name).join(", ")}\n`, + ); +} + +main(process.argv.slice(2)); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json new file mode 100644 index 00000000000..a81cf28f93b --- /dev/null +++ b/scripts/release-package-manifest.json @@ -0,0 +1,92 @@ +[ + { + "dir": "packages/adapter-utils", + "name": "@paperclipai/adapter-utils", + "publishFromCi": true + }, + { + "dir": "packages/adapters/acpx-local", + "name": "@paperclipai/adapter-acpx-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/claude-local", + "name": "@paperclipai/adapter-claude-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/codex-local", + "name": "@paperclipai/adapter-codex-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/cursor-local", + "name": "@paperclipai/adapter-cursor-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/gemini-local", + "name": "@paperclipai/adapter-gemini-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/opencode-local", + "name": "@paperclipai/adapter-opencode-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/pi-local", + "name": "@paperclipai/adapter-pi-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/openclaw-gateway", + "name": "@paperclipai/adapter-openclaw-gateway", + "publishFromCi": true + }, + { + "dir": "packages/shared", + "name": "@paperclipai/shared", + "publishFromCi": true + }, + { + "dir": "packages/db", + "name": "@paperclipai/db", + "publishFromCi": true + }, + { + "dir": "packages/plugins/sdk", + "name": "@paperclipai/plugin-sdk", + "publishFromCi": true + }, + { + "dir": "server", + "name": "@paperclipai/server", + "publishFromCi": true + }, + { + "dir": "cli", + "name": "paperclipai", + "publishFromCi": true + }, + { + "dir": "packages/mcp-server", + "name": "@paperclipai/mcp-server", + "publishFromCi": true + }, + { + "dir": "packages/plugins/create-paperclip-plugin", + "name": "@paperclipai/create-paperclip-plugin", + "publishFromCi": true + }, + { + "dir": "packages/plugins/sandbox-providers/e2b", + "name": "@paperclipai/plugin-e2b", + "publishFromCi": true + }, + { + "dir": "ui", + "name": "@paperclipai/ui", + "publishFromCi": true + } +] diff --git a/scripts/release-package-map.mjs b/scripts/release-package-map.mjs index 60deba31044..a990b88ef78 100644 --- a/scripts/release-package-map.mjs +++ b/scripts/release-package-map.mjs @@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); +const manifestPath = join(repoRoot, "scripts", "release-package-manifest.json"); const roots = ["packages", "server", "ui", "cli"]; function readJson(filePath) { @@ -48,6 +49,84 @@ function discoverPublicPackages() { return packages; } +function loadReleaseManifest() { + const manifest = readJson(manifestPath); + + if (!Array.isArray(manifest)) { + throw new Error(`expected ${manifestPath} to contain an array.`); + } + + return manifest.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} must be an object.`); + } + + if (typeof entry.dir !== "string" || entry.dir.length === 0) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "dir".`); + } + + if (typeof entry.name !== "string" || entry.name.length === 0) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "name".`); + } + + if (typeof entry.publishFromCi !== "boolean") { + throw new Error( + `manifest entry ${index + 1} (${entry.dir}) in ${manifestPath} must set boolean "publishFromCi".`, + ); + } + + return entry; + }); +} + +function buildReleasePackagePlan() { + const discoveredPackages = discoverPublicPackages(); + const manifestEntries = loadReleaseManifest(); + const packageByDir = new Map(discoveredPackages.map((pkg) => [pkg.dir, pkg])); + const manifestByDir = new Map(); + const problems = []; + + for (const entry of manifestEntries) { + if (manifestByDir.has(entry.dir)) { + problems.push(`duplicate manifest entry for ${entry.dir}`); + continue; + } + + manifestByDir.set(entry.dir, entry); + const pkg = packageByDir.get(entry.dir); + + if (!pkg) { + problems.push(`${entry.dir} is listed in ${manifestPath} but is not a public package in this repo`); + continue; + } + + if (pkg.name !== entry.name) { + problems.push( + `${entry.dir} is listed as ${entry.name} in ${manifestPath}, but package.json declares ${pkg.name}`, + ); + } + } + + for (const pkg of discoveredPackages) { + if (!manifestByDir.has(pkg.dir)) { + problems.push( + `${pkg.dir} (${pkg.name}) is public but missing from ${manifestPath}; add it with publishFromCi true or false`, + ); + } + } + + if (problems.length > 0) { + throw new Error(`release package manifest validation failed:\n- ${problems.join("\n- ")}`); + } + + const packages = discoveredPackages.map((pkg) => ({ + ...pkg, + publishFromCi: manifestByDir.get(pkg.dir).publishFromCi, + })); + + return packages; +} + function sortTopologically(packages) { const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); const visited = new Set(); @@ -57,7 +136,7 @@ function sortTopologically(packages) { function visit(pkg) { if (visited.has(pkg.name)) return; if (visiting.has(pkg.name)) { - throw new Error(`cycle detected in public package graph at ${pkg.name}`); + throw new Error(`cycle detected in release package graph at ${pkg.name}`); } visiting.add(pkg.name); @@ -87,6 +166,10 @@ function sortTopologically(packages) { return ordered; } +function getReleasePackages() { + return sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi)); +} + function replaceWorkspaceDeps(deps, version) { if (!deps) return deps; const next = { ...deps }; @@ -101,7 +184,7 @@ function replaceWorkspaceDeps(deps, version) { } function setVersion(version) { - const packages = sortTopologically(discoverPublicPackages()); + const packages = getReleasePackages(); for (const pkg of packages) { const nextPkg = { @@ -134,17 +217,32 @@ function setVersion(version) { } function listPackages() { - const packages = sortTopologically(discoverPublicPackages()); + const packages = getReleasePackages(); for (const pkg of packages) { process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`); } } +function checkConfiguration() { + const packages = buildReleasePackagePlan(); + const enabledCount = packages.filter((pkg) => pkg.publishFromCi).length; + const disabledCount = packages.length - enabledCount; + + if (enabledCount === 0) { + throw new Error(`no packages are enabled for CI publishing in ${manifestPath}`); + } + + process.stdout.write( + `Release package manifest OK: ${enabledCount} enabled for CI publish, ${disabledCount} disabled pending bootstrap.\n`, + ); +} + function usage() { process.stderr.write( [ "Usage:", " node scripts/release-package-map.mjs list", + " node scripts/release-package-map.mjs check", " node scripts/release-package-map.mjs set-version ", "", ].join("\n"), @@ -152,20 +250,36 @@ function usage() { } const [command, arg] = process.argv.slice(2); +const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (command === "list") { - listPackages(); - process.exit(0); -} - -if (command === "set-version") { - if (!arg) { - usage(); - process.exit(1); +if (isDirectRun) { + if (command === "list") { + listPackages(); + process.exit(0); } - setVersion(arg); - process.exit(0); + + if (command === "check") { + checkConfiguration(); + process.exit(0); + } + + if (command === "set-version") { + if (!arg) { + usage(); + process.exit(1); + } + setVersion(arg); + process.exit(0); + } + + usage(); + process.exit(1); } -usage(); -process.exit(1); +export { + buildReleasePackagePlan, + checkConfiguration, + discoverPublicPackages, + getReleasePackages, + loadReleaseManifest, +}; diff --git a/scripts/release-package-map.test.mjs b/scripts/release-package-map.test.mjs new file mode 100644 index 00000000000..19bc86d79eb --- /dev/null +++ b/scripts/release-package-map.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildReleasePackagePlan, + checkConfiguration, + getReleasePackages, +} from "./release-package-map.mjs"; + +test("release package manifest covers all public packages with explicit CI enrollment", () => { + const packages = buildReleasePackagePlan(); + assert.ok(packages.length > 0); + assert.ok(packages.every((pkg) => typeof pkg.publishFromCi === "boolean")); +}); + +test("ACPX adapter remains enrolled for CI publishing", () => { + const packages = buildReleasePackagePlan(); + const acpxPackage = packages.find((pkg) => pkg.name === "@paperclipai/adapter-acpx-local"); + assert.ok(acpxPackage); + assert.equal(acpxPackage.publishFromCi, true); + + const enabledNames = new Set(getReleasePackages().map((pkg) => pkg.name)); + assert.equal(enabledNames.has("@paperclipai/adapter-acpx-local"), true); + assert.equal(enabledNames.has("@paperclipai/adapter-utils"), true); +}); + +test("release package configuration validates successfully", () => { + assert.doesNotThrow(() => checkConfiguration()); +});