fix(release): gate channel on published package closure

Publish the user-facing CLI last, wait for registry visibility after every workspace package, and verify a clean canary install before tagging.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-31 18:38:54 +00:00
1 parent 8699e457e0
commit 7a10927ca3
7 files changed
+140 -27

No files matched your search

+5 -1
View File
@@ -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:
+4 -1
View File
@@ -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`
+37
View File
@@ -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}"
+26 -1
View File
@@ -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/);
});
+17 -1
View File
@@ -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) {
+27
View File
@@ -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");
+24 -23
View File
@@ -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 ""