mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
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:
1 parent
6691c57e54
commit
664052f8ea
9 files changed
+636
-28
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user