Dev #11

Merged
cpfarhood merged 86 commits from dev into local 2026-05-12 00:02:32 +00:00
13 changed files with 1002 additions and 17 deletions
Showing only changes of commit 29401b231b - Show all commits
+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?.();
});