Files
paperclip/scripts/check-release-package-bootstrap.mjs
Devin Foley 29401b231b 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
2026-05-03 19:31:28 -07:00

207 lines
5.8 KiB
JavaScript

#!/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,
};