feat(release): add smoke-gated nightly channel and lane-separated Docker tags (#11006)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem publishes the `paperclipai` npm package set
and the Docker images on two lanes: canary on every master push, and
stable on manual promotion
> - There is no middle ground between those lanes. Users must track
every merge or wait weeks for a stable. Docker `:latest` also tracks
master, so Docker users have no stable image at all
> - A calm prerelease lane needs to exist, and it must never ship a
build that failed its checks
> - This pull request adds the nightly channel: a scheduled job that
selects the newest master commit with a green canary publish, runs the
full release smoke suite against that exact published canary, and only
then republishes it as the nightly. It also separates Docker tags by
lane, so `:latest` finally means stable
> - The benefit is that users can follow prereleases at a nightly
cadence with a smoke-tested guarantee, and Docker users get real
`:canary`, `:nightly`, and stable image tags

## 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**

The project publishes only `canary` (every master push) and `latest`
(manual stable). Users who want prereleases without per-merge churn have
no option. Docker has a second problem: master builds overwrite
`:latest`, and CI-published stables never produced Docker images,
because tags pushed with `GITHUB_TOKEN` do not fire the `v*` tag trigger
in `docker.yml`. No stable-versioned image exists in ghcr today.

**Proposed solution**

Add a `nightly` channel. A scheduled job selects the newest
canary-tagged master commit, smoke-tests that exact published canary,
and republishes the same commit as `YYYY.MDD.P-nightly.N` under the
`nightly` dist-tag. Separate Docker tags by lane (`:canary` for master,
`:nightly` for nightly tags, `:latest` plus version tags for stable tags
only), and have the release jobs dispatch `docker.yml` at the new tag so
lane images actually build.

**Alternatives considered**

Moving the `nightly` dist-tag to the existing canary version without a
republish. Rejected: the version string would say `canary` while the
user is on nightly, which breaks at-a-glance lane identification in bug
reports and `--version` output.

## What Changed

- `scripts/release-lib.sh`: channel-parameterized
`next_prerelease_version` and `prerelease_tag_name` helpers (canary
helpers delegate to them), a `require_channel_tag_at_head` guard, and
the no-provenance retry for Sigstore transparency-log duplicates now
covers the `nightly` dist-tag as well as `canary`
- `scripts/release.sh`: new `nightly` channel. It requires HEAD to carry
a `canary/v*` tag, publishes the full public package set as
`YYYY.MDD.P-nightly.N` under dist-tag `nightly`, and tags the source
commit `nightly/vYYYY.MDD.P-nightly.N`
- `.github/workflows/release.yml`: scheduled nightly chain (09:00 UTC) —
select candidate, smoke it via `release-smoke.yml`, publish on green
under the existing `npm-canary` environment, push the tag, dispatch
`docker.yml`. New `channel` dispatch input (default `stable`, so
existing stable dispatches are unchanged) with `nightly_source_version`
and `dry_run` support for forced runs. The stable path now also
dispatches `docker.yml` at the new `v*` tag
- `.github/workflows/docker.yml`: lane tag mapping for both image jobs —
master pushes publish `:canary` and no longer move `:latest`;
`nightly/v*` tags publish `:nightly`; only stable `v*` tags publish
`:latest` and the versioned tags. New `workflow_dispatch` trigger for
the release-job dispatches. Build-version stamping uses the exact
nightly version on nightly tag builds
- `.github/workflows/release-smoke.yml`: `nightly` added to the dispatch
choice list
- `doc/CHANNELS.md` (new): user-facing guide to the channels
- `doc/RELEASING.md`: nightly lane documentation, Docker tag mapping
table, and a nightly failure playbook
- `doc/RELEASE-AUTOMATION-SETUP.md`: note that nightly reuses
`npm-canary` and needs no npm trusted-publisher changes
- Tests: channel-parameterized version helper coverage in
`scripts/release-registry-versions.test.mjs`, and nightly flow coverage
(publish identity, notes not required, canary-tag guard) in
`scripts/__tests__/release-dry-run-notes.test.mjs`

## Verification

- `node --test` on the release script suites: 68 pass, including 6 new
tests. The only failure, `acpx-patch-packaging.test.mjs`, needs
installed `node_modules` and fails identically on a pristine checkout of
master in the same environment
- `bash -n` on both shell scripts and YAML parse of all three workflows
- Live fail-path check: `./scripts/release.sh nightly --print-version`
from a master tip with no canary tag fails with `HEAD has no canary/v*
tag`
- Live success-path check: the same command from the
`canary/v2026.806.0-canary.7` commit prints `2026.806.0-nightly.0`
- Live selection check: the candidate-selection shell logic run against
the real repository selects the commit of `canary/v2026.806.0-canary.7`,
which matches the current npm `canary` dist-tag exactly
- After merge: dispatch `release.yml` with `channel: nightly` and
`dry_run: true` to preview, then a real forced run to validate end to
end before the first scheduled run

## Risks

- Docker `:latest` changes meaning from "latest master build" to "latest
stable release". This is deliberate and will be announced. Users who
want the old behavior pull `:canary`. Until the first stable release
after this change, `:latest` stays at its current (master-built) image
- The nightly is a rebuild of the same source commit, not the
byte-identical canary artifact that was smoked. The lockfile pins
dependencies, and the publish path's registry-visibility and
clean-prefix install gates still run on the nightly artifacts
- All npm publishing must stay inside `release.yml` because npm trusted
publishing pins that workflow file per package. The nightly jobs were
added to `release.yml` for exactly that reason; this constraint is now
documented in `RELEASING.md`
- The stable-lane Docker dispatch fails gracefully (a warning with
manual instructions) when the source ref predates `docker.yml`'s
`workflow_dispatch` trigger

## 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
This commit is contained in:
Devin Foley 2026-08-10 12:16:55 -07:00 committed by GitHub
parent 6a4e2e1b8c
commit f9173782cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 719 additions and 66 deletions

View File

@ -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:-<none>}"
@ -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-<short>` 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:-<none>}"
@ -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-<short>-cloud, latest-cloud, <version>-cloud).
# Published under the same lane tag set as the self-hosted image, with a
# `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud,
# <version>-cloud, sha-<short>-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 }}

View File

@ -10,6 +10,7 @@ on:
type: choice
options:
- canary
- nightly
- latest
host_port:
description: Host port for the Docker smoke container

View File

@ -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 }}

79
doc/CHANNELS.md Normal file
View File

@ -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-<short-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).

View File

@ -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`:

View File

@ -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-<short>` |
| `nightly/v*` tag | `:nightly`, `:sha-<short>` |
| `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-<short>` |
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.

View File

@ -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);
});

View File

@ -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"

View File

@ -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");
});

View File

@ -18,11 +18,12 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
./scripts/release.sh <canary|nightly|stable> [--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:-<none>}"
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

View File

@ -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 <canary|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--allow-canary-latest]",
" node scripts/verify-release-registry-state.mjs --channel <canary|nightly|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--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,

View File

@ -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", () => {