From 29401b231bd403e49d207eac45109bd683e5962e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 19:31:28 -0700 Subject: [PATCH] fix(ci): gate new release packages on npm bootstrap (#5146) ## Thinking Path > - Paperclip is a control plane for autonomous agent companies, so its release automation is part of the core operator trust boundary. > - The affected subsystem is npm/GitHub Actions release publishing for the public monorepo packages. > - The concrete failure was that a newly added package reached `master`, the canary workflow attempted its first publish, and npm trusted publishing was not yet bootstrapped for that package. > - That means the problem is not just one broken run; it is a missing pre-merge guard that lets release-ineligible packages land and only fail once `publish_canary` runs. > - This pull request makes release enrollment explicit, validates that enrollment in CI, and adds a PR-time bootstrap check against npm for changed release-enabled package manifests. > - The result is that we keep trusted publishing, avoid teaching CI to `npm adduser`, and move this class of failure from post-merge canary time to pre-merge review time. ## What Changed - Added `scripts/release-package-manifest.json` so release-managed public packages are explicitly enrolled instead of being inferred from every non-private workspace package. - Hardened `scripts/release-package-map.mjs` to validate the manifest before release workflows rewrite versions or assemble publish payloads. - Added `scripts/check-release-package-bootstrap.mjs` and wired it into `.github/workflows/pr.yml` so PRs that change a release-enabled package manifest fail if that package does not already exist on npm. - Added release-package manifest coverage tests to `scripts/release-package-map.test.mjs` and included them in `pnpm run test:release-registry`. - Wired manifest validation into `.github/workflows/release.yml` and documented the first-publish bootstrap policy in `doc/PUBLISHING.md` and `doc/RELEASE-AUTOMATION-SETUP.md`. ## Verification - `pnpm run test:release-registry` - `./scripts/release.sh canary --skip-verify --dry-run` - Confirmed the committed diff contains no obvious PII/secrets via targeted pattern scan before pushing. ## Risks - Low risk overall: this is CI/release-policy code, not product runtime logic. - The new PR bootstrap check depends on npm metadata availability, so a transient npm outage could block a PR that changes a release-enabled package manifest. - The manifest introduces a new source of truth that must stay aligned with public package additions, but that is intentional and now enforced. ## Model Used - OpenAI Codex via the `codex_local` Paperclip adapter; GPT-5-based coding agent with tool use, terminal execution, git, and GitHub CLI. Exact served model ID/context window are not exposed by the local runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .github/workflows/pr.yml | 9 + .github/workflows/release.yml | 12 + doc/PUBLISHING.md | 52 ++++ doc/RELEASE-AUTOMATION-SETUP.md | 21 ++ package.json | 3 +- scripts/bootstrap-npm-package.mjs | 294 ++++++++++++++++++ scripts/bootstrap-npm-package.test.mjs | 54 ++++ scripts/check-release-package-bootstrap.mjs | 206 ++++++++++++ .../check-release-package-bootstrap.test.mjs | 104 +++++++ scripts/release-package-manifest.json | 92 ++++++ scripts/release-package-map.mjs | 146 ++++++++- scripts/release-package-map.test.mjs | 24 ++ .../heartbeat-plugin-environment.test.ts | 2 + 13 files changed, 1002 insertions(+), 17 deletions(-) create mode 100644 scripts/bootstrap-npm-package.mjs create mode 100644 scripts/bootstrap-npm-package.test.mjs create mode 100644 scripts/check-release-package-bootstrap.mjs create mode 100644 scripts/check-release-package-bootstrap.test.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 ad802136..8ac8f710 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -45,6 +45,15 @@ 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: | + mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}") + PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \ + node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}" + - 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 0b5983cc..c16bad23 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 784a12fd..c430d347 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -176,6 +176,58 @@ 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`. + +### One-time bootstrap sequence for a new package + +The first publish of a brand-new package still needs one human maintainer with npm write access. +After that, trusted publishing can take over. + +Example for `@paperclipai/adapter-acpx-local` from the repo root: + +```bash +# safe preview +pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local + +# one-time first publish from an authenticated maintainer machine +pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456 +``` + +The helper script: + +- checks that the package does not already exist on npm +- builds the target package unless `--skip-build` is passed +- runs `npm pack --dry-run` in the package directory +- only runs the real `npm publish --access public` when `--publish --otp ` is provided + +For the real `--publish` step, the maintainer machine must already be authenticated to npm. +If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper. +That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI. +The helper now requires `--otp ` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing. + +After that first publish succeeds: + +1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local` +2. go to `Settings` → `Trusted publishing` +3. add repository `paperclipai/paperclip` +4. set workflow filename to `release.yml` +5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens` +6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json) + +Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm. + ## Rollback model Rollback does not unpublish anything. diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index 25982892..d6e08b9f 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 7a9ec0c2..9d6b5bec 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,14 @@ "release:stable": "./scripts/release.sh stable", "release:github": "./scripts/create-github-release.sh", "release:rollback": "./scripts/rollback-latest.sh", + "release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "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 scripts/check-release-package-bootstrap.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/bootstrap-npm-package.mjs b/scripts/bootstrap-npm-package.mjs new file mode 100644 index 00000000..9b1e01be --- /dev/null +++ b/scripts/bootstrap-npm-package.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +import { buildReleasePackagePlan } from "./release-package-map.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +function normalizePath(filePath) { + return filePath.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function usage() { + process.stderr.write( + [ + "Usage:", + " node scripts/bootstrap-npm-package.mjs [--publish --otp ] [--skip-build]", + "", + "Examples:", + " node scripts/bootstrap-npm-package.mjs @paperclipai/adapter-acpx-local", + " node scripts/bootstrap-npm-package.mjs packages/adapters/acpx-local --publish", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const flags = new Set(); + let selector = null; + let otp = null; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + + if (arg === "--publish" || arg === "--skip-build") { + flags.add(arg); + continue; + } + + if (arg === "--otp") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error("expected a one-time password after --otp"); + } + otp = value; + index += 1; + continue; + } + + if (arg === "--help" || arg === "-h") { + return { help: true, selector: null, publish: false, skipBuild: false, otp: null }; + } + + if (arg.startsWith("--")) { + throw new Error(`unknown option: ${arg}`); + } + + if (selector) { + throw new Error("expected exactly one package selector"); + } + + selector = arg; + } + + return { + help: false, + selector, + publish: flags.has("--publish"), + skipBuild: flags.has("--skip-build"), + otp, + }; +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + ...options, + }); + + if (result.error) { + throw result.error; + } + + return result; +} + +function runChecked(command, args, options = {}) { + const result = runCommand(command, args, options); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`); + } +} + +function formatCommand(command, args) { + return `${command} ${args.join(" ")}`; +} + +function ensureNpmAuth() { + const result = runCommand("npm", ["whoami"]); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status === 0) { + return; + } + + const output = `${stdout}\n${stderr}`.trim(); + if (/\bE401\b|401 Unauthorized/i.test(output)) { + throw new Error( + [ + "npm auth check failed.", + "This usually means the machine is either not logged into npm yet or has a stale token in ~/.npmrc.", + "Run `npm logout --registry=https://registry.npmjs.org/` and then `npm login` or `npm adduser` on this maintainer machine with an npm account that can publish to the @paperclipai scope, then rerun with --publish.", + "Do not use this auth flow in CI; it is only for the one-time human bootstrap publish.", + ].join(" "), + ); + } + + throw new Error("npm whoami failed"); +} + +function inspectNpmPackage(packageName) { + const result = runCommand("npm", ["view", packageName, "version", "--json"]); + + if (result.status === 0) { + const version = JSON.parse((result.stdout ?? "").trim()); + return { exists: true, version }; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (/\bE404\b|404 Not Found|could not be found/i.test(output)) { + return { exists: false }; + } + + process.stderr.write(output ? `${output}\n` : ""); + throw new Error(`failed to query npm for ${packageName}`); +} + +function resolveTargetPackage(selector, packages = buildReleasePackagePlan()) { + const normalizedSelector = normalizePath(selector); + const matches = packages.filter( + (pkg) => pkg.name === selector || normalizePath(pkg.dir) === normalizedSelector, + ); + + if (matches.length === 1) { + return matches[0]; + } + + if (matches.length > 1) { + throw new Error(`package selector is ambiguous: ${selector}`); + } + + throw new Error( + `unknown package selector: ${selector}\nKnown packages:\n- ${packages.map((pkg) => `${pkg.name} (${pkg.dir})`).join("\n- ")}`, + ); +} + +function printNextSteps(pkg) { + process.stdout.write( + [ + "", + "Publish succeeded. Next:", + `1. Open https://www.npmjs.com/package/${pkg.name}`, + "2. Go to Settings -> Trusted publishing", + "3. Add repository paperclipai/paperclip", + "4. Set workflow filename to release.yml", + "5. Optionally enable Settings -> Publishing access -> Require two-factor authentication and disallow tokens", + "", + ].join("\n"), + ); +} + +function publishPackage(pkg, otp) { + const publishArgs = ["publish", "--access", "public"]; + if (otp) { + publishArgs.push("--otp", otp); + } + + const result = runCommand("npm", publishArgs, { cwd: join(repoRoot, pkg.dir) }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + const output = `${stdout}\n${stderr}`.trim(); + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status === 0) { + return; + } + + if (/\bEOTP\b|one-time password/i.test(output)) { + throw new Error( + [ + "npm publish reached the publish-time 2FA check.", + "Complete the browser auth URL printed by npm and rerun the helper, or rerun with `--otp ` if your npm account uses authenticator-app codes.", + ].join(" "), + ); + } + + throw new Error(`${formatCommand("npm", publishArgs)} failed with status ${result.status ?? "unknown"}`); +} + +function main(argv) { + const { help, selector, publish, skipBuild, otp } = parseArgs(argv); + + if (help) { + usage(); + return; + } + + if (!selector) { + usage(); + throw new Error("missing package selector"); + } + + const pkg = resolveTargetPackage(selector); + process.stdout.write(`Selected ${pkg.name} (${pkg.dir})\n`); + + if (publish && !otp) { + throw new Error("`--publish` requires `--otp `. Generate a fresh npm one-time password and rerun."); + } + + const npmState = inspectNpmPackage(pkg.name); + if (npmState.exists) { + throw new Error(`${pkg.name} already exists on npm at version ${npmState.version}; bootstrap is only for first publish`); + } + + process.stdout.write(`${pkg.name} is not on npm yet; continuing with bootstrap flow.\n`); + + if (publish) { + process.stdout.write("Checking npm auth with npm whoami...\n"); + ensureNpmAuth(); + } + + if (!skipBuild && typeof pkg.pkg?.scripts?.build === "string") { + process.stdout.write(`Building ${pkg.name}...\n`); + runChecked("pnpm", ["--filter", pkg.name, "build"]); + } + + process.stdout.write(`Previewing publish payload for ${pkg.name}...\n`); + runChecked("npm", ["pack", "--dry-run"], { cwd: join(repoRoot, pkg.dir) }); + + if (!publish) { + process.stdout.write( + [ + "", + "Dry run complete. To perform the first publish from an authenticated maintainer machine, run:", + `node scripts/bootstrap-npm-package.mjs ${pkg.name} --publish --otp `, + "", + ].join("\n"), + ); + return; + } + + process.stdout.write(`Publishing ${pkg.name}...\n`); + publishPackage(pkg, otp); + printNextSteps(pkg); +} + +const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +export { + ensureNpmAuth, + inspectNpmPackage, + parseArgs, + publishPackage, + resolveTargetPackage, +}; diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs new file mode 100644 index 00000000..48deb739 --- /dev/null +++ b/scripts/bootstrap-npm-package.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs"; + +test("parseArgs recognizes publish and skip-build flags", () => { + assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), { + help: false, + selector: "@paperclipai/adapter-acpx-local", + publish: true, + skipBuild: true, + otp: null, + }); +}); + +test("parseArgs accepts an explicit otp value", () => { + assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish", "--otp", "123456"]), { + help: false, + selector: "packages/adapters/acpx-local", + publish: true, + skipBuild: false, + otp: "123456", + }); +}); + +test("parseArgs leaves otp null when omitted", () => { + assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish"]), { + help: false, + selector: "packages/adapters/acpx-local", + publish: true, + skipBuild: false, + otp: null, + }); +}); + +test("parseArgs returns help mode", () => { + assert.deepEqual(parseArgs(["--help"]), { + help: true, + selector: null, + publish: false, + skipBuild: false, + otp: null, + }); +}); + +test("resolveTargetPackage matches by package name or dir", () => { + const packages = [ + { dir: "packages/a", name: "@paperclipai/a", pkg: {} }, + { dir: "packages/b", name: "@paperclipai/b", pkg: {} }, + ]; + + assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a"); + assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b"); +}); diff --git a/scripts/check-release-package-bootstrap.mjs b/scripts/check-release-package-bootstrap.mjs new file mode 100644 index 00000000..a9d792df --- /dev/null +++ b/scripts/check-release-package-bootstrap.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +import { buildReleasePackagePlan } from "./release-package-map.mjs"; + +function normalizePath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function classifyNpmViewFailure(output) { + return /\bE404\b|404 Not Found|could not be found/i.test(output) ? "missing" : "registry_error"; +} + +function inspectNpmPackage(packageName) { + const result = spawnSync("npm", ["view", packageName, "name", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status === 0) { + return { status: "exists" }; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + const failureType = classifyNpmViewFailure(output); + + if (failureType === "missing") { + return { status: "missing" }; + } + + return { + status: "registry_error", + detail: output || `npm view exited with status ${result.status ?? "unknown"}`, + }; +} + +function readGitFileAtRevision(revision, filePath) { + const result = spawnSync("git", ["show", `${revision}:${normalizePath(filePath)}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status === 0) { + return result.stdout; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + + if ( + /exists on disk, but not in/i.test(output) || + /does not exist in/i.test(output) + ) { + return null; + } + + throw new Error(`failed to read ${filePath} at ${revision}:\n${output || "git show failed"}`); +} + +function getBaseReleaseState( + revision, + releasePackages = buildReleasePackagePlan(), + readFileAtRevision = readGitFileAtRevision, +) { + if (!revision) return null; + + const manifestText = readFileAtRevision(revision, "scripts/release-package-manifest.json"); + + if (manifestText) { + const manifestEntries = JSON.parse(manifestText); + + if (!Array.isArray(manifestEntries)) { + throw new Error(`expected scripts/release-package-manifest.json at ${revision} to contain an array`); + } + + return { + source: "manifest", + byDir: new Map( + manifestEntries + .filter((entry) => entry?.publishFromCi === true && typeof entry.dir === "string" && typeof entry.name === "string") + .map((entry) => [entry.dir, { name: entry.name, publishFromCi: true }]), + ), + }; + } + + const byDir = new Map(); + + for (const pkg of releasePackages) { + const packageJsonText = readFileAtRevision(revision, `${pkg.dir}/package.json`); + if (!packageJsonText) continue; + + const basePackage = JSON.parse(packageJsonText); + if (basePackage.private) continue; + + byDir.set(pkg.dir, { + name: basePackage.name, + publishFromCi: true, + }); + } + + return { + source: "public-packages", + byDir, + }; +} + +function collectReleasePackagesForChangedPaths( + changedPaths, + releasePackages = buildReleasePackagePlan(), + baseReleaseState = null, +) { + const normalizedChangedPaths = changedPaths.map(normalizePath); + const manifestFileChanged = normalizedChangedPaths.includes("scripts/release-package-manifest.json"); + const changedReleasePackages = []; + const seen = new Set(); + + for (const pkg of releasePackages) { + if (!pkg.publishFromCi) continue; + const packageJsonPath = `${pkg.dir}/package.json`; + const packageJsonChanged = normalizedChangedPaths.includes(packageJsonPath); + const basePackage = baseReleaseState?.byDir.get(pkg.dir); + const newlyReleaseEnabled = + manifestFileChanged && + (!baseReleaseState || !basePackage || basePackage.publishFromCi !== true || basePackage.name !== pkg.name); + const isRelevant = packageJsonChanged || newlyReleaseEnabled; + + if (!isRelevant) continue; + if (seen.has(pkg.name)) continue; + + changedReleasePackages.push(pkg); + seen.add(pkg.name); + } + + return changedReleasePackages; +} + +function main(changedPaths) { + const releasePackages = buildReleasePackagePlan(); + const baseReleaseState = getBaseReleaseState(process.env.PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA, releasePackages); + const changedReleasePackages = collectReleasePackagesForChangedPaths(changedPaths, releasePackages, baseReleaseState); + + if (changedReleasePackages.length === 0) { + process.stdout.write("No release-enabled package manifests changed in this PR.\n"); + return; + } + + const missingPackages = []; + const registryFailures = []; + + for (const pkg of changedReleasePackages) { + const npmStatus = inspectNpmPackage(pkg.name); + + if (npmStatus.status === "missing") { + missingPackages.push(pkg); + continue; + } + + if (npmStatus.status === "registry_error") { + registryFailures.push({ pkg, detail: npmStatus.detail }); + } + } + + 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}`); + } + + if (registryFailures.length > 0) { + const details = registryFailures + .map( + ({ pkg, detail }) => + `${pkg.name} (${pkg.dir}) could not be checked against npm due to a registry error:\n${detail}`, + ) + .join("\n- "); + + throw new Error(`release package bootstrap check could not verify npm state:\n- ${details}`); + } + + process.stdout.write( + `Release bootstrap OK for changed manifests: ${changedReleasePackages.map((pkg) => pkg.name).join(", ")}\n`, + ); +} + +if (process.argv[1] && normalizePath(process.argv[1]).endsWith("scripts/check-release-package-bootstrap.mjs")) { + main(process.argv.slice(2)); +} + +export { + classifyNpmViewFailure, + collectReleasePackagesForChangedPaths, + getBaseReleaseState, +}; diff --git a/scripts/check-release-package-bootstrap.test.mjs b/scripts/check-release-package-bootstrap.test.mjs new file mode 100644 index 00000000..90c92308 --- /dev/null +++ b/scripts/check-release-package-bootstrap.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyNpmViewFailure, + collectReleasePackagesForChangedPaths, + getBaseReleaseState, +} from "./check-release-package-bootstrap.mjs"; + +test("manifest changes without base state validate all release-enabled packages", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + { dir: "packages/c", name: "@paperclipai/c", publishFromCi: false }, + ]; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["scripts/release-package-manifest.json"], + releasePackages, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/a", "@paperclipai/b"], + ); +}); + +test("manifest changes only validate newly release-enabled packages relative to base state", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + { dir: "packages/c", name: "@paperclipai/c", publishFromCi: false }, + ]; + const baseReleaseState = { + source: "manifest", + byDir: new Map([["packages/a", { name: "@paperclipai/a", publishFromCi: true }]]), + }; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["scripts/release-package-manifest.json"], + releasePackages, + baseReleaseState, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/b"], + ); +}); + +test("package-specific changes only validate affected release-enabled packages", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + ]; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["packages/b/package.json", "README.md"], + releasePackages, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/b"], + ); +}); + +test("npm E404 failures are treated as missing packages", () => { + assert.equal(classifyNpmViewFailure("npm error code E404"), "missing"); + assert.equal(classifyNpmViewFailure("404 Not Found"), "missing"); +}); + +test("non-404 npm failures are treated as registry errors", () => { + assert.equal(classifyNpmViewFailure("npm error code EAI_AGAIN"), "registry_error"); + assert.equal(classifyNpmViewFailure("npm error code E429"), "registry_error"); +}); + +test("base release state falls back to public packages when manifest is absent", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + ]; + + const baseReleaseState = getBaseReleaseState("base-sha", releasePackages, (_revision, filePath) => { + if (filePath === "scripts/release-package-manifest.json") { + return null; + } + + if (filePath === "packages/a/package.json") { + return JSON.stringify({ name: "@paperclipai/a", private: false }); + } + + if (filePath === "packages/b/package.json") { + return JSON.stringify({ name: "@paperclipai/b", private: true }); + } + + return null; + }); + + assert.equal(baseReleaseState?.source, "public-packages"); + assert.deepEqual([...baseReleaseState.byDir.entries()], [ + ["packages/a", { name: "@paperclipai/a", publishFromCi: true }], + ]); +}); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json new file mode 100644 index 00000000..a81cf28f --- /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 60deba31..a990b88e 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 00000000..704dd3dd --- /dev/null +++ b/scripts/release-package-map.test.mjs @@ -0,0 +1,24 @@ +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("release package list only contains CI-enrolled packages", () => { + const enabledPackages = getReleasePackages(); + assert.ok(enabledPackages.length > 0); + assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true)); +}); + +test("release package configuration validates successfully", () => { + assert.doesNotThrow(() => checkConfiguration()); +}); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index 46de3f47..9e92f260 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -38,6 +38,7 @@ vi.mock("../adapters/index.js", () => ({ execute: adapterExecute, supportsLocalAgentJwt: false, }), + listAdapterModelProfiles: async () => [], runningProcesses: new Map(), })); @@ -70,6 +71,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { }); afterAll(async () => { + await db.$client.end(); await stopDb?.(); });