Files
PaperClipAI/scripts/release-lib.sh
T
Devin Foley 8f7b8b3fda feat(release): add human-gated beta channel with stable soak enforcement (#11008)
> Follow-up to #11006 (merged): rebased onto master and ready for
review.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem now publishes canary (every master push),
nightly (scheduled, smoke-gated, added in #11006), and stable (manual)
> - There is still no human-approved release-candidate lane between
nightly and stable, and nothing enforces that a stable actually soaked
anywhere before shipping
> - Betas need a real approval gate, and stables need a soak policy that
is data, not prose
> - This pull request adds the beta channel: a manual promotion of a
chosen nightly behind the `npm-beta` environment gate, re-smoked after
publish, plus a stable preflight that enforces a 3-day beta soak with a
written-justification bypass
> - The benefit is a complete canary → nightly → beta → stable train
where every stable shipped as a beta first, and emergencies leave a
written trace

## Linked Issues or Issue Description

**Subsystem affected**

Release automation: `scripts/release.sh`, `scripts/release-lib.sh`,
`.github/workflows/release.yml`, `.github/workflows/docker.yml`,
`.github/workflows/release-smoke.yml`.

**Problem or motivation**

After #11006 the project has canary and nightly prerelease lanes, but no
release-candidate lane. Stable promotion has no enforced soak: any ref
can ship as stable directly. There is no approval boundary for a
broader-audience prerelease, and no structured way to record why an
emergency release skipped validation.

**Proposed solution**

Add a `beta` channel: a manual dispatch that promotes a chosen nightly's
source commit, publishes behind the `npm-beta` GitHub environment
(required reviewers are the gate), re-smokes the published beta, and
tags `beta/vX`. Enforce in the stable path that the source commit
shipped as a beta at least 3 days earlier (measured from the beta's npm
publish time), with a `skip_soak_justification` input as the recorded
emergency bypass.

**Alternatives considered**

Codifying the soak policy in docs only. Rejected: an unenforced policy
decays; the preflight makes the policy executable while the
justification input keeps the emergency path usable and auditable.

## What Changed

- `scripts/release.sh` + `scripts/release-lib.sh`: `beta` channel —
requires HEAD to carry a `nightly/v*` tag, publishes the package set as
`YYYY.MDD.P-beta.N` under dist-tag `beta`, tags
`beta/vYYYY.MDD.P-beta.N`
- `.github/workflows/release.yml`:
- `channel: beta` dispatch path: `select_beta` resolves the newest (or
an explicit `source_version`) nightly and fails loudly on selection
problems; `publish_beta` runs behind the `npm-beta` environment, pushes
the tag, and dispatches `docker.yml`; `smoke_beta` re-runs the release
smoke suite against the exact published beta version
- stable path: new `preflight_stable` job enforces the 3-day beta soak
from the beta's npm publish time; `skip_soak_justification` bypasses
with the reason echoed into the job summary; dry runs report without
blocking
- `.github/workflows/docker.yml`: `beta/v*` tags publish `:beta` on both
images, with exact version stamping
- `.github/workflows/release-smoke.yml`: `beta` added to the dispatch
choice list
- Docs: `CHANNELS.md` beta entries; `RELEASING.md` beta lane, soak gate,
and failure playbook; `RELEASE-AUTOMATION-SETUP.md` `npm-beta`
environment setup, including the warning to create the environment
before the first beta dispatch (GitHub auto-creates unprotected
environments on first reference)
- Tests: beta version-counting coverage in
`scripts/release-registry-versions.test.mjs`; beta identity and
nightly-tag guard coverage in
`scripts/__tests__/release-dry-run-notes.test.mjs`

## Verification

- `node --test` on the two touched suites: 17 pass, including the 3 new
beta tests
- `bash -n` on both shell scripts and YAML parse of all three workflows
- After merge, in order: create the `npm-beta` environment, dispatch
`channel: beta` with `dry_run: true` to preview, then a real promotion
of a published nightly through the approval gate, then a stable dry-run
against a young beta to see the soak gate report

## Risks

- If the `npm-beta` environment does not exist when the first beta
dispatch runs, GitHub creates it with no protection rules and the beta
publishes without approval. Mitigated by documentation and by creating
the environment before merge (operator step)
- Until the first beta exists, every stable dispatch requires
`skip_soak_justification`. This is deliberate — the first beta ships
immediately after this merges — but it is a behavior change to the
stable dispatch
- The soak clock reads the beta's npm publish time from the registry; a
registry outage makes the preflight fall back to requiring justification
(fail-closed)

## Model Used

Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use (repository exploration, local test
execution, live registry and git verification). All code, tests, and
docs in this PR were model-authored under human direction.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-10 16:52:59 -07:00

550 lines
14 KiB
Bash

#!/usr/bin/env bash
if [ -z "${REPO_ROOT:-}" ]; then
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
release_info() {
echo "$@"
}
release_warn() {
echo "Warning: $*" >&2
}
release_fail() {
echo "Error: $*" >&2
exit 1
}
git_remote_exists() {
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
}
github_repo_from_remote() {
local remote_url
remote_url="$(git -C "$REPO_ROOT" remote get-url "$1" 2>/dev/null || true)"
[ -n "$remote_url" ] || return 1
remote_url="${remote_url%.git}"
remote_url="${remote_url#ssh://}"
node - "$remote_url" <<'NODE'
const remoteUrl = process.argv[2];
const patterns = [
/^https?:\/\/github\.com\/([^/]+\/[^/]+)$/,
/^git@github\.com:([^/]+\/[^/]+)$/,
/^[^:]+:([^/]+\/[^/]+)$/
];
for (const pattern of patterns) {
const match = remoteUrl.match(pattern);
if (!match) continue;
process.stdout.write(match[1]);
process.exit(0);
}
process.exit(1);
NODE
}
resolve_release_remote() {
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"
if [ -n "$remote" ]; then
git_remote_exists "$remote" || release_fail "git remote '$remote' does not exist."
printf '%s\n' "$remote"
return
fi
if git_remote_exists public-gh; then
printf 'public-gh\n'
return
fi
if git_remote_exists public; then
printf 'public\n'
return
fi
if git_remote_exists origin; then
printf 'origin\n'
return
fi
release_fail "no git remote found. Configure RELEASE_REMOTE or PUBLISH_REMOTE."
}
fetch_release_remote() {
git -C "$REPO_ROOT" fetch "$1" --prune --tags
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_tag_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
}
git_remote_tag_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
}
get_last_stable_tag() {
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
}
get_current_stable_version() {
local tag
tag="$(get_last_stable_tag)"
if [ -z "$tag" ]; then
printf '0.0.0\n'
else
printf '%s\n' "${tag#v}"
fi
}
stable_version_slot_for_date() {
node - "${1:-}" <<'NODE'
const input = process.argv[2];
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
}
const month = String(date.getUTCMonth() + 1);
const day = String(date.getUTCDate()).padStart(2, '0');
process.stdout.write(`${date.getUTCFullYear()}.${month}${day}`);
NODE
}
utc_date_iso() {
node <<'NODE'
const date = new Date();
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
process.stdout.write(`${y}-${m}-${d}`);
NODE
}
next_stable_version() {
local release_date="$1"
shift
node - "$release_date" "$@" <<'NODE'
const input = process.argv[2];
const packageNames = process.argv.slice(3);
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
}
// Optional pre-fetched version data (see release-registry-versions.mjs).
// Avoids one serial `npm view` round-trip per package.
let versionsCache = null;
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
try {
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
} catch {
versionsCache = null;
}
}
const stableSlot = `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}${String(date.getUTCDate()).padStart(2, "0")}`;
const pattern = new RegExp(`^${stableSlot.replace(/\./g, '\\.')}\.(\\d+)$`);
let max = -1;
for (const packageName of packageNames) {
let versions = [];
if (versionsCache && Array.isArray(versionsCache[packageName])) {
versions = versionsCache[packageName];
} else {
try {
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (raw) {
const parsed = JSON.parse(raw);
versions = Array.isArray(parsed) ? parsed : [parsed];
}
} catch {
versions = [];
}
}
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
}
process.stdout.write(`${stableSlot}.${max + 1}`);
NODE
}
require_prerelease_channel() {
case "$1" in
canary|nightly|beta) ;;
*) release_fail "unknown prerelease channel: $1" ;;
esac
}
next_prerelease_version() {
local channel="$1"
local stable_version="$2"
shift 2
require_prerelease_channel "$channel"
node - "$channel" "$stable_version" "$@" <<'NODE'
const channel = process.argv[2];
const stable = process.argv[3];
const packageNames = process.argv.slice(4);
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
// Optional pre-fetched version data (see release-registry-versions.mjs).
// Avoids one serial `npm view` round-trip per package.
let versionsCache = null;
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
try {
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
} catch {
versionsCache = null;
}
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-${channel}\\.(\\d+)$`);
let max = -1;
for (const packageName of packageNames) {
let versions = [];
if (versionsCache && Array.isArray(versionsCache[packageName])) {
versions = versionsCache[packageName];
} else {
try {
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (raw) {
const parsed = JSON.parse(raw);
versions = Array.isArray(parsed) ? parsed : [parsed];
}
} catch {
versions = [];
}
}
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
}
process.stdout.write(`${stable}-${channel}.${max + 1}`);
NODE
}
next_canary_version() {
local stable_version="$1"
shift
next_prerelease_version canary "$stable_version" "$@"
}
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
stable_tag_name() {
printf 'v%s\n' "$1"
}
prerelease_tag_name() {
require_prerelease_channel "$1"
printf '%s/v%s\n' "$1" "$2"
}
canary_tag_name() {
prerelease_tag_name canary "$1"
}
npm_package_version_exists() {
local package_name="$1"
local version="$2"
local resolved
resolved="$(npm view "${package_name}@${version}" version 2>/dev/null || true)"
[ "$resolved" = "$version" ]
}
wait_for_npm_package_version() {
local package_name="$1"
local version="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-5}"
local attempt=1
while [ "$attempt" -le "$attempts" ]; do
if npm_package_version_exists "$package_name" "$version"; then
return 0
fi
if [ "$attempt" -lt "$attempts" ]; then
sleep "$delay_seconds"
fi
attempt=$((attempt + 1))
done
return 1
}
is_npm_tlog_duplicate_error() {
local output="$1"
grep -q "TLOG_CREATE_ENTRY_ERROR" <<< "$output" &&
grep -q "equivalent entry already exists in the transparency log" <<< "$output"
}
package_publish_tool() {
node -e '
const pkg = require(process.cwd() + "/package.json");
const bundled = pkg.bundleDependencies ?? pkg.bundledDependencies ?? [];
process.stdout.write(bundled.length > 0 ? "npm" : "pnpm");
'
}
BUNDLED_NPM_PACK_VERSION="10.9.7"
BUNDLED_NPM_PUBLISH_VERSION="11.18.0"
run_bundled_npm_pack() {
npx --yes "npm@$BUNDLED_NPM_PACK_VERSION" "$@"
}
run_bundled_npm_publish() {
npx --yes "npm@$BUNDLED_NPM_PUBLISH_VERSION" "$@" --loglevel verbose
}
run_package_publish() {
local publish_tool="$1"
local dist_tag="$2"
local disable_provenance="${3:-false}"
if [ "$publish_tool" = "npm" ]; then
if [ "$disable_provenance" = "true" ]; then
run_bundled_npm_publish publish --tag "$dist_tag" --access public --provenance=false
else
run_bundled_npm_publish publish --tag "$dist_tag" --access public
fi
return
fi
if [ "$disable_provenance" = "true" ]; then
pnpm publish --no-git-checks --tag "$dist_tag" --access public --provenance=false
else
pnpm publish --no-git-checks --tag "$dist_tag" --access public
fi
}
publish_package_to_npm() {
local dist_tag="$1"
local package_name="$2"
local package_version="$3"
local publish_tool="${4:-pnpm}"
local publish_log
publish_log="$(mktemp "${TMPDIR:-/tmp}/paperclip-npm-publish.XXXXXX")"
if (set -o pipefail; run_package_publish "$publish_tool" "$dist_tag" false 2>&1 | tee "$publish_log"); then
rm -f "$publish_log"
return 0
fi
if ! is_npm_tlog_duplicate_error "$(cat "$publish_log")"; then
rm -f "$publish_log"
return 1
fi
release_warn "npm publish hit a duplicate Sigstore transparency-log entry for ${package_name}@${package_version}."
if npm_package_version_exists "$package_name" "$package_version"; then
release_warn "npm already exposes ${package_name}@${package_version}; continuing to registry verification."
rm -f "$publish_log"
return 0
fi
case "$dist_tag" in
canary|nightly) ;;
*)
release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
rm -f "$publish_log"
return 1
;;
esac
release_warn "Retrying ${package_name}@${package_version} once with npm provenance disabled."
if run_package_publish "$publish_tool" "$dist_tag" true; then
rm -f "$publish_log"
return 0
fi
rm -f "$publish_log"
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}"
shift 2
local attempt=1
local output
local status
while [ "$attempt" -le "$attempts" ]; do
if output="$(node "$REPO_ROOT/scripts/verify-release-registry-state.mjs" "$@" 2>&1)"; then
[ -n "$output" ] && printf '%s\n' "$output"
return 0
fi
status=$?
printf '%s\n' "$output" >&2
if [ "$status" -eq 2 ]; then
return "$status"
fi
if [ "$attempt" -lt "$attempts" ]; then
release_warn "npm registry metadata has not converged yet (attempt ${attempt}/${attempts}); retrying in ${delay_seconds}s."
sleep "$delay_seconds"
fi
attempt=$((attempt + 1))
done
return "${status:-1}"
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
fi
}
require_on_master_branch() {
local current_branch
current_branch="$(git_current_branch)"
if [ "$current_branch" != "master" ]; then
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
fi
}
# Promotion channels only republish commits that already shipped on the
# previous lane, so the source commit must carry that lane's release tag.
require_channel_tag_at_head() {
local channel="$1"
require_prerelease_channel "$channel"
if ! git -C "$REPO_ROOT" tag --points-at HEAD | grep -q "^${channel}/v"; then
release_fail "HEAD has no ${channel}/v* tag; this channel only publishes commits that already shipped a ${channel} release."
fi
}
# The inverse guard: a commit ships on a promotion channel at most once, so
# concurrent or repeated runs cannot double-publish it. Delete the lane tag
# first if a republish is genuinely intended.
require_channel_tag_absent_at_head() {
local channel="$1"
local existing
require_prerelease_channel "$channel"
existing="$(git -C "$REPO_ROOT" tag --points-at HEAD | grep "^${channel}/v" | head -1 || true)"
if [ -n "$existing" ]; then
release_fail "HEAD already shipped as ${existing}; delete that tag first if you really want to republish this commit on the ${channel} channel."
fi
}
require_npm_publish_auth() {
local dry_run="$1"
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
}
list_public_package_info() {
node "$REPO_ROOT/scripts/release-package-map.mjs" list
}
set_public_package_version() {
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
}