diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ba9c25e400..42dbbc602b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,6 +6,12 @@ on: - "master" tags: - "v*" + - "nightly/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 + # way. + workflow_dispatch: permissions: contents: read @@ -40,7 +46,16 @@ jobs: id: build-version run: | set -euo pipefail - version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + case "${GITHUB_REF}" in + refs/tags/nightly/v*) + # Lane tags carry the exact published version; stamp it verbatim + # instead of describing drift from the nearest stable tag. + version="${GITHUB_REF#refs/tags/nightly/v}" + ;; + *) + version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + ;; + esac echo "version=${version}" >> "$GITHUB_OUTPUT" echo "Stamping build version: ${version:-}" @@ -131,15 +146,20 @@ jobs: echo "last=${last}" >> "$GITHUB_OUTPUT" echo "count=${count}" >> "$GITHUB_OUTPUT" + # Lane tag mapping: master pushes publish `:canary`, nightly/v* tags + # publish `:nightly`, and only stable v* tags move `:latest` and the + # versioned tags. `:sha-` is published on every build. - name: Docker meta id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + 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=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') }} type=sha labels: | io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} @@ -197,7 +217,16 @@ jobs: id: build-version run: | set -euo pipefail - version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + case "${GITHUB_REF}" in + refs/tags/nightly/v*) + # Lane tags carry the exact published version; stamp it verbatim + # instead of describing drift from the nearest stable tag. + version="${GITHUB_REF#refs/tags/nightly/v}" + ;; + *) + version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + ;; + esac echo "version=${version}" >> "$GITHUB_OUTPUT" echo "Stamping build version: ${version:-}" @@ -288,8 +317,9 @@ jobs: echo "last=${last}" >> "$GITHUB_OUTPUT" echo "count=${count}" >> "$GITHUB_OUTPUT" - # Published under the same tag set with a `-cloud` suffix - # (sha--cloud, latest-cloud, -cloud). + # Published under the same lane tag set as the self-hosted image, with a + # `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud, + # -cloud, sha--cloud). - name: Docker meta (cloud) id: meta-cloud uses: docker/metadata-action@v6 @@ -298,9 +328,11 @@ jobs: flavor: | suffix=-cloud,onlatest=true tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + 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=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') }} type=sha labels: | io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index 923bb896a3..6429d9ef1c 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -10,6 +10,7 @@ on: type: choice options: - canary + - nightly - latest host_port: description: Host port for the Docker smoke container diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d46cf1fb59..024244408b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,21 @@ on: push: branches: - master + schedule: + # Nightly cut at 09:00 UTC, after the workday's merges have settled. + - cron: "0 9 * * *" workflow_dispatch: inputs: + channel: + description: Release channel to publish + required: true + type: choice + options: + - stable + - nightly + default: stable source_ref: - description: Commit SHA, branch, or tag to publish as stable + description: (stable) Commit SHA, branch, or tag to publish as stable required: true type: string default: master @@ -15,8 +26,12 @@ on: description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable. 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. + required: false + type: string dry_run: - description: Preview the stable release without publishing + description: Preview the release without publishing required: true type: boolean default: false @@ -97,14 +112,218 @@ jobs: fi git push origin "refs/tags/${tag}" + # ----- Nightly lane ----------------------------------------------------- + # Once a night (or on a forced nightly dispatch), promote the newest master + # commit that already shipped a green canary: smoke-test that exact + # published canary first, then republish the same commit under the nightly + # identity. The candidate commit already passed release-verify during its + # canary publish, so the nightly publish skips re-verification. + + select_nightly: + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + proceed: ${{ steps.select.outputs.proceed }} + sha: ${{ steps.select.outputs.sha }} + canary_version: ${{ steps.select.outputs.canary_version }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: master + fetch-depth: 0 + + - name: Select nightly candidate + id: select + env: + EXPLICIT_CANARY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.source_version || '' }} + run: | + set -euo pipefail + + git fetch origin --tags --prune --quiet + + skip() { + echo "proceed=false" >> "$GITHUB_OUTPUT" + { + echo "## Nightly skipped" + echo "" + echo "$1" + } >> "$GITHUB_STEP_SUMMARY" + echo "Nightly skipped: $1" + } + + if [ -n "${EXPLICIT_CANARY_VERSION:-}" ]; then + tag="canary/v${EXPLICIT_CANARY_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 canary-tagged commit on master. Canary tags are pushed + # only after a successful canary publish, so tag presence is the + # green-publish signal. Walk master newest-first and stop at the + # first commit that carries a canary tag. + sha="$(grep -m1 -F \ + -f <(git for-each-ref 'refs/tags/canary/v*' --format='%(objectname)') \ + <(git rev-list origin/master -n 500) || true)" + + if [ -z "$sha" ]; then + skip "No canary/v* tag found on the last 500 commits of master." + exit 0 + fi + + tag="$(git tag --points-at "$sha" | grep '^canary/v' | sort -V | tail -1)" + fi + + canary_version="${tag#canary/v}" + + existing_nightly="$(git tag --points-at "$sha" | grep '^nightly/v' | head -1 || true)" + if [ -n "$existing_nightly" ]; then + skip "Candidate \`$sha\` (canary \`$canary_version\`) already shipped as \`$existing_nightly\`." + exit 0 + fi + + echo "proceed=true" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "canary_version=$canary_version" >> "$GITHUB_OUTPUT" + { + echo "## Nightly candidate" + echo "" + echo "- Source SHA: \`$sha\`" + echo "- Source canary: \`$canary_version\`" + } >> "$GITHUB_STEP_SUMMARY" + + # Gate the promotion on the release smoke suite, run against the exact + # published canary artifact that would become tonight's nightly. Red smoke + # means no nightly tonight. Skipped for dry-run dispatches. + smoke_nightly: + needs: select_nightly + if: needs.select_nightly.outputs.proceed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) + uses: ./.github/workflows/release-smoke.yml + with: + paperclip_version: ${{ needs.select_nightly.outputs.canary_version }} + artifact_name: nightly-release-smoke + + publish_nightly: + needs: [select_nightly, smoke_nightly] + # Publish when smoke passed, or when smoke was deliberately skipped by a + # dry-run dispatch (the publish itself is a dry-run in that case). + if: >- + !cancelled() && + needs.select_nightly.outputs.proceed == 'true' && + (needs.smoke_nightly.result == 'success' || + (needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run)) + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-canary + # The workflow-level concurrency group is per event, so a forced dispatch + # nightly could otherwise overlap the scheduled one and race it to the + # same next -nightly.N version. Serialize actual nightly publishes across + # events here; release.sh additionally refuses to double-publish a commit + # that already carries a nightly tag. + concurrency: + group: release-publish-nightly + cancel-in-progress: false + permissions: + contents: write + id-token: write + actions: write + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ needs.select_nightly.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 nightly + env: + GITHUB_ACTIONS: "true" + run: | + args=(nightly --skip-verify) + if [ "${{ github.event_name == 'workflow_dispatch' && 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 nightly tag + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + run: | + tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)" + if [ -z "$tag" ]; then + echo "Error: no nightly 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 nightly tag + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)" + { + echo "## Nightly published" + echo "" + echo "- Source SHA: \`${{ needs.select_nightly.outputs.sha }}\`" + echo "- Source canary: \`${{ needs.select_nightly.outputs.canary_version }}\`" + echo "- Published nightly: \`${tag#nightly/v}\`" + echo "- Docker build dispatched at \`${tag}\`" + } >> "$GITHUB_STEP_SUMMARY" + gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY" + + # ----- Stable lane ------------------------------------------------------ + verify_stable: - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' uses: ./.github/workflows/release-verify.yml with: ref: ${{ inputs.source_ref }} preview_stable: - if: github.event_name == 'workflow_dispatch' && inputs.dry_run + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run needs: verify_stable runs-on: ubuntu-latest timeout-minutes: 45 @@ -146,7 +365,7 @@ jobs: ./scripts/release.sh "${args[@]}" publish_stable: - if: github.event_name == 'workflow_dispatch' && !inputs.dry_run + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run needs: verify_stable runs-on: ubuntu-latest timeout-minutes: 45 @@ -154,6 +373,7 @@ jobs: permissions: contents: write id-token: write + actions: write steps: - name: Checkout repository @@ -213,6 +433,25 @@ jobs: 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. This + # is what moves Docker `:latest` and publishes the versioned stable + # image tags. + - name: Build Docker images for the stable tag + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="$(git tag --points-at HEAD | grep '^v' | head -1)" + if gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"; then + echo "Dispatched docker.yml at ${tag}." + else + # Older source commits may predate docker.yml's workflow_dispatch + # trigger; the dispatch then fails while the npm release is + # already complete and correct. + echo "::warning::Could not dispatch docker.yml at ${tag}. Run docker.yml manually at that tag to publish the stable images." + fi + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md new file mode 100644 index 0000000000..9aadd87ba7 --- /dev/null +++ b/doc/CHANNELS.md @@ -0,0 +1,79 @@ +# Release Channels + +Paperclip ships on four channels. Pick the one that matches your appetite for +freshness versus stability — switching is just a matter of which version you +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)* | +| `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. + +**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 +green is run through the full release smoke suite (real Docker container, real +onboarding flow, browser-driven). Only if that passes does it ship as the +nightly. If smoke fails, there is no nightly that night — the channel never +ships a build that failed its checks. + +**canary** is the bleeding edge: it publishes on every merge to `master`. +It is primarily the lane that continuously exercises our release automation, +but it's available to anyone who wants the newest bits and accepts the risk. + +## Installing from a channel + +npm / npx: + +```bash +npx paperclipai@latest onboard # stable +npx paperclipai@nightly onboard +npx paperclipai@canary onboard +``` + +Docker: + +```bash +docker pull ghcr.io/paperclipai/paperclip:latest # stable +docker pull ghcr.io/paperclipai/paperclip:nightly +docker pull ghcr.io/paperclipai/paperclip:canary +``` + +Every image is also published as `:sha-` for exact pinning, and +stable images additionally get `:YYYY.MDD.P` version tags. + +## Switching channels + +Channel choice is per-install: install from a different tag and you're on that +channel. Moving forward (stable → nightly) is always safe. Moving backward +(nightly → stable) can mean running an older schema than your data was created +with — treat a downgrade like a restore and keep a backup of your data +directory before switching down. + +## Reading version strings + +The version tells you which channel a build came from: + +- `2026.807.0` — stable, published 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). + +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. + +## For maintainers + +The publishing mechanics, promotion flow, and release checklist live in +[`RELEASING.md`](RELEASING.md). diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index d21975c106..157bf6526c 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -141,6 +141,12 @@ Reasoning: - every push to `master` should be able to publish a canary automatically - no human approval should be required for canaries +The scheduled nightly lane also publishes under `npm-canary`: it is the same +trust level (fully automated, no human gate), its runs execute on `master` so +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). + ## 6. Configure `npm-stable` Recommended settings for `npm-stable`: diff --git a/doc/RELEASING.md b/doc/RELEASING.md index da4143dfae..b623bd428a 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -5,9 +5,13 @@ Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-fa The release model is now commit-driven: 1. Every push to `master` publishes a canary automatically. -2. Stable releases are manually promoted from a chosen tested commit or canary tag. -3. Stable release notes live in `releases/vYYYY.MDD.P.md`. -4. Only stable releases get GitHub Releases. +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. + +The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md). ## Versioning Model @@ -15,12 +19,18 @@ 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` Examples: - first stable on March 18, 2026: `2026.318.0` - 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` + +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. Important constraints: @@ -41,16 +51,22 @@ Every stable release has four separate surfaces: A stable release is done only when all four surfaces are handled. -Canaries only cover the first two surfaces plus an internal traceability tag. +Canaries and nightlies only cover the first two surfaces plus an internal +traceability tag. ## Core Invariants - canaries publish from `master` +- 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 - tags point at the original source commit, not a generated release commit - stable notes are always `releases/vYYYY.MDD.P.md` -- canaries never create GitHub Releases -- canaries never require changelog generation +- canaries and nightlies never create GitHub Releases +- canaries and nightlies never require changelog generation +- Docker `:latest` moves only on stable releases; master builds publish + `:canary` and nightly builds publish `:nightly` ## TL;DR @@ -78,6 +94,40 @@ npx paperclipai@canary onboard npx paperclipai@canary onboard --data-dir "$(mktemp -d /tmp/paperclip-canary.XXXXXX)" ``` +### Nightly + +A scheduled job in [`.github/workflows/release.yml`](../.github/workflows/release.yml) +runs once a night at 09:00 UTC. + +It: + +- selects the newest commit on `master` that carries a `canary/v*` tag (the + tag is pushed only after a successful canary publish, so it is the + green-publish signal) +- skips with a job-summary reason when there is no new candidate or the + candidate already shipped as a nightly +- runs the release smoke suite ([`release-smoke.yml`](../.github/workflows/release-smoke.yml)) + against that exact published canary version — red smoke means no nightly + tonight +- republishes the same source commit as `YYYY.MDD.P-nightly.N` under the npm + dist-tag `nightly` (the commit was already verified by its canary run, so + verification is not repeated) +- creates and pushes the git tag `nightly/vYYYY.MDD.P-nightly.N` +- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to + publish the `:nightly` images + +To force a nightly outside the schedule (recovery, or promoting a specific +canary), dispatch `release.yml` with `channel: nightly`. Leave +`source_version` empty for automatic selection, or set it to an exact +canary version. `dry_run: true` previews the publish and skips smoke, the tag +push, and the Docker dispatch. + +Users install nightlies with: + +```bash +npx paperclipai@nightly onboard +``` + ### Stable Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs. @@ -86,6 +136,9 @@ 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) - `source_ref` - commit SHA, branch, or tag - `stable_date` @@ -113,8 +166,26 @@ The workflow: - computes the next stable patch slot for the chosen UTC date - publishes `YYYY.MDD.P` under npm dist-tag `latest` - creates git tag `vYYYY.MDD.P` +- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to + publish `:latest` and the versioned stable images - creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md` +## Docker Image Tags + +[`docker.yml`](../.github/workflows/docker.yml) publishes both the self-hosted +image and the `-cloud` variant with the same lane mapping: + +| Build ref | Tags | +| --- | --- | +| `master` push | `:canary`, `:sha-` | +| `nightly/v*` tag | `:nightly`, `:sha-` | +| `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-` | + +Lane tags are pushed by release workflows using `GITHUB_TOKEN`, and GitHub +suppresses push-triggered workflow runs for those pushes. The release jobs +therefore dispatch `docker.yml` explicitly at the new tag ref; the tag +mapping keys off `github.ref` either way. + ## Local Commands ### Preview a canary locally @@ -123,6 +194,15 @@ The workflow: ./scripts/release.sh canary --dry-run ``` +### Preview a nightly locally + +Requires HEAD to be a commit that already shipped a canary (it must carry a +`canary/v*` tag): + +```bash +./scripts/release.sh nightly --dry-run +``` + ### Preview a stable locally ```bash @@ -185,9 +265,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=latest ``` +The nightly lane runs this same suite automatically against its candidate +before publishing. + Minimum checks: - `npx paperclipai@canary onboard` installs @@ -224,6 +308,19 @@ Instead: 3. wait for the next automatic canary 4. rerun smoke testing +### If the nightly skipped or failed + +A skipped nightly is working as designed — the job summary names the reason +(no new green candidate, candidate already shipped, or red smoke). Nothing was +published, so there is nothing to clean up. + +To recover after fixing the cause, either wait for the next scheduled run or +force one: dispatch `release.yml` with `channel: nightly` (optionally pinning +`source_version` to a specific canary). + +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 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 2e82a61a0f..cbd7f460ad 100644 --- a/scripts/__tests__/release-dry-run-notes.test.mjs +++ b/scripts/__tests__/release-dry-run-notes.test.mjs @@ -38,10 +38,24 @@ get_current_stable_version() { printf '2026.709.0\\n'; } utc_date_iso() { printf '2026-07-10\\n'; } list_public_package_info() { printf 'cli\\tpaperclipai\\t0.0.0\\n'; } next_stable_version() { printf '2026.710.0\\n'; } -next_canary_version() { printf '2026.710.0-canary.0\\n'; } +next_prerelease_version() { printf '2026.710.0-%s.0\\n' "$1"; } release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; } stable_tag_name() { printf 'v%s\\n' "$1"; } -canary_tag_name() { printf 'canary/v%s\\n' "$1"; } +prerelease_tag_name() { printf '%s/v%s\\n' "$1" "$2"; } +require_channel_tag_at_head() { + if [ "\${FAKE_MISSING_CHANNEL_TAG:-}" = "$1" ]; then + echo "Error: HEAD has no $1/v* tag; this channel only publishes commits that already shipped a $1 release." >&2 + exit 1 + fi + echo "[fixture] require_channel_tag_at_head $1" +} +require_channel_tag_absent_at_head() { + if [ "\${FAKE_PRESENT_CHANNEL_TAG:-}" = "$1" ]; then + echo "Error: HEAD already shipped as $1/v2026.710.0-$1.0; delete that tag first if you really want to republish this commit on the $1 channel." >&2 + exit 1 + fi + echo "[fixture] require_channel_tag_absent_at_head $1" +} require_clean_worktree() { :; } require_npm_publish_auth() { :; } git_local_tag_exists() { return 1; } @@ -108,7 +122,7 @@ exit 0 return { binDir, callLog, fixtureDir, script: join(scriptsDir, "release.sh") }; } -function runRelease(args) { +function runRelease(args, extraEnv = {}) { const fixture = createReleaseFixture(); const result = spawnSync(fixture.script, args, { cwd: fixture.fixtureDir, @@ -117,6 +131,7 @@ function runRelease(args) { ...process.env, PATH: `${fixture.binDir}:${process.env.PATH}`, FAKE_CALL_LOG: fixture.callLog, + ...extraEnv, }, }); @@ -148,3 +163,35 @@ test("stable publish still requires release notes before publish work starts", ( assert.doesNotMatch(result.output, /==> Step 2\/7: Building workspace artifacts/); assert.doesNotMatch(result.calls, /^pnpm /m); }); + +test("nightly dry-run publishes under the nightly identity without release notes", () => { + const result = runRelease(["nightly", "--skip-verify", "--dry-run"]); + + assert.equal(result.status, 42); + assert.match(result.output, /\[fixture\] require_channel_tag_at_head canary/); + assert.match(result.output, /Nightly version: 2026\.710\.0-nightly\.0/); + assert.match(result.output, /Dist-tag: nightly/); + assert.match(result.output, /Git tag: nightly\/v2026\.710\.0-nightly\.0/); + assert.doesNotMatch(result.output, /stable release notes file is required/); + assert.match(result.calls, /^pnpm build$/m); +}); + +test("nightly refuses commits that never shipped a canary", () => { + const result = runRelease(["nightly", "--skip-verify", "--dry-run"], { + FAKE_MISSING_CHANNEL_TAG: "canary", + }); + + assert.equal(result.status, 1); + assert.match(result.output, /HEAD has no canary\/v\* tag/); + assert.doesNotMatch(result.calls, /^pnpm /m); +}); + +test("nightly refuses commits that already shipped as a nightly", () => { + const result = runRelease(["nightly", "--skip-verify", "--dry-run"], { + FAKE_PRESENT_CHANNEL_TAG: "nightly", + }); + + assert.equal(result.status, 1); + assert.match(result.output, /HEAD already shipped as nightly\/v/); + assert.doesNotMatch(result.calls, /^pnpm /m); +}); diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index 67c35919f0..7e3b1b4de9 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -197,13 +197,24 @@ process.stdout.write(`${stableSlot}.${max + 1}`); NODE } -next_canary_version() { - local stable_version="$1" - shift +require_prerelease_channel() { + case "$1" in + canary|nightly) ;; + *) release_fail "unknown prerelease channel: $1" ;; + esac +} - node - "$stable_version" "$@" <<'NODE' -const stable = process.argv[2]; -const packageNames = process.argv.slice(3); +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"); @@ -218,7 +229,7 @@ if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) { } } -const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-${channel}\\.(\\d+)$`); let max = -1; for (const packageName of packageNames) { @@ -249,10 +260,16 @@ for (const packageName of packageNames) { } } -process.stdout.write(`${stable}-canary.${max + 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" } @@ -261,8 +278,13 @@ 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() { - printf 'canary/v%s\n' "$1" + prerelease_tag_name canary "$1" } npm_package_version_exists() { @@ -369,11 +391,14 @@ publish_package_to_npm() { return 0 fi - if [ "$dist_tag" != "canary" ]; then - release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}." - rm -f "$publish_log" - return 1 - 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 @@ -468,6 +493,33 @@ require_on_master_branch() { 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" diff --git a/scripts/release-registry-versions.test.mjs b/scripts/release-registry-versions.test.mjs index 2915dae665..08566a699f 100644 --- a/scripts/release-registry-versions.test.mjs +++ b/scripts/release-registry-versions.test.mjs @@ -195,3 +195,43 @@ test("next_stable_version falls back to npm view without a versions file", () => assert.equal(result.output, "2026.707.2"); assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m); }); + +test("next_prerelease_version counts per channel so nightly numbering ignores canaries", () => { + 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.0", "2026.707.1-nightly.1"], + }), + ); + + const result = runReleaseLibHelper( + 'next_prerelease_version nightly 2026.707.1 "@paperclipai/a"', + fixture, + { RELEASE_PACKAGE_VERSIONS_FILE: versionsFile }, + ); + + assert.equal(result.status, 0); + assert.equal(result.output, "2026.707.1-nightly.2"); + assert.doesNotMatch(result.calls, /npm view/); +}); + +test("next_prerelease_version rejects unknown channels", () => { + const fixture = makeFixture(); + const result = runReleaseLibHelper( + 'next_prerelease_version weekly 2026.707.1 "@paperclipai/a"', + fixture, + ); + + assert.equal(result.status, 1); + assert.match(result.output, /unknown prerelease channel: weekly/); +}); + +test("prerelease_tag_name namespaces tags by channel", () => { + const fixture = makeFixture(); + const result = runReleaseLibHelper("prerelease_tag_name nightly 2026.707.1-nightly.2", fixture); + + assert.equal(result.status, 0); + assert.equal(result.output.trim(), "nightly/v2026.707.1-nightly.2"); +}); diff --git a/scripts/release.sh b/scripts/release.sh index 8e42505af1..74e563db7a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,11 +18,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] Examples: ./scripts/release.sh canary ./scripts/release.sh canary --date 2026-03-17 --dry-run + ./scripts/release.sh nightly --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 @@ -32,6 +33,10 @@ 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. + - Nightly releases republish a commit that already shipped a canary (HEAD + 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. - 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. @@ -85,7 +90,7 @@ set_cleanup_trap() { while [ $# -gt 0 ]; do case "$1" in - canary|stable) + canary|nightly|stable) if [ -n "$channel" ]; then release_fail "only one release channel may be provided." fi @@ -146,9 +151,17 @@ DIST_TAG="latest" if [ "$channel" = "canary" ]; then require_on_master_branch - TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")" + TARGET_PUBLISH_VERSION="$(next_prerelease_version canary "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")" DIST_TAG="canary" - tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")" + tag_name="$(prerelease_tag_name canary "$TARGET_PUBLISH_VERSION")" +elif [ "$channel" = "nightly" ]; then + # Nightly promotes an already-shipped canary commit, so it runs from a + # detached checkout of that commit rather than the master branch tip. + require_channel_tag_at_head canary + require_channel_tag_absent_at_head nightly + 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")" else tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")" fi @@ -193,11 +206,11 @@ release_info " Last stable tag: ${LAST_STABLE_TAG:-}" release_info " Current stable version: $CURRENT_STABLE_VERSION" 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" -else - release_info " Stable version: $TARGET_PUBLISH_VERSION" -fi +case "$channel" in + canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;; + nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;; + *) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;; +esac release_info " Dist-tag: $DIST_TAG" release_info " Git tag: $tag_name" if [ "$channel" = "stable" ]; then @@ -358,14 +371,17 @@ release_info "" if [ "$dry_run" = true ]; then release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}." else - if [ "$channel" = "canary" ]; then - release_info "Published canary ${TARGET_PUBLISH_VERSION}." - release_info "Install with: npx paperclipai@canary onboard" - release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}" - else - release_info "Published stable ${TARGET_PUBLISH_VERSION}." - release_info "Next steps:" - release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}" - release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" - fi + case "$channel" in + canary|nightly) + 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}" + ;; + *) + release_info "Published stable ${TARGET_PUBLISH_VERSION}." + release_info "Next steps:" + release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}" + release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" + ;; + esac fi diff --git a/scripts/verify-release-registry-state.mjs b/scripts/verify-release-registry-state.mjs index 50d4ad9464..6ac110aa97 100644 --- a/scripts/verify-release-registry-state.mjs +++ b/scripts/verify-release-registry-state.mjs @@ -3,6 +3,9 @@ import { pathToFileURL } from "node:url"; const CANARY_VERSION_RE = /-canary\.\d+$/; +const PRERELEASE_VERSION_RE = /-(?:canary|nightly)\.\d+$/; +// Channels that publish prerelease versions and must never move `latest`. +const PRERELEASE_CHANNELS = new Set(["canary", "nightly"]); const EXIT_RETRIABLE_FAILURE = 1; const EXIT_NON_RETRIABLE_FAILURE = 2; @@ -10,6 +13,10 @@ export function isCanaryVersion(version) { return CANARY_VERSION_RE.test(version); } +export function isPrereleaseVersion(version) { + return PRERELEASE_VERSION_RE.test(version); +} + function createExitError(message, exitCode = EXIT_RETRIABLE_FAILURE) { return Object.assign(new Error(message), { exitCode }); } @@ -22,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"), ); @@ -69,8 +76,8 @@ function parseArgs(argv) { } } - if (options.channel !== "canary" && options.channel !== "stable") { - throw createExitError("--channel must be canary or stable", EXIT_NON_RETRIABLE_FAILURE); + if (!PRERELEASE_CHANNELS.has(options.channel) && options.channel !== "stable") { + throw createExitError("--channel must be canary, nightly, or stable", EXIT_NON_RETRIABLE_FAILURE); } if (!options.distTag) { @@ -284,19 +291,19 @@ export function verifyPackageRegistryProblems({ } } - if (channel === "canary") { + if (PRERELEASE_CHANNELS.has(channel)) { const latestVersion = distTags.latest; - if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) { + if (latestVersion && isPrereleaseVersion(latestVersion) && !allowCanaryLatest) { problems.push( createProblem( - `${packageName}: latest dist-tag still resolves to canary ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`, + `${packageName}: latest dist-tag still resolves to prerelease ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`, { retriable: false }, ), ); } - if (latestVersion && isCanaryVersion(latestVersion)) { + if (latestVersion && isPrereleaseVersion(latestVersion)) { const latestManifest = requireManifest( packageName, latestVersion, diff --git a/scripts/verify-release-registry-state.test.mjs b/scripts/verify-release-registry-state.test.mjs index bf3b0b0cb2..ec5906d676 100644 --- a/scripts/verify-release-registry-state.test.mjs +++ b/scripts/verify-release-registry-state.test.mjs @@ -6,6 +6,7 @@ import { createManifestLookupKey, fetchRegistryJson, isCanaryVersion, + isPrereleaseVersion, verifyPackageRegistryProblems, verifyPackageRegistryState, } from "./verify-release-registry-state.mjs"; @@ -15,6 +16,12 @@ test("isCanaryVersion matches release canaries", () => { assert.equal(isCanaryVersion("2026.427.0"), false); }); +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"), false); +}); + test("collectInternalDependencyProblems flags missing internal versions", () => { const manifest = { dependencies: { @@ -192,7 +199,7 @@ test("verifyPackageRegistryState fails when canary latest is left in place by de allowCanaryLatest: false, }), [ - "@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest", + "@paperclipai/plugin-e2b: latest dist-tag still resolves to prerelease 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest", "@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", ], ); @@ -225,7 +232,37 @@ test("verifyPackageRegistryProblems marks canary latest drift as non-retriable", }); assert.equal(problems[0]?.retriable, false); - assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to canary/); + assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease/); +}); + +test("verifyPackageRegistryProblems accepts the nightly channel and flags nightly latest drift", () => { + const packageDocsByName = new Map([ + [ + "@paperclipai/plugin-e2b", + { + "dist-tags": { + latest: "2026.425.0-nightly.1", + nightly: "2026.427.0-nightly.0", + }, + versions: { + "2026.427.0-nightly.0": {}, + }, + }, + ], + ]); + + const problems = verifyPackageRegistryProblems({ + packageName: "@paperclipai/plugin-e2b", + packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"), + packageDocsByName, + channel: "nightly", + distTag: "nightly", + targetVersion: "2026.427.0-nightly.0", + allowCanaryLatest: false, + }); + + assert.equal(problems[0]?.retriable, false); + assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease 2026\.425\.0-nightly\.1/); }); test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => {