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
This commit is contained in:
Devin Foley
2026-05-03 19:31:28 -07:00
committed by GitHub
parent a5430f010d
commit 29401b231b
13 changed files with 1002 additions and 17 deletions
+9
View File
@@ -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 }}")"
+12
View File
@@ -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
+52
View File
@@ -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 <code>` 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 <code>` 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.
+21
View File
@@ -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:
+2 -1
View File
@@ -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",
+294
View File
@@ -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 <package-name-or-dir> [--publish --otp <code>] [--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 <code>` 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 <code>`. 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 <code>`,
"",
].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,
};
+54
View File
@@ -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");
});
+206
View File
@@ -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,
};
@@ -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 }],
]);
});
+92
View File
@@ -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
}
]
+130 -16
View File
@@ -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 <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,
};
+24
View File
@@ -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());
});
@@ -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?.();
});