Skip to content

Commit 40dff0c

Browse files
cliffhallclaude
andcommitted
fix: publish only from main; tie the version PRs to the release issue
- release.yml refuses a Release whose commit is not on main, before any publish job is eligible. A Release can be created for any tag, and main is what makes the released code reviewed code. - prepare-python-release.yml takes the release issue's number: the PR body opens with Closes #N, the branch is v2/chore/<N>-python-calver-<date>. - version-packages.yml labels its PR v2. The action rewrites that PR's body on every run, so AGENTS.md names it as an exception and ties it to the release issue with a comment instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
1 parent 3c9ebed commit 40dff0c

5 files changed

Lines changed: 71 additions & 6 deletions

File tree

‎.github/workflows/prepare-python-release.yml‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@
88
# published until a maintainer publishes a GitHub Release (`release.yml`).
99
#
1010
# The PR targets the branch the workflow was dispatched on, which is `v2/main`
11-
# in the normal flow.
11+
# in the normal flow. It is one of a release's preparation PRs, so it follows
12+
# the same rules as any other PR here: its body opens with `Closes #N` for the
13+
# milestone's release issue (the `issue` input), its branch is named for that
14+
# issue, and it is labeled `v2`. The link is only a cross-reference on
15+
# `v2/main`; the release issue stays open until the Release is published.
1216

1317
name: Prepare Python Release
1418

1519
on:
1620
workflow_dispatch:
21+
inputs:
22+
issue:
23+
description: "The release issue this PR belongs to (its number, e.g. 4930)"
24+
required: true
25+
type: string
1726

1827
jobs:
1928
prepare:
@@ -56,16 +65,24 @@ jobs:
5665
env:
5766
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5867
BASE: ${{ github.ref_name }}
68+
ISSUE: ${{ inputs.issue }}
5969
run: |
70+
# The input is free text; only a bare issue number may reach the
71+
# branch name and the PR body.
72+
case "$ISSUE" in
73+
'' | *[!0-9]*) echo "issue must be a number, got: '$ISSUE'" >&2; exit 1 ;;
74+
esac
6075
DATE=$(date -u +%Y.%-m.%-d)
61-
BRANCH="release/python-$DATE"
76+
BRANCH="v2/chore/$ISSUE-python-calver-$DATE"
6277
git config user.name "github-actions[bot]"
6378
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
6479
git checkout -b "$BRANCH"
6580
git add -u
6681
git commit -m "chore: stamp Python CalVer versions for $DATE"
6782
git push --force origin "$BRANCH"
6883
{
84+
echo "Closes #$ISSUE"
85+
echo
6986
echo "Stamps today's CalVer date onto the Python packages that changed since their last version bump:"
7087
echo
7188
sed 's/^/- /' "$RUNNER_TEMP/stamped.txt"

‎.github/workflows/release.yml‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
# registry tokens. Each trusted publisher is bound to this workflow's filename
2424
# (`release.yml`) and the `release` environment, so the publish jobs must stay
2525
# in this file and keep that environment.
26+
#
27+
# Only a commit that is on `main` is published. A Release can be created for
28+
# any tag on any commit, and the environment approval shows a reviewer a tag
29+
# name, not what it points at, so `detect-packages` checks the released commit
30+
# against `main` and every publish job needs it.
2631

2732
name: Release
2833

@@ -40,8 +45,22 @@ jobs:
4045
pypi_packages: ${{ steps.find-packages.outputs.pypi_packages }}
4146
steps:
4247
# On a release event the default ref is the release's tag: the exact
43-
# commit the maintainer released.
48+
# commit the maintainer released. Full history, for the check below.
4449
- uses: actions/checkout@v6
50+
with:
51+
fetch-depth: 0
52+
53+
# `main` only receives reviewed milestone merges, so "on main" is what
54+
# makes the released code reviewed code. A tag cut from `v2/main`, a
55+
# feature branch or a fork's commit fails here, before any publish job
56+
# is eligible to run.
57+
- name: Refuse a release whose commit is not on main
58+
run: |
59+
git fetch --no-tags origin main
60+
if ! git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then
61+
echo "::error::The released commit $GITHUB_SHA is not on main. Publish the Release from a tag on main (see RELEASING.md)."
62+
exit 1
63+
fi
4564
4665
- name: Find all packages
4766
id: find-packages

‎.github/workflows/version-packages.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
# so it has to exist on the develop branch before the milestone merge carries
1010
# it to `main`. A bump made downstream of `v2/main` would never flow back.
1111
#
12+
# The action rewrites the PR's title and body on every run, so that PR cannot
13+
# carry a `Closes #N` line the way every other PR here does; `AGENTS.md` names
14+
# it as an exception and says how it is tied to its release issue instead. The
15+
# `v2` label does survive an update, and this workflow applies it.
16+
#
1217
# This workflow only ever opens a PR. It gives the action no publish script, so
1318
# it never publishes, tags or creates a Release; that is `release.yml`, on a
1419
# maintainer-published GitHub Release.
@@ -43,6 +48,7 @@ jobs:
4348
run: npm ci --ignore-scripts
4449

4550
- name: Create or update the Version Packages PR
51+
id: changesets
4652
uses: changesets/action@v2
4753
with:
4854
version-script: npm run version-packages
@@ -53,3 +59,12 @@ jobs:
5359
# the CHANGELOG entries. The action itself authenticates through its
5460
# `github-token` input, which defaults to this same token.
5561
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
63+
# Every PR in this repository is labeled `v2`. Adding a label that is
64+
# already there is a no-op, so this runs on every update.
65+
- name: Label the Version Packages PR
66+
if: steps.changesets.outputs.pr-number != ''
67+
env:
68+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
69+
PR: ${{ steps.changesets.outputs.pr-number }}
70+
run: gh pr edit "$PR" --add-label v2

‎AGENTS.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,20 @@ holds for a maintainer's own one-line fix as much as for a feature.
241241
slug**. It still targets `v2/main`, and it is reviewed inside the fork. The
242242
flow is the `security-advisory` skill.
243243

244+
- **Exception: the two version PRs that automation opens.** Each changes
245+
version metadata only, so neither answers the template checklist, and each
246+
is tied to its milestone's **release issue** rather than to an issue of its
247+
own.
248+
- The **Prepare Python Release** PR follows the rest of the rules: the
249+
workflow takes the release issue's number, writes `Closes #N` as the
250+
body's first line, names the branch `v2/chore/<N>-python-calver-<date>`
251+
and labels it `v2`.
252+
- The changesets **"Version Packages"** PR cannot carry a `Closes` line: the
253+
action rewrites its body on every push to `v2/main`, and its branch is the
254+
action's own (`changeset-release/v2/main`). The workflow labels it `v2`.
255+
Tie it to the release issue with a **comment** (`Part of #N`), which an
256+
update does not overwrite.
257+
244258
No other PR is exempt.
245259

246260
Every PR answers the checklist in

‎RELEASING.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The semver line starts at **`1.0.0`** for all four packages. Their earlier relea
2828

2929
`fetch`, `git` and `time` use **CalVer** (`2026.8.1`). pip and uv have no dist-tags and always install the numerically highest version, so the existing `2026.x` line on PyPI has to keep climbing.
3030

31-
A maintainer dispatches the **Prepare Python Release** workflow ([`prepare-python-release.yml`](.github/workflows/prepare-python-release.yml)) on `v2/main`. It runs [`scripts/prepare-python-release.mjs`](scripts/prepare-python-release.mjs), which stamps today's date onto each Python package with a shipped change since its last version bump, refreshes its `uv.lock`, and opens a normal PR. A package with no such change is left alone.
31+
A maintainer dispatches the **Prepare Python Release** workflow ([`prepare-python-release.yml`](.github/workflows/prepare-python-release.yml)) on `v2/main`, giving it the number of the milestone's release issue. It runs [`scripts/prepare-python-release.mjs`](scripts/prepare-python-release.mjs), which stamps today's date onto each Python package with a shipped change since its last version bump, refreshes its `uv.lock`, and opens a PR whose body starts with `Closes #<release issue>`. A package with no such change is left alone.
3232

3333
GitHub only lists a `workflow_dispatch` workflow that exists on the default branch (`main`). Until the first milestone merge carries this one there, run the same script locally on a branch cut from `origin/v2/main` and open the PR by hand:
3434

@@ -39,11 +39,11 @@ node scripts/prepare-python-release.mjs # prints one "name: old -> new" line
3939
### PRs opened by these workflows
4040

4141
> [!NOTE]
42-
> Both workflows open their PR with the workflow token, and GitHub does not start CI for a PR opened that way. Close and reopen the PR to run CI before merging. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR.
42+
> Both workflows open their PR with the workflow token, and GitHub does not start CI for a PR opened that way. Close and reopen the PR to run CI before merging. The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR.
4343
4444
## How publishing works
4545

46-
Publishing is triggered by a maintainer **publishing a GitHub Release** whose tag is on `main`. There is no scheduled or merge-triggered release. The release tag is only a label: it carries no version, and nothing compares it with one.
46+
Publishing is triggered by a maintainer **publishing a GitHub Release** whose tag is on `main`. There is no scheduled or merge-triggered release. The release tag is only a label: it carries no version, and nothing compares it with one. What the workflow does check is where the tag points: a Release whose commit is not on `main` fails before any publish job can start, since `main` only receives reviewed milestone merges.
4747

4848
[`release.yml`](.github/workflows/release.yml) runs on `release: published`, gated by the `release` environment (a required reviewer must approve each deployment). It runs every package as an independent matrix job (`fail-fast: false`, so one package's failure never blocks another), checked out at the release tag. Each job: registry-diff guard → install → **run the package's tests** (plus `pyright` for Python) → build → publish.
4949

0 commit comments

Comments
 (0)