diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index d6e08b9f1a5..d21975c106f 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -232,9 +232,13 @@ After setup: Install-path check: ```bash -npx paperclipai@canary onboard +npm install --prefix "$(mktemp -d)" paperclipai@canary --no-audit --no-fund ``` +The release script runs this clean-prefix install after publishing every workspace +package dependency-first and publishing `paperclipai` last. A package that is not +yet registry-visible stops the train before the channel entrypoint can advance. + ## 12. Verify the Stable Workflow After at least one good canary exists: diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 3ece1fbad06..da4143dfae6 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -62,8 +62,11 @@ It: - verifies the pushed commit - computes the canary version for the current UTC date -- publishes under npm dist-tag `canary` +- publishes workspace packages dependency-first under npm dist-tag `canary` +- waits for each package version to become registry-visible before continuing +- publishes the user-facing `paperclipai` package last, so `paperclipai@canary` does not advance before the full package set exists - verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm +- installs `paperclipai@canary` into a clean temporary prefix as the final npm gate - 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` diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index a0e2852d753..67c35919f0a 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -385,6 +385,43 @@ publish_package_to_npm() { return 1 } +publish_package_to_npm_and_wait() { + local dist_tag="$1" + local package_name="$2" + local package_version="$3" + local publish_tool="${4:-pnpm}" + local attempts="${5:-12}" + local delay_seconds="${6:-5}" + + publish_package_to_npm "$dist_tag" "$package_name" "$package_version" "$publish_tool" || return 1 + + if wait_for_npm_package_version "$package_name" "$package_version" "$attempts" "$delay_seconds"; then + return 0 + fi + + release_warn "npm accepted ${package_name}@${package_version}, but the version did not become registry-visible." + return 1 +} + +verify_npm_installable() { + local package_spec="$1" + local expected_version="$2" + local install_dir + local installed_version + + install_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-install.XXXXXX")" + + if ! npm install --prefix "$install_dir" "$package_spec" --no-audit --no-fund; then + rm -rf "$install_dir" + return 1 + fi + + installed_version="$(node -e "console.log(require(process.argv[1]).version)" "$install_dir/node_modules/paperclipai/package.json")" + rm -rf "$install_dir" + + [ "$installed_version" = "$expected_version" ] +} + wait_for_release_registry_state() { local attempts="${1:-12}" local delay_seconds="${2:-5}" diff --git a/scripts/release-lib.test.mjs b/scripts/release-lib.test.mjs index e33974c8c1a..eb6a6efde05 100644 --- a/scripts/release-lib.test.mjs +++ b/scripts/release-lib.test.mjs @@ -17,6 +17,7 @@ function runPublishHelper({ distTag = "canary", callerPipefail = true, publishTool = "pnpm", + waitForRegistry = false, }) { const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-lib-")); const binDir = join(fixtureDir, "bin"); @@ -129,7 +130,11 @@ exec npm "$@" const script = ` ${shellOptions} source "${repoRoot}/scripts/release-lib.sh" -publish_package_to_npm ${distTag} @paperclipai/example 1.2.3 ${publishTool} +${ + waitForRegistry + ? `publish_package_to_npm_and_wait ${distTag} @paperclipai/example 1.2.3 ${publishTool} 1 0` + : `publish_package_to_npm ${distTag} @paperclipai/example 1.2.3 ${publishTool}` +} `; let status = 0; @@ -239,3 +244,23 @@ test("publish_package_to_npm does not retry stable publishes without provenance" assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m); assert.doesNotMatch(result.calls, /--provenance=false/); }); + +test("publish_package_to_npm_and_wait confirms registry visibility before returning", () => { + const result = runPublishHelper({ + pnpmMode: "success", + npmVersionExists: true, + waitForRegistry: true, + }); + + assert.equal(result.status, 0); + assert.match(result.calls, /^pnpm publish --no-git-checks --tag canary --access public$/m); + assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m); +}); + +test("publish_package_to_npm_and_wait blocks the release when registry visibility lags", () => { + const result = runPublishHelper({ pnpmMode: "success", waitForRegistry: true }); + + assert.notEqual(result.status, 0); + assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m); + assert.match(result.output, /did not become registry-visible/); +}); diff --git a/scripts/release-package-map.mjs b/scripts/release-package-map.mjs index 12ed0124fa8..bf41cd9c220 100644 --- a/scripts/release-package-map.mjs +++ b/scripts/release-package-map.mjs @@ -8,6 +8,7 @@ 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"]; +const CHANNEL_ENTRYPOINT_PACKAGE = "paperclipai"; function readJson(filePath) { return JSON.parse(readFileSync(filePath, "utf8")); @@ -211,7 +212,22 @@ function sortTopologically(packages) { } function getReleasePackages() { - return sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi)); + const ordered = sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi)); + const entrypoint = ordered.find((pkg) => pkg.name === CHANNEL_ENTRYPOINT_PACKAGE); + + if (!entrypoint) { + throw new Error( + `release package graph is missing channel entrypoint ${CHANNEL_ENTRYPOINT_PACKAGE}`, + ); + } + + // npm trusted publishing can authenticate `npm publish`, but not a later + // `npm dist-tag add`. Publish the user-facing CLI last so its channel tag + // cannot advance until every other release package has been accepted by npm. + return [ + ...ordered.filter((pkg) => pkg.name !== CHANNEL_ENTRYPOINT_PACKAGE), + entrypoint, + ]; } function replaceWorkspaceDeps(deps, version) { diff --git a/scripts/release-package-map.test.mjs b/scripts/release-package-map.test.mjs index 577186057b1..e323f6c94dc 100644 --- a/scripts/release-package-map.test.mjs +++ b/scripts/release-package-map.test.mjs @@ -24,6 +24,33 @@ test("release package list only contains CI-enrolled packages", () => { assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true)); }); +test("release package list publishes the installable channel entrypoint last", () => { + const enabledPackages = getReleasePackages(); + + assert.equal(enabledPackages.at(-1)?.name, "paperclipai"); + assert.ok(enabledPackages.slice(0, -1).some((pkg) => pkg.name === "@paperclipai/server")); +}); + +test("release package list keeps runtime workspace dependencies ahead of consumers", () => { + const enabledPackages = getReleasePackages(); + const publishIndexByName = new Map(enabledPackages.map((pkg, index) => [pkg.name, index])); + + for (const pkg of enabledPackages) { + for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) { + for (const [dependencyName, spec] of Object.entries(pkg.pkg[section] ?? {})) { + if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue; + const dependencyIndex = publishIndexByName.get(dependencyName); + if (dependencyIndex === undefined) continue; + + assert.ok( + dependencyIndex < publishIndexByName.get(pkg.name), + `${dependencyName} must publish before ${pkg.name}`, + ); + } + } + } +}); + test("Hermes release surface publishes the unified built-in package and keeps gateway as a shim", () => { const packages = buildReleasePackagePlan(); const hermes = packages.find((pkg) => pkg.name === "@paperclipai/hermes-paperclip-adapter"); diff --git a/scripts/release.sh b/scripts/release.sh index cbdc39d50b8..8e42505af16 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -250,6 +250,9 @@ if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." fi +VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}" +VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" + release_info "" if [ "$dry_run" = true ]; then release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..." @@ -281,12 +284,24 @@ else node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir" cd "$publish_dir" fi - publish_package_to_npm "$DIST_TAG" "$pkg_name" "$pkg_version" "$publish_tool" + if ! publish_package_to_npm_and_wait \ + "$DIST_TAG" \ + "$pkg_name" \ + "$pkg_version" \ + "$publish_tool" \ + "$VERIFY_ATTEMPTS" \ + "$VERIFY_DELAY_SECONDS"; then + if [ "$publish_tool" = "npm" ]; then + rm -rf "$publish_dir" + fi + release_fail "stopping release: npm did not publish and expose ${pkg_name}@${pkg_version}" + fi if [ "$publish_tool" = "npm" ]; then rm -rf "$publish_dir" fi + release_info " ✓ Published version is registry-visible" done <<< "$VERSIONED_PACKAGE_INFO" - release_info " ✓ Published all packages under dist-tag $DIST_TAG" + release_info " ✓ Published the full package set under dist-tag $DIST_TAG" fi release_info "" @@ -294,29 +309,9 @@ 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 and dist-tag integrity..." - VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}" - VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" REGISTRY_STATE_VERIFY_ATTEMPTS="${NPM_REGISTRY_STATE_VERIFY_ATTEMPTS:-12}" REGISTRY_STATE_VERIFY_DELAY_SECONDS="${NPM_REGISTRY_STATE_VERIFY_DELAY_SECONDS:-5}" - MISSING_PUBLISHED_PACKAGES="" - - while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do - [ -z "$pkg_name" ] && continue - release_info " Checking $pkg_name@$pkg_version" - if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then - release_info " ✓ Found on npm" - continue - fi - - if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then - MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, " - fi - MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}" - done <<< "$VERSIONED_PACKAGE_INFO" - - [ -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" + release_info " ✓ Every version was registry-visible before the next package publish" verify_args=( --channel "$channel" @@ -342,6 +337,12 @@ else release_fail "publish completed, but npm dist-tags or registry metadata never converged for ${TARGET_PUBLISH_VERSION}" fi + + release_info " Installing paperclipai@$DIST_TAG into a clean prefix..." + if ! verify_npm_installable "paperclipai@$DIST_TAG" "$TARGET_PUBLISH_VERSION"; then + release_fail "paperclipai@$DIST_TAG did not install cleanly at expected version ${TARGET_PUBLISH_VERSION}" + fi + release_info " ✓ Clean-prefix install resolved ${TARGET_PUBLISH_VERSION}" fi release_info ""