feat(release): draft stable notes at beta publish, read them from master at promotion (#11567)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release channel system promotes builds canary → nightly → beta →
stable, and stable releases publish a GitHub Release from
`releases/vYYYY.MDD.P.md`
> - The stable lane requires that notes file to exist inside the
promoted source commit, but the file is named for the promotion date,
which is unknown when the source commit is created
> - A promoted beta can therefore never pass the notes check: every
happy-path stable is forced through the candidate-branch fix path, with
a soak-gate justification, for a notes-only change
> - This pull request drafts the notes automatically when the beta is
published and lets the stable promotion read them from `master`
> - The benefit is a walkable stable happy path: the soak gate stays
exact, notes get a real review window during the soak, and the
justification path returns to its real purpose (cherry-picked fixes)

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The stable promotion path in the release channel system (`release.yml`,
`scripts/release.sh`).

**Current behavior**

`release.sh stable` requires `releases/vYYYY.MDD.P.md` in the
checked-out source tree, and `publish_stable` checks out the exact
promoted SHA. The soak gate requires a `beta/v*` tag to point at that
same SHA. No commit can satisfy both for a promoted beta, so a stable
promotion must cut a candidate branch with a notes-only commit and
bypass the soak gate with a written justification. Release notes are
also written at promotion time, under time pressure, with no review
window.

**Proposed behavior**

When a beta publishes, a `draft_stable_notes` job generates a grouped
notes skeleton at `releases/beta/v<beta-version>.md` and pushes it to a
machine-owned branch; a human opens the PR and edits it during the 3-day
soak. The stable preflight resolves notes before the `npm-stable`
approval gate: source-tree notes first (the candidate fix path,
unchanged), then the merged beta-keyed file on `master`; it fails early
with the missing path named when neither exists. After the stable ships,
a canonicalization job pushes a branch that moves the file to
`releases/vYYYY.MDD.P.md`.

Related (not duplicates): #11006 and #11008 introduced the nightly and
beta lanes this builds on; older changelog PRs (for example #10669)
authored notes manually at promotion time, which is the flow this
replaces.

**Reason and benefit**

The happy path becomes: promote the exact soaked SHA, no justification,
notes reviewed during the soak instead of written at the gate. The
`releases/vYYYY.MDD.P.md` invariant still holds durably via the
canonicalization PR.

## What Changed

- `scripts/release.sh`: new `--notes-file PATH` (stable only) overrides
where the pre-publish notes check looks, so notes can live outside the
source checkout without dirtying the worktree.
- `scripts/create-github-release.sh`: same `--notes-file` override for
the GitHub Release body.
- `scripts/draft-stable-notes.sh` (new): deterministic skeleton
generator — commit subjects from the newest stable tag (falling back to
the previous beta, then full history) to the beta's source commit,
grouped into Features / Fixes / Other.
- `.github/workflows/release.yml`:
- `draft_stable_notes` job after `publish_beta`: runs the generator and
force-pushes `release-notes/v<beta-version>`; the job summary links the
compare page. It recreates the beta tag locally if the tag push was
rejected (the known workflows-permission case), so drafting is not
blocked on manual tag recovery.
- `preflight_stable`: computes the target stable version (`release.sh
stable --print-version`) and resolves the notes source (`source_tree` →
`master_beta` → fail early / warn on dry run); new outputs.
- `publish_stable`: materializes `master`-side notes into `RUNNER_TEMP`
and passes `--notes-file` to both scripts; outputs the published stable
version.
- `canonicalize_stable_notes` job: pushes the `git mv` branch after a
stable that used `master`-side notes.
- `doc/RELEASING.md`, `doc/RELEASE-CHECKLIST.md`: document the
drafted-notes flow, the preflight resolution order, and the
canonicalization step; the LLM changelog flow now targets the draft
branch during the soak.
- `.agents/skills/release-changelog/SKILL.md`,
`.agents/skills/release-changelog-discord-message/SKILL.md`: the
notes-authoring skills now describe this flow — range ends at the beta
source commit (not `HEAD`), the file is beta-keyed on the
`release-notes/v<beta-version>` branch (seeded with
`scripts/draft-stable-notes.sh` for betas that predate the automation),
and the canonicalization link caveat is called out for announcements.

## Verification

- `node --test scripts/draft-stable-notes.test.mjs` — 6 tests, temp
git-repo fixtures: grouping, stable-tag range, previous-beta and
full-history fallbacks, default output path, malformed version, missing
tag.
- `node --test scripts/release-lib.test.mjs` — unchanged suite still
green.
- `bash -n` on both changed shell scripts; `release.yml` re-parsed as
YAML.
- `./scripts/release.sh stable --print-version` unchanged (prints the
next stable version); `--notes-file` on a non-stable channel fails with
a clear error.
- Not exercised end-to-end: the new workflow jobs need a real beta
publish to run. The first beta after merge is the live test; the draft
job is additive and cannot affect the publish result (it runs after
`publish_beta` completes).

## Risks

- Low risk to publishing itself: `--notes-file` defaults preserve
today's behavior everywhere; the draft and canonicalization jobs are
additive and run after the publishes succeed.
- The preflight now fails a real stable run when no notes are found.
That is the intended fail-early behavior (it previously failed later,
inside `publish_stable`, after the `npm-stable` approval).
- `draft_stable_notes` force-pushes only the machine-owned
`release-notes/v<beta-version>` branch; a beta re-cut regenerates it
cleanly.
- The stable version computed at preflight could differ from the
published one if a run crosses UTC midnight between the two jobs; the
materialized notes are passed by path, so the publish still succeeds,
and the canonicalization job uses the actually-published version.

## Model Used

Claude Fable 5 (Claude Code)



## Pre-submission 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
This commit is contained in:
Devin Foley 2026-08-17 20:47:23 -07:00 committed by GitHub
parent 6691c57e54
commit 664052f8ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 636 additions and 28 deletions

View File

@ -11,7 +11,11 @@ description: >
Write the Discord release announcement for the **stable** Paperclip release.
This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill
generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into
writes the changelog — during the beta soak it lives at
`releases/beta/v{beta-version}.md` on the `release-notes/v{beta-version}`
branch, and after the stable ships a canonicalization PR renames it to
`releases/vYYYY.MDD.P.md` (see that skill's Channel Process section). This
skill turns that file into
a single copy-pasteable Discord block, in dotta's voice, and posts it as the
`discord_announcement` document on the release issue.
@ -27,8 +31,9 @@ current Paperclip work — not invented.
## When to use
- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the
release worktree/PR.
- After `release-changelog` has produced the changelog (beta-keyed on the
`release-notes/v{beta-version}` branch during the soak, or the
canonicalized `releases/vYYYY.MDD.P.md` after the stable ships).
- When the release issue (the one assigned by the release routine) asks for a
Discord announcement, or has a `discord_announcement` document that needs to
be refreshed for a new date/version.
@ -123,6 +128,12 @@ Notes on the template:
- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are
part of the brand — keep them.
- Name the install channels somewhere in the post: `npx paperclipai@latest`
for the stable, `@beta` / `@nightly` / `@canary` for earlier access, and
Docker `:latest` moving **only** on stable releases.
- The FULL RELEASE NOTES link points at `releases/v{VERSION}.md` on
`master` — that file exists only after the post-stable canonicalization
PR merges. Merge it before the announcement is posted.
- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style
and stay consistent within a single post.
- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings
@ -175,15 +186,19 @@ Mimic this register; do not invent a "professional" tone.
## Workflow
1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`.
Use the version and contributor list from that file — never re-derive them.
1. Read the matching changelog produced by `release-changelog` — the
beta-keyed file during the soak, `releases/vYYYY.MDD.P.md` once
canonicalized. Use the version and contributor list from that file —
never re-derive them.
2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`.
If it does not exist and Cases are enabled, create it using the schema in
`.agents/skills/release-changelog/SKILL.md` before creating child cases.
3. Read the **release issue thread** (the one assigned to you that ran the
release routine) — comments + linked issues + recent issues in the company
are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes,
not invented ones.
are the source for `WHATS NEXT` and `What's on my mind`. Commits already
on `origin/master` **after** the beta source commit are prime "what's
next" material: they are literally the next release's content. Pull real
themes, not invented ones.
4. Re-read the three verbatim examples below — they're the canonical voice.
5. Draft the announcement using the template above.
6. PUT it as the `discord_announcement` document on the release issue (see

View File

@ -32,14 +32,57 @@ Important rules:
- do not derive versions from semver bump types
- do not create canary changelog files
## Channel Process — Source Commit and File Location
Stables promote a **soaked beta**, so the changelog describes the beta's
source commit, not the tip of `master`:
- The release **source** is the commit the newest `beta/v<beta-version>`
tag points at (`{beta-src}` below). Resolve it with:
```bash
git fetch origin --tags
npm view paperclipai dist-tags # the beta dist-tag names the version
git rev-parse 'beta/v{beta-version}^{commit}'
```
- Commits on `master` after `{beta-src}` ship in the **next** release.
Never include them; they are input for a "what's next" section, not the
changelog.
- During the soak, the file lives at `releases/beta/v{beta-version}.md`
on the branch `release-notes/v{beta-version}` (PR to `master`). The
release workflow pushes that branch with a generated skeleton when the
beta publishes; work on it and rewrite the skeleton in place. If the
branch does not exist (a beta cut before the automation), create it
from `origin/master` and seed the skeleton:
```bash
./scripts/draft-stable-notes.sh {beta-version}
```
- The PR must merge to `master` **before** the stable is dispatched: the
stable preflight reads the file from `master` and fails without it.
- Never create `releases/vYYYY.MDD.P.md` yourself on this path — after
the stable ships, the workflow opens a canonicalization PR that renames
the beta-keyed file to it.
- **Fix path exception** (patch releases from a `candidate/release-*`
branch): there the notes *do* go directly on the candidate branch as
`releases/vYYYY.MDD.P.md`, committed alongside the cherry-picked fixes.
## Step 0 — Idempotency Check
Before generating anything, check whether the file already exists:
Before generating anything, check whether the changelog already exists:
```bash
ls releases/vYYYY.MDD.P.md 2>/dev/null
ls releases/beta/v{beta-version}.md 2>/dev/null # soak-window home
ls releases/vYYYY.MDD.P.md 2>/dev/null # canonicalized / fix path
git ls-remote origin 'refs/heads/release-notes/v{beta-version}'
```
A `release-notes/v{beta-version}` branch holding only the generated
skeleton is the normal starting state, not a conflict — rewrite it in
place.
If it exists:
1. read it first
@ -49,13 +92,17 @@ If it exists:
## Step 1 — Determine the Stable Range
Find the last stable tag:
Find the last stable tag and the beta source commit:
```bash
git tag --list 'v*' --sort=-version:refname | head -1
git log v{last}..HEAD --oneline --no-merges
beta_src="$(git rev-parse 'beta/v{beta-version}^{commit}')"
git log v{last}..${beta_src} --oneline --no-merges
```
The changelog range is always `v{last}..{beta-src}` — never `..HEAD` and
never `..origin/master`.
The stable version comes from one of:
- an explicit maintainer request
@ -76,8 +123,8 @@ Collect release data from:
Useful commands:
```bash
git log v{last}..HEAD --oneline --no-merges
git log v{last}..HEAD --format="%H %s" --no-merges
git log v{last}..{beta-src} --oneline --no-merges
git log v{last}..{beta-src} --format="%H %s" --no-merges
ls .changeset/*.md | grep -v README.md
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
```
@ -94,10 +141,10 @@ Look for:
Key commands:
```bash
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
git diff v{last}..HEAD -- packages/db/src/schema/
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
git diff --name-only v{last}..{beta-src} -- packages/db/src/migrations/
git diff v{last}..{beta-src} -- packages/db/src/schema/
git diff v{last}..{beta-src} -- server/src/routes/ server/src/api/
git log v{last}..{beta-src} --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
```
If breaking changes are detected, flag them prominently — they must appear in the
@ -143,6 +190,14 @@ Rules:
## Step 5 — Write the File
The **file path** is the beta-keyed one from the Channel Process section
(`releases/beta/v{beta-version}.md`), but the **content** is titled with
the planned stable version. Resolve it with
`./scripts/release.sh stable --date {planned-promotion-date} --print-version`
(promotion is normally the beta publish date plus the 3-day soak). If the
promotion date slips, the version re-resolves at dispatch — the beta-keyed
filename makes that harmless; refresh the title when it happens.
The opening line of the changelog must be an H1 of the format `# Paperclip {version}`
(no braces), e.g. `# Paperclip v2026.618.0`. Always include the `Paperclip ` prefix and
the `v` on the version.
@ -177,7 +232,7 @@ The `Contributors` section should always be included. List every person who auth
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
1. Extract usernames from merge commit messages: `git log v{last}..{beta-src} --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.

View File

@ -621,6 +621,67 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
# Draft the eventual stable's release notes the moment the beta exists:
# the promoted bits are frozen now, and the 3-day soak is the natural
# review window. The draft lands on a machine-owned branch; a human opens
# and merges the PR, because a PR created with GITHUB_TOKEN would not
# trigger the pr.yml checks a merge requires.
draft_stable_notes:
needs: [select_beta, publish_beta]
if: ${{ !cancelled() && !inputs.dry_run && needs.publish_beta.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
- 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: Draft stable notes from the published beta
env:
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
SOURCE_SHA: ${{ needs.select_beta.outputs.sha }}
run: |
set -euo pipefail
git fetch origin --tags --quiet
# A rejected beta tag push (the workflows-permission case) leaves
# the tag absent from origin while npm already has the beta;
# recreate it locally so drafting does not block on the manual
# tag recovery.
if ! git rev-parse --verify "refs/tags/beta/v${BETA_VERSION}" >/dev/null 2>&1; then
git tag "beta/v${BETA_VERSION}" "${SOURCE_SHA}"
fi
./scripts/draft-stable-notes.sh "${BETA_VERSION}"
- name: Push the draft branch
env:
BETA_VERSION: ${{ needs.publish_beta.outputs.beta_version }}
run: |
set -euo pipefail
branch="release-notes/v${BETA_VERSION}"
git checkout -B "$branch"
git add "releases/beta/v${BETA_VERSION}.md"
git commit -m "docs(release): draft stable notes for beta ${BETA_VERSION}"
# Machine-owned branch: force push so a re-run regenerates cleanly.
git push -f origin "$branch"
{
echo "## Stable notes draft pushed"
echo ""
echo "- Branch: \`${branch}\`"
echo "- Open the PR (a human opens it so CI runs):"
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
echo "- Edit it during the soak. The stable promotion reads"
echo " \`releases/beta/v${BETA_VERSION}.md\` from master."
} >> "$GITHUB_STEP_SUMMARY"
# Post-publish verification: run the release smoke suite against the exact
# beta version that was just published.
#
@ -656,6 +717,11 @@ jobs:
contents: read
outputs:
sha: ${{ steps.soak.outputs.sha }}
beta_version: ${{ steps.soak.outputs.beta_version }}
stable_version: ${{ steps.notes.outputs.stable_version }}
notes_mode: ${{ steps.notes.outputs.notes_mode }}
notes_path: ${{ steps.notes.outputs.notes_path }}
notes_ref: ${{ steps.notes.outputs.notes_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@ -724,6 +790,7 @@ jobs:
fi
beta_version="${beta_tag#beta/v}"
echo "beta_version=${beta_version}" >> "$GITHUB_OUTPUT"
publish_time="$(npm view "paperclipai@${beta_version}" time --json 2>/dev/null \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const t=JSON.parse(d);process.stdout.write(typeof t === "string" ? t : (t[process.argv[1]] ?? ""))})' "$beta_version" || true)"
if [ -z "$publish_time" ]; then
@ -747,6 +814,58 @@ jobs:
echo "- Soak time: ${age_days} days"
} >> "$GITHUB_STEP_SUMMARY"
# The stable notes need not exist inside the promoted source commit:
# a promoted beta's notes are drafted on master (releases/beta/v*.md)
# at beta-publish time and edited during the soak. Resolve which copy
# publish_stable should read, and fail early — before the npm-stable
# approval gate — when none exists. Notes inside the source tree
# (the candidate fix path) take precedence.
- name: Resolve stable release notes
id: notes
env:
SHA: ${{ steps.soak.outputs.sha }}
BETA_VERSION: ${{ steps.soak.outputs.beta_version }}
STABLE_DATE: ${{ inputs.stable_date }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# Pin the master revision the notes were resolved at, so the
# publish and canonicalization steps read the same content even
# when master advances during the approval delay.
echo "notes_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
args=(stable --print-version)
if [ -n "${STABLE_DATE}" ]; then
args+=(--date "${STABLE_DATE}")
fi
version="$(./scripts/release.sh "${args[@]}")"
echo "stable_version=${version}" >> "$GITHUB_OUTPUT"
if git cat-file -e "${SHA}:releases/v${version}.md" 2>/dev/null; then
echo "notes_mode=source_tree" >> "$GITHUB_OUTPUT"
echo "notes_path=releases/v${version}.md" >> "$GITHUB_OUTPUT"
echo "- Stable notes: \`releases/v${version}.md\` at the source commit" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ -n "${BETA_VERSION}" ] && [ -f "releases/beta/v${BETA_VERSION}.md" ]; then
echo "notes_mode=master_beta" >> "$GITHUB_OUTPUT"
echo "notes_path=releases/beta/v${BETA_VERSION}.md" >> "$GITHUB_OUTPUT"
echo "- Stable notes: \`releases/beta/v${BETA_VERSION}.md\` on master" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
msg="no release notes found for stable ${version}: neither releases/v${version}.md at the source commit nor releases/beta/v${BETA_VERSION:-<beta>}.md on master. Merge the notes PR from the beta's draft branch (release-notes/v<beta-version>), or add the file to the source ref."
if [ "${DRY_RUN}" = "true" ]; then
echo "::warning::${msg}"
echo "- Stable notes: MISSING (a real release would be blocked)" >> "$GITHUB_STEP_SUMMARY"
echo "notes_mode=missing" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::${msg}"
exit 1
verify_stable:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
needs: preflight_stable
@ -806,6 +925,8 @@ jobs:
contents: write
id-token: write
actions: write
outputs:
stable_version: ${{ steps.tag.outputs.version }}
steps:
- name: Checkout repository
@ -836,6 +957,22 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# A promoted beta's notes live on master (drafted at beta publish,
# edited during the soak), not inside the promoted source commit — a
# commit cannot carry a file named for a promotion date that was
# unknown when it was created. Materialize them outside the worktree
# so release.sh still runs against a clean source checkout.
- name: Materialize stable release notes from master
if: needs.preflight_stable.outputs.notes_mode == 'master_beta'
env:
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
run: |
set -euo pipefail
git fetch origin master --quiet
git show "${NOTES_REF}:${NOTES_PATH}" > "$RUNNER_TEMP/stable-notes.md"
echo "STABLE_NOTES_FILE=$RUNNER_TEMP/stable-notes.md" >> "$GITHUB_ENV"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
@ -844,6 +981,9 @@ jobs:
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
args+=(--notes-file "$STABLE_NOTES_FILE")
fi
./scripts/release.sh "${args[@]}"
- name: Dump npm debug logs
@ -857,12 +997,14 @@ jobs:
done
- name: Push stable tag
id: tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
if ! git push origin "refs/tags/${tag}"; then
sha="$(git rev-parse HEAD)"
{
@ -913,4 +1055,59 @@ jobs:
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
args=("$version")
if [ -n "${STABLE_NOTES_FILE:-}" ]; then
args+=(--notes-file "$STABLE_NOTES_FILE")
fi
./scripts/create-github-release.sh "${args[@]}"
# After a stable ships from master-side beta notes, move the file to its
# canonical home (releases/vYYYY.MDD.P.md) so the stable-notes invariant
# holds durably. Machine-owned branch + human-opened PR, as with the
# draft job above.
canonicalize_stable_notes:
needs: [preflight_stable, publish_stable]
if: ${{ needs.preflight_stable.outputs.notes_mode == 'master_beta' && needs.publish_stable.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: master
fetch-depth: 0
- 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: Push the canonicalization branch
env:
STABLE_VERSION: ${{ needs.publish_stable.outputs.stable_version }}
NOTES_PATH: ${{ needs.preflight_stable.outputs.notes_path }}
NOTES_REF: ${{ needs.preflight_stable.outputs.notes_ref }}
run: |
set -euo pipefail
branch="release-notes/v${STABLE_VERSION}-canonicalize"
git checkout -B "$branch"
# Canonicalize exactly what shipped: take the notes at the pinned
# revision the release read them from, so a master edit made
# during the run surfaces as a reviewable diff in this PR instead
# of silently diverging from the published GitHub Release.
git show "${NOTES_REF}:${NOTES_PATH}" > "releases/v${STABLE_VERSION}.md"
git add "releases/v${STABLE_VERSION}.md"
if git ls-files --error-unmatch "${NOTES_PATH}" >/dev/null 2>&1; then
git rm -q "${NOTES_PATH}"
fi
git commit -m "docs(release): canonicalize stable notes for v${STABLE_VERSION}"
git push -f origin "$branch"
{
echo "## Stable notes canonicalization pushed"
echo ""
echo "- Branch: \`${branch}\`"
echo "- Open and merge the PR (a human opens it so CI runs):"
echo " https://github.com/${GITHUB_REPOSITORY}/compare/master...${branch}?expand=1"
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -36,6 +36,11 @@ Happy path:
- [ ] `npm view paperclipai@beta version` shows the new `-beta.N`
- [ ] `beta/v*` tag pushed; `:beta` and `:beta-cloud` images built
- [ ] post-publish smoke (`smoke_beta`) is green
- [ ] `draft_stable_notes` pushed `release-notes/v<beta-version>`; open the
notes PR from the job-summary link
- [ ] during the soak: edit the notes PR into release voice and merge it
(the stable promotion reads `releases/beta/v<beta-version>.md` from
`master`)
Fix path (cherry-picked candidate):
@ -50,7 +55,8 @@ Fix path (cherry-picked candidate):
- [ ] pick the beta to promote; its source commit is `source_ref`
- [ ] the beta has soaked ≥ 3 days with no open beta-blocker issues
- [ ] author `releases/vYYYY.MDD.P.md` on that source ref
- [ ] the beta's notes PR (`releases/beta/v<beta-version>.md`) is merged on
`master` — preflight fails, before the approval gate, without it
- [ ] dispatch `release.yml` with `channel: stable` (a dry run first shows
the resolved version and soak state without publishing)
- [ ] approve the `npm-stable` environment gate
@ -59,10 +65,15 @@ Fix path (cherry-picked candidate):
versioned Docker tags built
- [ ] if the soak gate was bypassed, `skip_soak_justification` carries a
real written reason (it lands in the job summary)
- [ ] open and merge the canonicalization PR
(`release-notes/v<version>-canonicalize`) so the notes land at
`releases/vYYYY.MDD.P.md`
Fix path: `candidate/release-<target>` from the beta's source commit; the
soak gate will demand a justification because the exact bits were not
soaked — write one that stands on its own.
soaked — write one that stands on its own. A candidate branch carries its
own `releases/vYYYY.MDD.P.md` (preflight prefers source-tree notes), so
author the notes as a commit on the candidate.
## After any incomplete run

View File

@ -10,7 +10,10 @@ The release model is now commit-driven:
3. Betas are manual, human-approved promotions of a chosen nightly.
4. Stable releases promote a beta that has soaked for at least 3 days
(bypass requires a written justification).
5. Stable release notes live in `releases/vYYYY.MDD.P.md`.
5. Stable release notes live in `releases/vYYYY.MDD.P.md`. They are
drafted automatically when a beta is published (as
`releases/beta/v<beta-version>.md` on `master`), edited during the
soak, and moved to the versioned name after the stable ships.
6. Only stable releases get GitHub Releases.
The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md).
@ -71,7 +74,12 @@ internal traceability tag.
shipped as a beta at least 3 days earlier unless a written justification
is provided
- tags point at the original source commit, not a generated release commit
- stable notes are always `releases/vYYYY.MDD.P.md`
- stable notes are always `releases/vYYYY.MDD.P.md` in the end state: a
promoted beta's notes are drafted and edited at
`releases/beta/v<beta-version>.md` on `master` during the soak (the
promoted commit cannot carry a file named for a promotion date that was
unknown when it was created), and a post-stable canonicalization PR
moves them to the versioned name
- canaries, nightlies, and betas never create GitHub Releases
- canaries, nightlies, and betas never require changelog generation
- Docker `:latest` moves only on stable releases; master builds publish
@ -157,6 +165,13 @@ Betas are manual promotions. Dispatch
dispatched at that tag to publish the `:beta` images
- after publishing, the release smoke suite runs against the exact published
beta version as verification
- a `draft_stable_notes` job also generates the eventual stable's notes
skeleton — `releases/beta/v<beta-version>.md`, grouped from
`git log <last-stable-tag>..<source-commit>` — and force-pushes it to the
machine-owned `release-notes/v<beta-version>` branch. Open the PR from
the job-summary link (a human opens it so CI runs) and edit the notes
during the soak; the stable promotion reads the merged file from
`master`
- `dry_run: true` previews the publish and skips the tag push, Docker
dispatch, and post-publish smoke
@ -228,7 +243,11 @@ Before running stable:
1. pick the beta you are promoting (its source commit is the `source_ref`)
2. confirm the beta has soaked for 3 days with no open blockers
3. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
4. create or update `releases/vYYYY.MDD.P.md` on that source ref
4. make sure the notes PR from the beta's draft branch
(`release-notes/v<beta-version>`, adding
`releases/beta/v<beta-version>.md`) is merged on `master` — or, for
candidate builds, that the candidate branch itself carries
`releases/vYYYY.MDD.P.md`
5. run the stable workflow from that source ref
Example:
@ -241,11 +260,19 @@ The workflow:
- re-verifies the exact source ref
- computes the next stable patch slot for the chosen UTC date
- resolves the release notes in preflight: `releases/vYYYY.MDD.P.md` at
the source commit (the candidate fix path) takes precedence, otherwise
`releases/beta/v<beta-version>.md` on `master` (a promoted beta). When
neither exists the run fails before the `npm-stable` approval gate with
the missing path named
- 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`
- creates or updates the GitHub Release from the resolved notes file
- for master-side beta notes, pushes a `release-notes/v<version>-canonicalize`
branch that `git mv`s them to `releases/vYYYY.MDD.P.md` — open and merge
its PR to restore the canonical layout
## Docker Image Tags
@ -314,6 +341,12 @@ Stable changelog files live at:
Canaries do not get changelog files.
The `draft_stable_notes` job seeds a deterministic skeleton (grouped
commit subjects) on the `release-notes/v<beta-version>` branch at beta
publish; the flows below turn that skeleton into narrative release notes
during the soak. Run them against the draft branch's
`releases/beta/v<beta-version>.md` and push to the notes PR.
Recommended local generation flow:
```bash

View File

@ -7,15 +7,17 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
dry_run=false
version=""
notes_file_override=""
usage() {
cat <<'EOF'
Usage:
./scripts/create-github-release.sh <version> [--dry-run]
./scripts/create-github-release.sh <version> [--dry-run] [--notes-file PATH]
Examples:
./scripts/create-github-release.sh 2026.318.0
./scripts/create-github-release.sh 2026.318.0 --dry-run
./scripts/create-github-release.sh 2026.318.0 --notes-file /tmp/stable-notes.md
Notes:
- Run this after pushing the stable tag.
@ -28,6 +30,14 @@ EOF
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--notes-file)
shift
if [ $# -eq 0 ]; then
echo "Error: --notes-file requires a path." >&2
exit 1
fi
notes_file_override="$1"
;;
-h|--help)
usage
exit 0
@ -55,6 +65,9 @@ fi
tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md"
if [ -n "$notes_file_override" ]; then
notes_file="$notes_file_override"
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -z "${PUBLISH_REMOTE:-}" ] && git_remote_exists origin; then
PUBLISH_REMOTE=origin
fi

129
scripts/draft-stable-notes.sh Executable file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
beta_version=""
out_file=""
repo_dir="$REPO_ROOT"
usage() {
cat <<'EOF'
Usage:
./scripts/draft-stable-notes.sh <beta-version> [--out PATH] [--repo-dir PATH]
Examples:
./scripts/draft-stable-notes.sh 2026.817.1-beta.0
./scripts/draft-stable-notes.sh 2026.817.1-beta.0 --out /tmp/draft.md
Notes:
- Generates a stable release-notes draft for the given published beta:
the commit range from the newest stable tag (v*) to the beta's source
commit, grouped by conventional-commit type.
- Written to releases/beta/v<beta-version>.md by default. The draft is
meant to be committed to master via PR and edited during the beta
soak; the stable promotion reads it from master.
- With no stable tag yet, the range falls back to the previous beta
tag, and failing that to the full history of the source commit.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--out)
shift
[ $# -gt 0 ] || release_fail "--out requires a path."
out_file="$1"
;;
--repo-dir)
shift
[ $# -gt 0 ] || release_fail "--repo-dir requires a path."
repo_dir="$1"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$beta_version" ]; then
release_fail "only one beta version may be provided."
fi
beta_version="$1"
;;
esac
shift
done
if [ -z "$beta_version" ]; then
usage
exit 1
fi
if [[ ! "$beta_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
release_fail "beta version must look like 2026.318.1-beta.0, got: $beta_version"
fi
beta_tag="beta/v${beta_version}"
if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2>&1; then
release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta."
fi
source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")"
if [ -z "$out_file" ]; then
out_file="${repo_dir}/releases/beta/v${beta_version}.md"
fi
# Range start: the newest stable tag reachable from the source commit —
# not the newest by version, which can sit on a divergent lineage (a
# stable cut from a candidate branch, or a source that predates it) and
# would produce an empty or wrong range. Before the first reachable
# stable, fall back to the nearest beta tag strictly before the source;
# with no marker at all, cover the source commit's full history.
range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)"
range_label="$range_start"
if [ -z "$range_start" ]; then
range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)"
range_label="$range_start"
fi
if [ -n "$range_start" ]; then
range="${range_start}..${source_sha}"
else
range="$source_sha"
range_label="the beginning of history"
release_info "No stable or prior beta tag found; drafting from full history."
fi
subjects="$(git -C "$repo_dir" log --no-merges --format='%s' "$range")"
section() {
local title="$1" pattern="$2" invert="${3:-false}" body
if [ "$invert" = true ]; then
body="$(printf '%s\n' "$subjects" | grep -Ev "$pattern" || true)"
else
body="$(printf '%s\n' "$subjects" | grep -E "$pattern" || true)"
fi
[ -n "$body" ] || return 0
printf '## %s\n\n' "$title"
printf '%s\n' "$body" | sed 's/^/- /'
printf '\n'
}
conventional='^(feat|fix)(\([^)]*\))?!?: '
mkdir -p "$(dirname "$out_file")"
{
printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version"
printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}"
printf '> Edit freely during the soak: rewrite for release-notes voice,\n'
printf '> fold noise, and call out anything a self-hoster must act on.\n'
printf '> The stable promotion reads this file from master and publishes\n'
printf '> it as the GitHub Release body under the stable version.\n\n'
section 'Features' '^feat(\([^)]*\))?!?: '
section 'Fixes' '^fix(\([^)]*\))?!?: '
section 'Other changes' "$conventional" true
} > "$out_file"
release_info "Draft stable notes written to $out_file"
release_info " Source beta: $beta_version ($source_sha)"
release_info " Range start: $range_label"

View File

@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const script = join(repoRoot, "scripts", "draft-stable-notes.sh");
function git(dir, ...args) {
return execFileSync("git", ["-C", dir, ...args], {
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@example.invalid",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@example.invalid",
},
});
}
function commit(dir, subject) {
git(dir, "commit", "--allow-empty", "-m", subject);
}
function makeFixtureRepo() {
const dir = mkdtempSync(join(tmpdir(), "draft-stable-notes-"));
git(dir, "init", "-q", "-b", "master");
return dir;
}
function runDraft(dir, betaVersion, extraArgs = []) {
const out = join(dir, "draft.md");
const stdout = execFileSync(
"bash",
[script, betaVersion, "--repo-dir", dir, "--out", out, ...extraArgs],
{ encoding: "utf8" }
);
return { stdout, body: readFileSync(out, "utf8") };
}
test("drafts grouped notes from the newest stable tag to the beta source", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: ancient work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: add widgets (#2)");
commit(dir, "fix(ui): unbreak widget list (#3)");
commit(dir, "chore: bump deps (#4)");
commit(dir, "feat(api)!: breaking widget API (#5)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /^# Paperclip stable draft — from beta 2026\.101\.0-beta\.0/);
assert.match(body, /## Features\n\n- feat\(api\)!: breaking widget API \(#5\)\n- feat: add widgets \(#2\)/);
assert.match(body, /## Fixes\n\n- fix\(ui\): unbreak widget list \(#3\)/);
assert.match(body, /## Other changes\n\n- chore: bump deps \(#4\)/);
assert.doesNotMatch(body, /ancient work/);
});
test("uses the nearest ancestor stable tag, not the newest by version", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: old work (#1)");
git(dir, "tag", "v2026.100.0");
commit(dir, "feat: mid work (#2)");
git(dir, "tag", "beta/v2026.150.0-beta.0");
commit(dir, "feat: new work (#3)");
git(dir, "tag", "v2026.200.0");
// Promoting the older source must draft against its own lineage's last
// stable (v2026.100.0), not the newer v2026.200.0 that already contains
// it — that range would be empty.
const { body } = runDraft(dir, "2026.150.0-beta.0");
assert.match(body, /- feat: mid work \(#2\)/);
assert.doesNotMatch(body, /old work/);
assert.doesNotMatch(body, /new work/);
});
test("falls back to the previous beta tag when no stable tag exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: first-train work (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
commit(dir, "fix: second-train fix (#2)");
git(dir, "tag", "beta/v2026.101.0-beta.0");
const { body } = runDraft(dir, "2026.101.0-beta.0");
assert.match(body, /- fix: second-train fix \(#2\)/);
assert.doesNotMatch(body, /first-train work/);
});
test("covers full history when no earlier marker exists", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: the very first commit (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
const { body } = runDraft(dir, "2026.100.0-beta.0");
assert.match(body, /- feat: the very first commit \(#1\)/);
});
test("writes to releases/beta/v<version>.md inside the repo by default", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: default path (#1)");
git(dir, "tag", "beta/v2026.100.0-beta.0");
execFileSync("bash", [script, "2026.100.0-beta.0", "--repo-dir", dir], {
encoding: "utf8",
});
const body = readFileSync(
join(dir, "releases", "beta", "v2026.100.0-beta.0.md"),
"utf8"
);
assert.match(body, /- feat: default path \(#1\)/);
});
test("rejects a malformed beta version", () => {
const dir = makeFixtureRepo();
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});
test("rejects a beta version whose tag does not exist", () => {
const dir = makeFixtureRepo();
commit(dir, "feat: unrelated (#1)");
assert.throws(() =>
execFileSync("bash", [script, "2026.100.0-beta.9", "--repo-dir", dir], {
encoding: "utf8",
stdio: "pipe",
})
);
});

View File

@ -12,6 +12,7 @@ dry_run=false
skip_verify=false
print_version_only=false
from_candidate=false
notes_file_override=
tag_name=""
cleanup_on_exit=false
@ -19,7 +20,7 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <canary|nightly|beta|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
./scripts/release.sh <canary|nightly|beta|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--notes-file PATH]
Examples:
./scripts/release.sh canary
@ -48,7 +49,10 @@ Notes:
validating the candidate branch before using it.
- 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.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md,
or be supplied explicitly with --notes-file (stable only). The override
exists so promotions can read notes maintained on master instead of
requiring them inside the promoted source commit's tree.
- The script rewrites versions temporarily and restores the working tree on
exit. Tags always point at the original source commit, not a generated
release commit.
@ -114,6 +118,11 @@ while [ $# -gt 0 ]; do
--skip-verify) skip_verify=true ;;
--print-version) print_version_only=true ;;
--from-candidate) from_candidate=true ;;
--notes-file)
shift
[ $# -gt 0 ] || release_fail "--notes-file requires a path."
notes_file_override="$1"
;;
-h|--help)
usage
exit 0
@ -134,6 +143,10 @@ if [ "$from_candidate" = true ] && [ "$channel" != "beta" ]; then
release_fail "--from-candidate only applies to the beta channel."
fi
if [ -n "$notes_file_override" ] && [ "$channel" != "stable" ]; then
release_fail "--notes-file only applies to the stable channel."
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
@ -203,6 +216,9 @@ if [ "$print_version_only" = true ]; then
fi
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
if [ -n "$notes_file_override" ]; then
NOTES_FILE="$notes_file_override"
fi
require_clean_worktree
require_npm_publish_auth "$dry_run"