diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md index 9ae7972d09..7852ad1a83 100644 --- a/.agents/skills/release-changelog-discord-message/SKILL.md +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -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 diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index e428969fdd..da1d141d33 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -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` + 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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7915921ba8..3f0e1e98a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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:-}.md on master. Merge the notes PR from the beta's draft branch (release-notes/v), 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" diff --git a/doc/RELEASE-CHECKLIST.md b/doc/RELEASE-CHECKLIST.md index 3ab74932a8..e8d3c03245 100644 --- a/doc/RELEASE-CHECKLIST.md +++ b/doc/RELEASE-CHECKLIST.md @@ -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`; 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.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.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-canonicalize`) so the notes land at + `releases/vYYYY.MDD.P.md` Fix path: `candidate/release-` 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 diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 1e1891663d..3eb0059316 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -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.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.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.md`, grouped from + `git log ..` — and force-pushes it to the + machine-owned `release-notes/v` 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`, adding + `releases/beta/v.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.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-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` 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.md` and push to the notes PR. + Recommended local generation flow: ```bash diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index f326811a57..603f5832fd 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -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 [--dry-run] + ./scripts/create-github-release.sh [--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 diff --git a/scripts/draft-stable-notes.sh b/scripts/draft-stable-notes.sh new file mode 100755 index 0000000000..1ba75033d7 --- /dev/null +++ b/scripts/draft-stable-notes.sh @@ -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 [--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.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" diff --git a/scripts/draft-stable-notes.test.mjs b/scripts/draft-stable-notes.test.mjs new file mode 100644 index 0000000000..c5bb01dfc0 --- /dev/null +++ b/scripts/draft-stable-notes.test.mjs @@ -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.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", + }) + ); +}); diff --git a/scripts/release.sh b/scripts/release.sh index 5d51aab69a..9c94f467e6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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 [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] + ./scripts/release.sh [--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"