diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 76add6a6..d4ee9dda 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -80,6 +80,9 @@ jobs: - name: Run tests run: pnpm test:run + - name: Verify release registry test coverage + run: pnpm run test:release-registry + - name: Build run: pnpm build diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 83a6c4a7..784a12fd 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -143,6 +143,13 @@ This keeps the default install path unchanged while allowing explicit installs w npx paperclipai@canary onboard ``` +The release script now verifies two things after a canary publish: + +- the `canary` dist-tag resolves to the version that was just published +- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm + +It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended. + ### Stable Stable publishes use the npm dist-tag `latest`. diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 61d094c4..3ece1fba 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -63,6 +63,8 @@ It: - verifies the pushed commit - computes the canary version for the current UTC date - publishes under npm dist-tag `canary` +- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm +- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional - creates a git tag `canary/vYYYY.MDD.P-canary.N` Users install canaries with: diff --git a/package.json b/package.json index d470705e..40cefd52 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "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", + "test:release-registry": "node --test scripts/verify-release-registry-state.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", diff --git a/scripts/release.sh b/scripts/release.sh index 74cdaa8f..fc9441a6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -11,6 +11,7 @@ release_date="" dry_run=false skip_verify=false print_version_only=false +allow_canary_latest=false tag_name="" cleanup_on_exit=false @@ -18,11 +19,12 @@ cleanup_on_exit=false usage() { cat <<'EOF' Usage: - ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] + ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--allow-canary-latest] Examples: ./scripts/release.sh canary ./scripts/release.sh canary --date 2026-03-17 --dry-run + ./scripts/release.sh canary --allow-canary-latest ./scripts/release.sh stable ./scripts/release.sh stable --date 2026-03-17 --dry-run ./scripts/release.sh stable --date 2026-03-18 --print-version @@ -32,6 +34,9 @@ Notes: zero-padded UTC day, and P is the same-day stable patch slot. - Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag "canary" and create the git tag canary/vYYYY.MDD.P-canary.N. + - Canary releases fail by default if npm leaves the "latest" dist-tag + pointing at any canary. Pass --allow-canary-latest only when that is an + intentional first-publish or migration state. - Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and create the git tag vYYYY.MDD.P. - Stable release notes must already exist at releases/vYYYY.MDD.P.md. @@ -99,6 +104,7 @@ while [ $# -gt 0 ]; do --dry-run) dry_run=true ;; --skip-verify) skip_verify=true ;; --print-version) print_version_only=true ;; + --allow-canary-latest) allow_canary_latest=true ;; -h|--help) usage exit 0 @@ -115,6 +121,10 @@ done exit 1 } +if [ "$allow_canary_latest" = true ] && [ "$channel" != "canary" ]; then + release_fail "--allow-canary-latest can only be used with the canary channel." +fi + PUBLISH_REMOTE="$(resolve_release_remote)" fetch_release_remote "$PUBLISH_REMOTE" @@ -187,6 +197,11 @@ release_info " Release date (UTC): $RELEASE_DATE" release_info " Target stable version: $TARGET_STABLE_VERSION" if [ "$channel" = "canary" ]; then release_info " Canary version: $TARGET_PUBLISH_VERSION" + if [ "$allow_canary_latest" = true ]; then + release_info " latest dist-tag policy: allow canary" + else + release_info " latest dist-tag policy: fail if npm leaves latest on a canary" + fi else release_info " Stable version: $TARGET_PUBLISH_VERSION" fi @@ -263,7 +278,7 @@ release_info "" if [ "$dry_run" = true ]; then release_info "==> Step 6/7: Skipping npm verification in dry-run mode..." else - release_info "==> Step 6/7: Confirming npm package availability..." + release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..." VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}" VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" MISSING_PUBLISHED_PACKAGES="" @@ -285,6 +300,21 @@ else [ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES" release_info " ✓ Verified all versioned packages are available on npm" + + verify_args=( + --channel "$channel" + --dist-tag "$DIST_TAG" + --target-version "$TARGET_PUBLISH_VERSION" + ) + if [ "$allow_canary_latest" = true ]; then + verify_args+=(--allow-canary-latest) + fi + while IFS=$'\t' read -r _pkg_dir pkg_name _pkg_version; do + [ -z "$pkg_name" ] && continue + verify_args+=(--package "$pkg_name") + done <<< "$VERSIONED_PACKAGE_INFO" + + node "$REPO_ROOT/scripts/verify-release-registry-state.mjs" "${verify_args[@]}" fi release_info "" diff --git a/scripts/verify-release-registry-state.mjs b/scripts/verify-release-registry-state.mjs new file mode 100644 index 00000000..85859a3e --- /dev/null +++ b/scripts/verify-release-registry-state.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; + +const CANARY_VERSION_RE = /-canary\.\d+$/; + +export function isCanaryVersion(version) { + return CANARY_VERSION_RE.test(version); +} + +function usage() { + process.stderr.write( + [ + "Usage:", + " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const options = { + channel: "", + distTag: "", + targetVersion: "", + allowCanaryLatest: false, + packages: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + switch (arg) { + case "--channel": + options.channel = argv[index + 1] ?? ""; + index += 1; + break; + case "--dist-tag": + options.distTag = argv[index + 1] ?? ""; + index += 1; + break; + case "--target-version": + options.targetVersion = argv[index + 1] ?? ""; + index += 1; + break; + case "--package": + options.packages.push(argv[index + 1] ?? ""); + index += 1; + break; + case "--allow-canary-latest": + options.allowCanaryLatest = true; + break; + case "-h": + case "--help": + usage(); + process.exit(0); + default: + throw new Error(`unexpected argument: ${arg}`); + } + } + + if (options.channel !== "canary" && options.channel !== "stable") { + throw new Error("--channel must be canary or stable"); + } + + if (!options.distTag) { + throw new Error("--dist-tag is required"); + } + + if (!options.targetVersion) { + throw new Error("--target-version is required"); + } + + if (options.packages.length === 0 || options.packages.some((name) => !name)) { + throw new Error("at least one non-empty --package value is required"); + } + + if (options.allowCanaryLatest && options.channel !== "canary") { + throw new Error("--allow-canary-latest only applies to canary releases"); + } + + return options; +} + +function createRegistryUrl(packageName) { + const registry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? "https://registry.npmjs.org/"; + return new URL(encodeURIComponent(packageName), registry.endsWith("/") ? registry : `${registry}/`); +} + +async function fetchPackageDocument(packageName, { allowMissing = false } = {}) { + const url = createRegistryUrl(packageName); + const response = await fetch(url, { + headers: { + accept: "application/vnd.npm.install-v1+json, application/json;q=0.9", + }, + }); + + if (response.status === 404 && allowMissing) { + return null; + } + + if (!response.ok) { + throw new Error(`npm registry request failed for ${packageName}: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +export function collectInternalDependencyProblems(manifest, packageDocsByName) { + const problems = []; + const sections = [ + ["dependencies", manifest.dependencies ?? {}], + ["optionalDependencies", manifest.optionalDependencies ?? {}], + ["peerDependencies", manifest.peerDependencies ?? {}], + ]; + + for (const [sectionName, deps] of sections) { + for (const [dependencyName, dependencyVersion] of Object.entries(deps)) { + if (!dependencyName.startsWith("@paperclipai/")) { + continue; + } + + if (typeof dependencyVersion !== "string" || !dependencyVersion) { + problems.push( + `${sectionName} declares ${dependencyName} with a non-string version: ${JSON.stringify(dependencyVersion)}`, + ); + continue; + } + + const dependencyDoc = packageDocsByName.get(dependencyName); + if (!dependencyDoc) { + problems.push(`${sectionName} requires ${dependencyName}@${dependencyVersion}, but that package is not published`); + continue; + } + + if (!(dependencyVersion in (dependencyDoc.versions ?? {}))) { + problems.push( + `${sectionName} requires ${dependencyName}@${dependencyVersion}, but npm does not expose that version`, + ); + } + } + } + + return problems; +} + +function requireManifest(packageName, version, packageDoc, problems) { + const manifest = packageDoc.versions?.[version]; + if (!manifest) { + if (problems) { + problems.push(`${packageName}: npm registry is missing manifest data for ${version}`); + } + return null; + } + return manifest; +} + +export function verifyPackageRegistryState({ + packageName, + packageDoc, + packageDocsByName, + channel, + distTag, + targetVersion, + allowCanaryLatest, +}) { + const problems = []; + const distTags = packageDoc["dist-tags"] ?? {}; + const taggedVersion = distTags[distTag]; + + if (taggedVersion !== targetVersion) { + problems.push( + `${packageName}: dist-tag ${distTag} resolves to ${taggedVersion ?? ""}, expected ${targetVersion}`, + ); + } + + const targetManifest = requireManifest(packageName, targetVersion, packageDoc, problems); + if (targetManifest) { + for (const problem of collectInternalDependencyProblems(targetManifest, packageDocsByName)) { + problems.push(`${packageName}@${targetVersion}: ${problem}`); + } + } + + if (channel === "canary") { + const latestVersion = distTags.latest; + + if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) { + problems.push( + `${packageName}: latest dist-tag still resolves to canary ${latestVersion}; rerun with --allow-canary-latest only when that state is intentional`, + ); + } + + if (latestVersion && isCanaryVersion(latestVersion)) { + const latestManifest = requireManifest(packageName, latestVersion, packageDoc, problems); + if (latestManifest) { + for (const problem of collectInternalDependencyProblems(latestManifest, packageDocsByName)) { + problems.push(`${packageName}@${latestVersion} via latest: ${problem}`); + } + } + } + } + + return problems; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const packageNames = [...new Set(options.packages)]; + const packageDocsByName = new Map(); + + await Promise.all( + packageNames.map(async (packageName) => { + packageDocsByName.set(packageName, await fetchPackageDocument(packageName)); + }), + ); + + const additionalInternalDeps = new Set(); + for (const packageDoc of packageDocsByName.values()) { + const versionsToCheck = new Set([options.targetVersion]); + const latestVersion = packageDoc["dist-tags"]?.latest; + if (latestVersion && isCanaryVersion(latestVersion)) { + versionsToCheck.add(latestVersion); + } + + for (const version of versionsToCheck) { + const manifest = packageDoc.versions?.[version]; + if (!manifest) { + continue; + } + + for (const deps of [ + manifest.dependencies ?? {}, + manifest.optionalDependencies ?? {}, + manifest.peerDependencies ?? {}, + ]) { + for (const dependencyName of Object.keys(deps)) { + if (dependencyName.startsWith("@paperclipai/")) { + additionalInternalDeps.add(dependencyName); + } + } + } + } + } + + const missingDeps = [...additionalInternalDeps].filter((dep) => !packageDocsByName.has(dep)); + await Promise.all( + missingDeps.map(async (dependencyName) => { + packageDocsByName.set( + dependencyName, + await fetchPackageDocument(dependencyName, { allowMissing: true }), + ); + }), + ); + + const problems = []; + + for (const packageName of packageNames) { + process.stdout.write(` Verifying ${packageName} on dist-tag ${options.distTag}\n`); + const packageProblems = verifyPackageRegistryState({ + packageName, + packageDoc: packageDocsByName.get(packageName), + packageDocsByName, + channel: options.channel, + distTag: options.distTag, + targetVersion: options.targetVersion, + allowCanaryLatest: options.allowCanaryLatest, + }); + + if (packageProblems.length === 0) { + process.stdout.write(` ✓ dist-tag and published internal dependencies are consistent\n`); + continue; + } + + for (const problem of packageProblems) { + process.stderr.write(` ✗ ${problem}\n`); + problems.push(problem); + } + } + + if (problems.length > 0) { + throw new Error(`npm registry verification failed for ${problems.length} problem(s)`); + } +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + main().catch((error) => { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + }); +} diff --git a/scripts/verify-release-registry-state.test.mjs b/scripts/verify-release-registry-state.test.mjs new file mode 100644 index 00000000..e23cfcdc --- /dev/null +++ b/scripts/verify-release-registry-state.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectInternalDependencyProblems, + isCanaryVersion, + verifyPackageRegistryState, +} from "./verify-release-registry-state.mjs"; + +test("isCanaryVersion matches release canaries", () => { + assert.equal(isCanaryVersion("2026.427.0-canary.3"), true); + assert.equal(isCanaryVersion("2026.427.0"), false); +}); + +test("collectInternalDependencyProblems flags missing internal versions", () => { + const manifest = { + dependencies: { + "@paperclipai/plugin-sdk": "2026.425.0-canary.5", + e2b: "^2.19.0", + }, + }; + const packageDocsByName = new Map([ + [ + "@paperclipai/plugin-sdk", + { + versions: { + "2026.427.0-canary.3": {}, + }, + }, + ], + ]); + + assert.deepEqual(collectInternalDependencyProblems(manifest, packageDocsByName), [ + "dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version", + ]); +}); + +test("verifyPackageRegistryState fails when canary latest is left in place by default", () => { + const packageDocsByName = new Map([ + [ + "@paperclipai/plugin-e2b", + { + "dist-tags": { + latest: "2026.425.0-canary.5", + canary: "2026.427.0-canary.3", + }, + versions: { + "2026.425.0-canary.5": { + dependencies: { + "@paperclipai/plugin-sdk": "2026.425.0-canary.5", + }, + }, + "2026.427.0-canary.3": { + dependencies: { + "@paperclipai/plugin-sdk": "2026.427.0-canary.3", + }, + }, + }, + }, + ], + [ + "@paperclipai/plugin-sdk", + { + versions: { + "2026.427.0-canary.3": {}, + }, + }, + ], + ]); + + assert.deepEqual( + verifyPackageRegistryState({ + packageName: "@paperclipai/plugin-e2b", + packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"), + packageDocsByName, + channel: "canary", + distTag: "canary", + targetVersion: "2026.427.0-canary.3", + allowCanaryLatest: false, + }), + [ + "@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; rerun with --allow-canary-latest only when that state is intentional", + "@paperclipai/plugin-e2b@2026.425.0-canary.5 via latest: dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version", + ], + ); +}); + +test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => { + const packageDocsByName = new Map([ + [ + "paperclipai", + { + "dist-tags": { + latest: "2026.427.0-canary.3", + canary: "2026.427.0-canary.3", + }, + versions: { + "2026.427.0-canary.3": { + dependencies: { + "@paperclipai/server": "2026.427.0-canary.3", + }, + }, + }, + }, + ], + [ + "@paperclipai/server", + { + versions: { + "2026.427.0-canary.3": {}, + }, + }, + ], + ]); + + assert.deepEqual( + verifyPackageRegistryState({ + packageName: "paperclipai", + packageDoc: packageDocsByName.get("paperclipai"), + packageDocsByName, + channel: "canary", + distTag: "canary", + targetVersion: "2026.427.0-canary.3", + allowCanaryLatest: true, + }), + [], + ); +});