diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 42dbbc602b..2e41dd7657 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,6 +7,7 @@ on: tags: - "v*" - "nightly/v*" + - "beta/v*" # Release workflows push lane tags with GITHUB_TOKEN, and GitHub suppresses # push-triggered runs for those, so release.yml dispatches this workflow at # the new tag ref instead. The tag mapping below keys off github.ref either @@ -52,6 +53,9 @@ jobs: # instead of describing drift from the nearest stable tag. version="${GITHUB_REF#refs/tags/nightly/v}" ;; + refs/tags/beta/v*) + version="${GITHUB_REF#refs/tags/beta/v}" + ;; *) version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" ;; @@ -157,6 +161,7 @@ jobs: tags: | type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }} + type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} @@ -223,6 +228,9 @@ jobs: # instead of describing drift from the nearest stable tag. version="${GITHUB_REF#refs/tags/nightly/v}" ;; + refs/tags/beta/v*) + version="${GITHUB_REF#refs/tags/beta/v}" + ;; *) version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" ;; @@ -330,6 +338,7 @@ jobs: tags: | type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }} + type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 073be39d38..5ec66da8a6 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -11,6 +11,7 @@ on: options: - canary - nightly + - beta - latest host_port: description: Host port for the Docker smoke container diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 024244408b..b24bdc1866 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ on: type: choice options: - stable + - beta - nightly default: stable source_ref: @@ -27,7 +28,11 @@ on: required: false type: string source_version: - description: (nightly) Explicit canary version to promote, for example 2026.806.0-canary.7. Leave empty to select the newest canary on master. + description: For nightly, the explicit canary version to promote (empty selects the newest canary on master). For beta, the explicit nightly version to promote (empty selects the newest nightly on master). + required: false + type: string + skip_soak_justification: + description: (stable) Written justification for publishing a stable whose source has not soaked as a beta for 3 days. Leave empty for normal releases. required: false type: string dry_run: @@ -314,17 +319,306 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY" + # ----- Beta lane -------------------------------------------------------- + # Beta is a manual, human-approved promotion of a nightly. Unlike the + # scheduled nightly lane, a beta dispatch is explicit operator intent, so + # selection problems fail the run loudly instead of skipping quietly. The + # publish runs behind the npm-beta environment, whose required reviewers + # are the approval gate. + + select_beta: + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'beta' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + sha: ${{ steps.select.outputs.sha }} + nightly_version: ${{ steps.select.outputs.nightly_version }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: master + fetch-depth: 0 + + - name: Select beta candidate + id: select + env: + EXPLICIT_NIGHTLY_VERSION: ${{ inputs.source_version }} + run: | + set -euo pipefail + + git fetch origin --tags --prune --quiet + + if [ -n "${EXPLICIT_NIGHTLY_VERSION:-}" ]; then + tag="nightly/v${EXPLICIT_NIGHTLY_VERSION}" + sha="$(git rev-list -n 1 "$tag" 2>/dev/null || true)" + if [ -z "$sha" ]; then + echo "Error: tag $tag does not exist." >&2 + exit 1 + fi + else + # Newest nightly-tagged commit on master. + sha="$(grep -m1 -F \ + -f <(git for-each-ref 'refs/tags/nightly/v*' --format='%(objectname)') \ + <(git rev-list origin/master -n 2000) || true)" + + if [ -z "$sha" ]; then + echo "Error: no nightly/v* tag found on the last 2000 commits of master. Publish a nightly first, or pass source_version." >&2 + exit 1 + fi + + tag="$(git tag --points-at "$sha" | grep '^nightly/v' | sort -V | tail -1)" + fi + + nightly_version="${tag#nightly/v}" + + existing_beta="$(git tag --points-at "$sha" | grep '^beta/v' | head -1 || true)" + if [ -n "$existing_beta" ]; then + echo "Error: candidate $sha (nightly $nightly_version) already shipped as $existing_beta." >&2 + exit 1 + fi + + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "nightly_version=$nightly_version" >> "$GITHUB_OUTPUT" + { + echo "## Beta candidate" + echo "" + echo "- Source SHA: \`$sha\`" + echo "- Source nightly: \`$nightly_version\`" + } >> "$GITHUB_STEP_SUMMARY" + + publish_beta: + needs: select_beta + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-beta + # Serialize beta publishes so two dispatches cannot race to the same next + # -beta.N version; release.sh additionally refuses to double-publish a + # commit that already carries a beta tag. + concurrency: + group: release-publish-beta + cancel-in-progress: false + permissions: + contents: write + id-token: write + actions: write + outputs: + beta_version: ${{ steps.result.outputs.beta_version }} + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ needs.select_beta.outputs.sha }} + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + 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 + + - name: Restore tracked install-time changes + run: git checkout -- pnpm-lock.yaml + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Publish beta + env: + GITHUB_ACTIONS: "true" + run: | + args=(beta --skip-verify) + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=(--dry-run) + fi + ./scripts/release.sh "${args[@]}" + + - name: Dump npm debug logs + if: failure() + run: | + shopt -s nullglob + for f in "$HOME"/.npm/_logs/*.log; do + echo "===== $f =====" + tail -n 300 "$f" | sed -E \ + -e 's#((authorization|_authToken|_auth|node_auth_token|npm_token)"?[[:space:]]*[:=][[:space:]]*"?)(Bearer[[:space:]]+)?[^",[:space:]]+#\1***REDACTED***#Ig' + done + + - name: Push beta tag + if: ${{ !inputs.dry_run }} + run: | + tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)" + if [ -z "$tag" ]; then + echo "Error: no beta tag points at HEAD after release." >&2 + exit 1 + fi + git push origin "refs/tags/${tag}" + + # Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag + # trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN + # pushes), so dispatch the image build at the new tag explicitly. + - name: Build Docker images for the beta tag + id: result + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)" + echo "beta_version=${tag#beta/v}" >> "$GITHUB_OUTPUT" + { + echo "## Beta published" + echo "" + echo "- Source SHA: \`${{ needs.select_beta.outputs.sha }}\`" + echo "- Source nightly: \`${{ needs.select_beta.outputs.nightly_version }}\`" + echo "- Published beta: \`${tag#beta/v}\`" + echo "- Docker build dispatched at \`${tag}\`" + } >> "$GITHUB_STEP_SUMMARY" + gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY" + + # Post-publish verification: run the release smoke suite against the exact + # beta version that was just published. + smoke_beta: + needs: publish_beta + if: ${{ !inputs.dry_run }} + uses: ./.github/workflows/release-smoke.yml + with: + paperclip_version: ${{ needs.publish_beta.outputs.beta_version }} + artifact_name: beta-release-smoke + # ----- Stable lane ------------------------------------------------------ + # Stable releases promote a soaked beta. The preflight enforces that the + # source commit shipped as a beta at least 3 days ago (measured from the + # npm publish time of that beta version), unless a written justification + # is provided. Dry runs report soak state without blocking. + # Resolves source_ref to an immutable commit exactly once; every downstream + # stable job consumes that SHA. Otherwise a branch or movable tag that + # advances mid-run could be soak-checked at one commit and verified or + # published at another. + preflight_stable: + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + sha: ${{ steps.soak.outputs.sha }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: master + fetch-depth: 0 + + - name: Check beta soak + id: soak + env: + SOURCE_REF: ${{ inputs.source_ref }} + JUSTIFICATION: ${{ inputs.skip_soak_justification }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + git fetch origin --tags --prune --quiet + + sha="$(git rev-parse --verify "${SOURCE_REF}^{commit}" 2>/dev/null || true)" + if [ -z "$sha" ]; then + git fetch origin "$SOURCE_REF" --quiet || true + sha="$(git rev-parse --verify "FETCH_HEAD^{commit}" 2>/dev/null || true)" + fi + if [ -z "$sha" ]; then + echo "Error: could not resolve source_ref '$SOURCE_REF' to a commit." >&2 + exit 1 + fi + + echo "sha=$sha" >> "$GITHUB_OUTPUT" + { + echo "## Stable source pinned" + echo "" + echo "- source_ref: \`${SOURCE_REF}\` -> \`$sha\`" + } >> "$GITHUB_STEP_SUMMARY" + + fail_or_justify() { + if [ -n "${JUSTIFICATION:-}" ]; then + { + echo "## Stable soak gate bypassed" + echo "" + echo "$1" + echo "" + echo "Justification: ${JUSTIFICATION}" + } >> "$GITHUB_STEP_SUMMARY" + echo "::warning::Soak gate bypassed: $1" + return 0 + fi + if [ "${DRY_RUN}" = "true" ]; then + echo "::warning::Soak gate would block a real release: $1" + { + echo "## Stable soak gate (dry run)" + echo "" + echo "A real release would be blocked: $1" + } >> "$GITHUB_STEP_SUMMARY" + return 0 + fi + echo "Error: $1" >&2 + echo "Pass skip_soak_justification with a written reason to release anyway." >&2 + exit 1 + } + + beta_tag="$(git tag --points-at "$sha" | grep '^beta/v' | sort -V | tail -1 || true)" + if [ -z "$beta_tag" ]; then + fail_or_justify "source commit $sha never shipped as a beta (no beta/v* tag)." + exit 0 + fi + + beta_version="${beta_tag#beta/v}" + publish_time="$(npm view "paperclipai@${beta_version}" time --json 2>/dev/null \ + | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const t=JSON.parse(d);process.stdout.write(typeof t === "string" ? t : (t[process.argv[1]] ?? ""))})' "$beta_version" || true)" + if [ -z "$publish_time" ]; then + fail_or_justify "could not determine the npm publish time of beta ${beta_version}." + exit 0 + fi + + age_seconds="$(node -e 'process.stdout.write(String(Math.floor((Date.now() - Date.parse(process.argv[1])) / 1000)))' "$publish_time")" + min_seconds=$((3 * 24 * 60 * 60)) + age_days="$(node -e 'process.stdout.write((Number(process.argv[1]) / 86400).toFixed(1))' "$age_seconds")" + + if [ "$age_seconds" -lt "$min_seconds" ]; then + fail_or_justify "beta ${beta_version} has only soaked ${age_days} days (minimum is 3)." + exit 0 + fi + + { + echo "## Stable soak gate passed" + echo "" + echo "- Source beta: \`${beta_version}\`" + echo "- Soak time: ${age_days} days" + } >> "$GITHUB_STEP_SUMMARY" + verify_stable: if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' + needs: preflight_stable uses: ./.github/workflows/release-verify.yml with: - ref: ${{ inputs.source_ref }} + ref: ${{ needs.preflight_stable.outputs.sha }} preview_stable: if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run - needs: verify_stable + needs: [preflight_stable, verify_stable] runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -335,7 +629,7 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.source_ref }} + ref: ${{ needs.preflight_stable.outputs.sha }} - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -366,7 +660,7 @@ jobs: publish_stable: if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run - needs: verify_stable + needs: [preflight_stable, verify_stable] runs-on: ubuntu-latest timeout-minutes: 45 environment: npm-stable @@ -380,7 +674,7 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - ref: ${{ inputs.source_ref }} + ref: ${{ needs.preflight_stable.outputs.sha }} - name: Setup pnpm uses: pnpm/action-setup@v6 diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md index 9aadd87ba7..b99ae33a3d 100644 --- a/doc/CHANNELS.md +++ b/doc/CHANNELS.md @@ -7,14 +7,20 @@ install. | Channel | What it is | Updates | npm | Docker | | --- | --- | --- | --- | --- | | `stable` | The recommended release | every week or two | `paperclipai@latest` | `ghcr.io/paperclipai/paperclip:latest` | -| `beta` | Release candidates soaking before stable | when promoted | *(coming soon)* | *(coming soon)* | +| `beta` | Release candidates soaking before stable | when promoted | `paperclipai@beta` | `ghcr.io/paperclipai/paperclip:beta` | | `nightly` | Yesterday's merges, smoke-tested as a unit | once a night | `paperclipai@nightly` | `ghcr.io/paperclipai/paperclip:nightly` | | `canary` | Every merge to `master`, as it happens | many times a day | `paperclipai@canary` | `ghcr.io/paperclipai/paperclip:canary` | ## Choosing a channel **stable** is the right choice for almost everyone. It only moves when a -release has been explicitly vetted and promoted by a maintainer. +release has been explicitly vetted and promoted by a maintainer, and every +stable must first soak as a beta for at least 3 days. + +**beta** is for people who want the next stable early. A beta is a nightly +that a maintainer hand-picked and explicitly promoted behind an approval +gate, and it is re-smoked after publishing. Betas are the release candidates: +what you run on beta today is what stable becomes a few days later. **nightly** is for people who want new features quickly but not the churn of tracking every merge. Once a night, the newest master build that published @@ -33,6 +39,7 @@ npm / npx: ```bash npx paperclipai@latest onboard # stable +npx paperclipai@beta onboard npx paperclipai@nightly onboard npx paperclipai@canary onboard ``` @@ -41,6 +48,7 @@ Docker: ```bash docker pull ghcr.io/paperclipai/paperclip:latest # stable +docker pull ghcr.io/paperclipai/paperclip:beta docker pull ghcr.io/paperclipai/paperclip:nightly docker pull ghcr.io/paperclipai/paperclip:canary ``` @@ -61,17 +69,19 @@ directory before switching down. The version tells you which channel a build came from: - `2026.807.0` — stable, published Aug 7 2026 +- `2026.807.0-beta.0` — beta promoted on Aug 7 2026 - `2026.807.0-nightly.0` — nightly cut on Aug 7 2026 - `2026.807.0-canary.4` — the fifth canary for the Aug 7 line -A nightly republishes the exact source commit of a specific canary; the -version dates the nightly cut, and the two share a source SHA (visible in the -release job summary and as git tags on the commit). +Each promotion republishes the exact source commit of the previous lane's +build: a nightly shares its source SHA with a canary, and a beta with a +nightly. The version dates the promotion, and the shared SHA is visible in +the release job summaries and as git tags on the commit. One quirk to be aware of: npm's semver ordering compares prerelease names -alphabetically, so `-canary.N` sorts *below* `-nightly.N` for the same base -version. This never matters when installing by dist-tag (the recommended way), -only if you write version ranges by hand. +alphabetically, so `-beta.N` sorts below `-canary.N`, which sorts below +`-nightly.N` for the same base version. This never matters when installing by +dist-tag (the recommended way), only if you write version ranges by hand. ## For maintainers diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index 157bf6526c..e8131595f8 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -113,9 +113,10 @@ Goal: ## 4. Create GitHub Environments -Create two environments in the GitHub repository: +Create three environments in the GitHub repository: - `npm-canary` +- `npm-beta` - `npm-stable` Path: @@ -147,6 +148,29 @@ the branch rule is satisfied, and reusing the environment means the nightly lane required no new environments and no npm trusted-publisher changes (publishing still happens from `release.yml`, see section 2.2). +## 5.1. Configure `npm-beta` + +Recommended settings for `npm-beta`: + +- environment name: `npm-beta` +- required reviewers: at least one maintainer +- prevent self-review: enabled when your team size allows it +- wait timer: none +- deployment branches and tags: + - selected branches only + - allow `master` + +Reasoning: + +- beta promotions are deliberate human decisions; the required reviewer on + this environment is the promotion gate +- create this environment before the first `channel: beta` dispatch. If the + workflow runs first, GitHub auto-creates the environment with no + protection rules, and that first beta would publish without approval + +Like nightly, beta publishing lives in `release.yml`, so no npm +trusted-publisher changes are needed (see section 2.2). + ## 6. Configure `npm-stable` Recommended settings for `npm-stable`: diff --git a/doc/RELEASING.md b/doc/RELEASING.md index b623bd428a..1559601ec7 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -7,9 +7,11 @@ The release model is now commit-driven: 1. Every push to `master` publishes a canary automatically. 2. Once a night, the newest master commit with a green canary publish is smoke-tested and republished as the nightly. -3. Stable releases are manually promoted from a chosen tested commit or canary tag. -4. Stable release notes live in `releases/vYYYY.MDD.P.md`. -5. Only stable releases get GitHub Releases. +3. Betas are manual, human-approved promotions of a chosen nightly. +4. Stable releases promote a beta that has soaked for at least 3 days + (bypass requires a written justification). +5. Stable release notes live in `releases/vYYYY.MDD.P.md`. +6. Only stable releases get GitHub Releases. The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md). @@ -20,6 +22,7 @@ Paperclip uses calendar versions that still fit semver syntax: - stable: `YYYY.MDD.P` - canary: `YYYY.MDD.P-canary.N` - nightly: `YYYY.MDD.P-nightly.N` +- beta: `YYYY.MDD.P-beta.N` Examples: @@ -27,10 +30,11 @@ Examples: - second stable on March 18, 2026: `2026.318.1` - fourth canary for the `2026.318.1` line: `2026.318.1-canary.3` - first nightly cut on March 18, 2026: `2026.318.1-nightly.0` +- first beta promoted on March 18, 2026: `2026.318.1-beta.0` -A nightly republishes the exact source commit of an existing canary; its -version dates the nightly cut (the scheduled run's UTC date), not the source -canary. +A promotion republishes the exact source commit of the previous lane's build +(canary → nightly → beta); the version dates the promotion, not the source +build. Important constraints: @@ -51,8 +55,8 @@ Every stable release has four separate surfaces: A stable release is done only when all four surfaces are handled. -Canaries and nightlies only cover the first two surfaces plus an internal -traceability tag. +Canaries, nightlies, and betas only cover the first two surfaces plus an +internal traceability tag. ## Core Invariants @@ -60,13 +64,18 @@ traceability tag. - nightlies republish a commit that already shipped a canary (the commit must carry a `canary/v*` tag), and only after the release smoke suite passes against that exact published canary -- stables publish from an explicitly chosen source ref +- betas republish a commit that already shipped a nightly (the commit must + carry a `nightly/v*` tag), behind the `npm-beta` approval gate, and the + published beta is re-smoked +- stables publish from an explicitly chosen source ref, which must have + shipped as a beta at least 3 days earlier unless a written justification + is provided - tags point at the original source commit, not a generated release commit - stable notes are always `releases/vYYYY.MDD.P.md` -- canaries and nightlies never create GitHub Releases -- canaries and nightlies never require changelog generation +- canaries, nightlies, and betas never create GitHub Releases +- canaries, nightlies, and betas never require changelog generation - Docker `:latest` moves only on stable releases; master builds publish - `:canary` and nightly builds publish `:nightly` + `:canary`, nightly builds `:nightly`, and beta builds `:beta` ## TL;DR @@ -128,6 +137,31 @@ Users install nightlies with: npx paperclipai@nightly onboard ``` +### Beta + +Betas are manual promotions. Dispatch +[`release.yml`](../.github/workflows/release.yml) with `channel: beta`. + +- leave `source_version` empty to promote the newest nightly, or set it to an + exact nightly version such as `2026.807.0-nightly.0` +- the selection job resolves the nightly's source commit and fails loudly if + it does not exist or already shipped as a beta +- the publish waits for approval in the **`npm-beta` environment** — its + required reviewers are the promotion gate +- the same commit is republished as `YYYY.MDD.P-beta.N` under the npm + dist-tag `beta`, tagged `beta/vYYYY.MDD.P-beta.N`, and `docker.yml` is + dispatched at that tag to publish the `:beta` images +- after publishing, the release smoke suite runs against the exact published + beta version as verification +- `dry_run: true` previews the publish and skips the tag push, Docker + dispatch, and post-publish smoke + +Users install betas with: + +```bash +npx paperclipai@beta onboard +``` + ### Stable Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs. @@ -137,22 +171,31 @@ Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Inputs: - `channel` - - `stable` (the default) for a stable release; `nightly` forces a nightly - run (see above) + - `stable` (the default) for a stable release; `beta` and `nightly` run + those lanes instead (see above) - `source_ref` - commit SHA, branch, or tag - `stable_date` - optional UTC date override in `YYYY-MM-DD` - enter a date like `2026-03-18`, not a version like `2026.318.0` +- `skip_soak_justification` + - written reason for releasing a stable whose source has not soaked as a + beta for 3 days; leave empty for normal releases - `dry_run` - preview only when true +The stable preflight enforces the beta soak: the source commit must carry a +`beta/v*` tag whose npm publish time is at least 3 days old. If it is not, +the run fails unless `skip_soak_justification` is provided; the justification +is echoed into the job summary. Dry runs report soak state without blocking. + Before running stable: -1. pick the canary commit or tag you trust -2. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version` -3. create or update `releases/vYYYY.MDD.P.md` on that source ref -4. run the stable workflow from that source ref +1. pick the beta you are promoting (its source commit is the `source_ref`) +2. confirm the beta has soaked for 3 days with no open blockers +3. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version` +4. create or update `releases/vYYYY.MDD.P.md` on that source ref +5. run the stable workflow from that source ref Example: @@ -179,6 +222,7 @@ image and the `-cloud` variant with the same lane mapping: | --- | --- | | `master` push | `:canary`, `:sha-` | | `nightly/v*` tag | `:nightly`, `:sha-` | +| `beta/v*` tag | `:beta`, `:sha-` | | `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-` | Lane tags are pushed by release workflows using `GITHUB_TOKEN`, and GitHub @@ -203,6 +247,15 @@ Requires HEAD to be a commit that already shipped a canary (it must carry a ./scripts/release.sh nightly --dry-run ``` +### Preview a beta locally + +Requires HEAD to be a commit that already shipped a nightly (it must carry a +`nightly/v*` tag): + +```bash +./scripts/release.sh beta --dry-run +``` + ### Preview a stable locally ```bash @@ -266,11 +319,13 @@ Automated browser smoke is also available: ```bash gh workflow run release-smoke.yml -f paperclip_version=canary gh workflow run release-smoke.yml -f paperclip_version=nightly +gh workflow run release-smoke.yml -f paperclip_version=beta gh workflow run release-smoke.yml -f paperclip_version=latest ``` The nightly lane runs this same suite automatically against its candidate -before publishing. +before publishing, and the beta lane runs it against the published beta as +post-publish verification. Minimum checks: @@ -321,6 +376,16 @@ force one: dispatch `release.yml` with `channel: nightly` (optionally pinning If the nightly published to npm but the tag push or Docker dispatch failed, push the `nightly/v*` tag manually and run `docker.yml` at that tag. +### If a beta looks bad during soak + +Do not promote it to stable. Fix forward: land the fix on `master`, let it +ship through canary and nightly, and promote a new beta. The soak clock +starts over for the new beta. + +If the published beta is actively harmful to beta users, move the `beta` +dist-tag back to the previous beta version with `npm dist-tag add` per +package, and re-point the `:beta` Docker tags at the previous beta's images. + ### If stable npm publish succeeds but tag push or GitHub release creation fails This is a partial release. npm is already live. diff --git a/scripts/__tests__/release-dry-run-notes.test.mjs b/scripts/__tests__/release-dry-run-notes.test.mjs index cbd7f460ad..64f70fcc0f 100644 --- a/scripts/__tests__/release-dry-run-notes.test.mjs +++ b/scripts/__tests__/release-dry-run-notes.test.mjs @@ -195,3 +195,35 @@ test("nightly refuses commits that already shipped as a nightly", () => { assert.match(result.output, /HEAD already shipped as nightly\/v/); assert.doesNotMatch(result.calls, /^pnpm /m); }); + +test("beta dry-run publishes under the beta identity without release notes", () => { + const result = runRelease(["beta", "--skip-verify", "--dry-run"]); + + assert.equal(result.status, 42); + assert.match(result.output, /\[fixture\] require_channel_tag_at_head nightly/); + assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/); + assert.match(result.output, /Dist-tag: beta/); + assert.match(result.output, /Git tag: beta\/v2026\.710\.0-beta\.0/); + assert.doesNotMatch(result.output, /stable release notes file is required/); + assert.match(result.calls, /^pnpm build$/m); +}); + +test("beta refuses commits that already shipped as a beta", () => { + const result = runRelease(["beta", "--skip-verify", "--dry-run"], { + FAKE_PRESENT_CHANNEL_TAG: "beta", + }); + + assert.equal(result.status, 1); + assert.match(result.output, /HEAD already shipped as beta\/v/); + assert.doesNotMatch(result.calls, /^pnpm /m); +}); + +test("beta refuses commits that never shipped a nightly", () => { + const result = runRelease(["beta", "--skip-verify", "--dry-run"], { + FAKE_MISSING_CHANNEL_TAG: "nightly", + }); + + assert.equal(result.status, 1); + assert.match(result.output, /HEAD has no nightly\/v\* tag/); + assert.doesNotMatch(result.calls, /^pnpm /m); +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index d259376d23..29ffad1469 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -20,9 +20,11 @@ test("release workflow delegates stable and canary verification to the reusable // The stable lane is gated on the stable channel since the nightly lane // was added; a `needs:` line (for example a preflight job) may sit between // the gate and the delegation. + // The stable preflight resolves source_ref to an immutable SHA exactly + // once; verification must consume that pin, not re-resolve the ref. assert.match( releaseWorkflow, - /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ inputs\.source_ref \}\}/, + /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ needs\.preflight_stable\.outputs\.sha \}\}/, ); assert.doesNotMatch(releaseWorkflow, /verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/); }); diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index 7e3b1b4de9..6d1c37fe52 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -199,7 +199,7 @@ NODE require_prerelease_channel() { case "$1" in - canary|nightly) ;; + canary|nightly|beta) ;; *) release_fail "unknown prerelease channel: $1" ;; esac } diff --git a/scripts/release-registry-versions.test.mjs b/scripts/release-registry-versions.test.mjs index 08566a699f..ff107d9b0e 100644 --- a/scripts/release-registry-versions.test.mjs +++ b/scripts/release-registry-versions.test.mjs @@ -217,6 +217,27 @@ test("next_prerelease_version counts per channel so nightly numbering ignores ca assert.doesNotMatch(result.calls, /npm view/); }); +test("next_prerelease_version counts beta numbering independently of other channels", () => { + const fixture = makeFixture(); + const versionsFile = join(fixture.fixtureDir, "versions.json"); + writeFileSync( + versionsFile, + JSON.stringify({ + "@paperclipai/a": ["2026.707.1-canary.4", "2026.707.1-nightly.3", "2026.707.1-beta.0"], + }), + ); + + const result = runReleaseLibHelper( + 'next_prerelease_version beta 2026.707.1 "@paperclipai/a"', + fixture, + { RELEASE_PACKAGE_VERSIONS_FILE: versionsFile }, + ); + + assert.equal(result.status, 0); + assert.equal(result.output, "2026.707.1-beta.1"); + assert.doesNotMatch(result.calls, /npm view/); +}); + test("next_prerelease_version rejects unknown channels", () => { const fixture = makeFixture(); const result = runReleaseLibHelper( diff --git a/scripts/release.sh b/scripts/release.sh index 74e563db7a..db3a4ea90a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,12 +18,13 @@ 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] Examples: ./scripts/release.sh canary ./scripts/release.sh canary --date 2026-03-17 --dry-run ./scripts/release.sh nightly --dry-run + ./scripts/release.sh beta --dry-run ./scripts/release.sh stable ./scripts/release.sh stable --date 2026-03-17 --dry-run ./scripts/release.sh stable --date 2026-03-18 --print-version @@ -37,6 +38,9 @@ Notes: must carry a canary/v* tag) as YYYY.MDD.P-nightly.N under the npm dist-tag "nightly", with the git tag nightly/vYYYY.MDD.P-nightly.N. The version dates the nightly cut, not the source canary. + - Beta releases republish a commit that already shipped a nightly (HEAD + must carry a nightly/v* tag) as YYYY.MDD.P-beta.N under the npm + dist-tag "beta", with the git tag beta/vYYYY.MDD.P-beta.N. - Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and create the git tag vYYYY.MDD.P. - Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md. @@ -90,7 +94,7 @@ set_cleanup_trap() { while [ $# -gt 0 ]; do case "$1" in - canary|nightly|stable) + canary|nightly|beta|stable) if [ -n "$channel" ]; then release_fail "only one release channel may be provided." fi @@ -162,6 +166,13 @@ elif [ "$channel" = "nightly" ]; then TARGET_PUBLISH_VERSION="$(next_prerelease_version nightly "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")" DIST_TAG="nightly" tag_name="$(prerelease_tag_name nightly "$TARGET_PUBLISH_VERSION")" +elif [ "$channel" = "beta" ]; then + # Beta promotes an already-shipped nightly commit. + require_channel_tag_at_head nightly + require_channel_tag_absent_at_head beta + TARGET_PUBLISH_VERSION="$(next_prerelease_version beta "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")" + DIST_TAG="beta" + tag_name="$(prerelease_tag_name beta "$TARGET_PUBLISH_VERSION")" else tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")" fi @@ -209,6 +220,7 @@ release_info " Target stable version: $TARGET_STABLE_VERSION" case "$channel" in canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;; nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;; + beta) release_info " Beta version: $TARGET_PUBLISH_VERSION" ;; *) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;; esac release_info " Dist-tag: $DIST_TAG" @@ -372,7 +384,7 @@ if [ "$dry_run" = true ]; then release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}." else case "$channel" in - canary|nightly) + canary|nightly|beta) release_info "Published $channel ${TARGET_PUBLISH_VERSION}." release_info "Install with: npx paperclipai@$channel onboard" release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}" diff --git a/scripts/verify-release-registry-state.mjs b/scripts/verify-release-registry-state.mjs index 6ac110aa97..c4848bafab 100644 --- a/scripts/verify-release-registry-state.mjs +++ b/scripts/verify-release-registry-state.mjs @@ -3,9 +3,9 @@ import { pathToFileURL } from "node:url"; const CANARY_VERSION_RE = /-canary\.\d+$/; -const PRERELEASE_VERSION_RE = /-(?:canary|nightly)\.\d+$/; +const PRERELEASE_VERSION_RE = /-(?:canary|nightly|beta)\.\d+$/; // Channels that publish prerelease versions and must never move `latest`. -const PRERELEASE_CHANNELS = new Set(["canary", "nightly"]); +const PRERELEASE_CHANNELS = new Set(["canary", "nightly", "beta"]); const EXIT_RETRIABLE_FAILURE = 1; const EXIT_NON_RETRIABLE_FAILURE = 2; @@ -29,7 +29,7 @@ function usage() { process.stderr.write( [ "Usage:", - " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]", + " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]", "", ].join("\n"), ); @@ -77,7 +77,7 @@ function parseArgs(argv) { } if (!PRERELEASE_CHANNELS.has(options.channel) && options.channel !== "stable") { - throw createExitError("--channel must be canary, nightly, or stable", EXIT_NON_RETRIABLE_FAILURE); + throw createExitError("--channel must be canary, nightly, beta, or stable", EXIT_NON_RETRIABLE_FAILURE); } if (!options.distTag) { diff --git a/scripts/verify-release-registry-state.test.mjs b/scripts/verify-release-registry-state.test.mjs index ec5906d676..112d61c307 100644 --- a/scripts/verify-release-registry-state.test.mjs +++ b/scripts/verify-release-registry-state.test.mjs @@ -19,6 +19,7 @@ test("isCanaryVersion matches release canaries", () => { test("isPrereleaseVersion matches canary and nightly versions", () => { assert.equal(isPrereleaseVersion("2026.427.0-canary.3"), true); assert.equal(isPrereleaseVersion("2026.427.0-nightly.0"), true); + assert.equal(isPrereleaseVersion("2026.427.0-beta.2"), true); assert.equal(isPrereleaseVersion("2026.427.0"), false); });