forked from farhoodlabs/paperclip
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
This commit is contained in:
@@ -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 }}")"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 }],
|
||||
]);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user