mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
fix(ci): gate new release packages on npm bootstrap
This commit is contained in:
1 parent
76f09c8eb6
commit
48ce651160
9 files changed
+370
-17
No files matched your search
@@ -45,6 +45,14 @@ 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: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
||||
node ./scripts/check-release-package-bootstrap.mjs $changed
|
||||
|
||||
- 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,20 @@ 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`.
|
||||
|
||||
## 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:
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"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",
|
||||
"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,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));
|
||||
@@ -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,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());
|
||||
});
|
||||
Reference in new issue
Block a user