paperclip/scripts/check-release-package-bootstrap.mjs

207 lines
5.8 KiB
JavaScript
Raw Normal View History

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