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:
parent
6a4e2e1b8c
commit
f9173782cd
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ on:
|
|||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- nightly
|
||||
- latest
|
||||
host_port:
|
||||
description: Host port for the Docker smoke container
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
109
doc/RELEASING.md
109
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-<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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue