fix(ci): gate new release packages on npm bootstrap

This commit is contained in:
Devin Foley committed 2026-05-03 17:29:49 -07:00
1 parent 76f09c8eb6
commit 48ce651160
9 files changed
+370 -17

No files matched your search

@@ -0,0 +1,63 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { buildReleasePackagePlan } from "./release-package-map.mjs";
function normalizePath(filePath) {
return filePath.replace(/\\/g, "/");
}
function npmPackageExists(packageName) {
try {
execFileSync("npm", ["view", packageName, "name", "--json"], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
return true;
} catch {
return false;
}
}
function main(changedPaths) {
const normalizedChangedPaths = changedPaths.map(normalizePath);
const releasePackages = buildReleasePackagePlan();
const changedReleasePackages = [];
const seen = new Set();
for (const pkg of releasePackages) {
if (!pkg.publishFromCi) continue;
const packageJsonPath = `${pkg.dir}/package.json`;
if (!normalizedChangedPaths.includes(packageJsonPath)) continue;
if (seen.has(pkg.name)) continue;
changedReleasePackages.push(pkg);
seen.add(pkg.name);
}
if (changedReleasePackages.length === 0) {
process.stdout.write("No release-enabled package manifests changed in this PR.\n");
return;
}
const missingPackages = changedReleasePackages.filter((pkg) => !npmPackageExists(pkg.name));
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}`);
}
process.stdout.write(
`Release bootstrap OK for changed manifests: ${changedReleasePackages.map((pkg) => pkg.name).join(", ")}\n`,
);
}
main(process.argv.slice(2));
+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,
};
+29
View File
@@ -0,0 +1,29 @@
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("ACPX adapter remains enrolled for CI publishing", () => {
const packages = buildReleasePackagePlan();
const acpxPackage = packages.find((pkg) => pkg.name === "@paperclipai/adapter-acpx-local");
assert.ok(acpxPackage);
assert.equal(acpxPackage.publishFromCi, true);
const enabledNames = new Set(getReleasePackages().map((pkg) => pkg.name));
assert.equal(enabledNames.has("@paperclipai/adapter-acpx-local"), true);
assert.equal(enabledNames.has("@paperclipai/adapter-utils"), true);
});
test("release package configuration validates successfully", () => {
assert.doesNotThrow(() => checkConfiguration());
});