mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-09 08:33:02 +02:00
## 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
207 lines
5.8 KiB
JavaScript
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,
|
|
};
|