You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Item 1 of #144. release.yml used a per-PR concurrency group, so two PRs merged close together could reserve versions out of order: the later PR took the lower version, and the "latest" release left out a change. A single shared group alone isn't enough, because GitHub keeps only the newest pending run in a group and cancels the one it replaces, which would silently skip a release.
One group: every run that can release uses release-main (cancel-in-progress: false), so releases never overlap. A run that can't release (an unmerged PR closing, a dispatch from a branch) gets a group of its own, so it can't replace a pending release run and then do nothing.
Catch-up planner: a plan job runs .github/scripts/release_queue.py, which lists every merged PR still owed a release, in the order their merge commits sit on main's history, never by merged_at (it has one-second resolution, so two merges in the same second would tie). main is fetched after the PRs are listed, so every listed merge has a position; if one doesn't, the run fails rather than guess, and the next run retries. The cutoff is the newest published Release, never just the newest git tag, so a run that tagged but failed before its Release doesn't hide that PR. It pages through closed PRs back to the cutoff PR's merge, so a backlog can't push an owed PR out of view. A PR is owed a release when it merged after the cutoff, has no [skip-release] marker, no Release names it, and its merge commit isn't inside the cutoff Release's tag, so history from before release automation is never swept up. With no Release at all, only the triggering PR is queued.
Serialized release: the release steps run as a matrix over that list with max-parallel: 1, so a cancelled or failed run's PR goes out with the next run.
Ordering guard: before reserving a tag, each PR checks that every PR ahead of it in the list already has a published Release, so none can overtake another. fail-fast: false, so one failure is reported without cancelling the rest.
Draft Releases don't count as released anywhere (planner, ordering guard, and the release job's existing-release check), and the release job publishes a draft it finds for its tag, so a draft can't leave a PR owed forever.
Manual runs (workflow_dispatch) go through the same planner, so a newer PR can't be released ahead of an older one still owed a release.
One blog PR per run: a docs job runs after the releases, even if some failed. It writes a post for every Release that main has no post for (via backfill_blog_posts.py), rebuilds the README and blog tables once, and force-pushes one docs/release-notes branch. Only one blog PR is ever open, and a still-open one is folded into the next run's instead of conflicting with it. Titles stay docs: add release blog for PR #N [skip-release], or release blogs for PR #A, #B when a run covers several.
Permissions: workflow-level permissions: {}. plan is read-only, release has contents: write and pull-requests: read, and docs has contents and pull-requests: write.
Testing
test_release_queue.py: 16 cases, covering ordering (including two merges in the same second, and a listed merge missing from main stopping the run), released and [skip-release] PRs, history inside the cutoff Release, an unpublished tag not moving the cutoff, the first release, a manual run for a newer PR queuing the older owed one first, paging back to the boundary and stopping, and JSON output. All script tests: 140 passed, 1 skipped.
Run read-only against this repo, the planner returns [] with v0.0.12 (PR ci(hooks): Lint the staged Markdown, not the working copy #170) as the cutoff, so the switch releases nothing extra. No Release is currently missing its post, so the docs job won't sweep in old posts.
The ordering guard's matching was run against real tags in a scratch repo, and the docs job's title and PR-number extraction was simulated for one and for several posts.
yamllint, actionlint and zizmor are clean, and scripts/gate.sh passed.
First live run: this PR's own merge runs the new workflow (GitHub uses main's copy after the merge), with a list of just this PR, and opens its blog PR from the new docs job.
Refs #144 (item 3, the test matrix, is still open; atelier-store later).
Release runs used a per-PR concurrency group, so two PRs merged close
together could reserve versions out of order: the later PR took the
lower version, and "latest" left out a change. One shared group alone
isn't enough, because GitHub keeps only the newest pending run in a
group and cancels the one it replaces, which would skip a release.
Every run now shares the release-main group. A plan job lists every
merged PR still owed a release, oldest merge first
(.github/scripts/release_queue.py), and the release job runs that list
one PR at a time, so a cancelled run's PR goes out with the next. Before
tagging, each PR checks that every PR ahead of it already has its tag,
so none can overtake another. A manual run still handles exactly the PR
it names. Write permissions move from the workflow to the release job.
Refs #144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.40%. Comparing base (ef64fe0) to head (c1074c3). ⚠️ Report is 1 commits behind head on main.
Addresses Copilot review on #172:
- The planner read only the 50 most recently updated closed PRs, so a
backlog could push an owed PR off the page. It now pages back until
PRs are older than the cutoff PR's merge.
- The cutoff was the highest git tag, so a run that tagged but failed
before its Release hid that PR from every later run. The cutoff is
now the newest published Release, and the ordering guard requires
earlier PRs to have a published Release, not just a tag.
- A manual run released exactly the PR it named, even ahead of an older
owed PR. It now goes through the same planner.
- Each released PR opened its own blog PR from main, and those rewrote
the same tables and conflicted. A docs job now runs after the
releases, writes every post main lacks, rebuilds the tables once and
force-pushes one docs/release-notes branch, so one blog PR is open at
a time and a still-open one is folded into the next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease
Addresses Copilot review on #172:
- merged_at has one-second resolution, so two PRs merged in the same
second could be queued in either order. The queue now follows each
merge commit's position on main's first-parent history, and only
merges newer than the checkout fall back to merge time.
- A run that can't release (an unmerged PR closing, a dispatch from a
branch) still joined release-main and could replace a pending release
run, then do nothing. Only eligible runs use release-main now; the
rest get a group of their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unbounded PR backlog can exceed GitHub's 256-job matrix limit
.github/workflows/release.yml:99
GitHub limits a matrix to 256 generated jobs, but the planner can return an unbounded backlog. At 257 owed PRs this matrix fails to expand before releasing anything, and every rerun sees the same unchanged cutoff and fails again. Cap each batch to at most 256 oldest PRs with a continuation mechanism, or process the queue in a non-matrix loop.
Addresses Copilot review on #172: merges newer than the plan job's
checkout fell back to merged_at, so two of them in the same second could
still be queued in either order. The planner now fetches main after
listing the PRs, so every listed merge has a position on main's history,
and fails the run rather than guess if one doesn't. The plan checkout
keeps its read-only credential for that fetch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses Copilot review on #172:
- The planner and the ordering guard ignore draft Releases, but the
release job's "Check existing release marker" counted them, so a PR
named only by a draft was skipped as released, never published, and
blocked every later PR at the guard. The check now ignores drafts too,
and "Create release" publishes a draft it finds for the tag.
- The release job reads the PR in "Resolve PR metadata" but had no
pull-requests permission, which can fail in a private repository. It
now has pull-requests: read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Item 1 of #144.
release.ymlused a per-PR concurrency group, so two PRs merged close together could reserve versions out of order: the later PR took the lower version, and the "latest" release left out a change. A single shared group alone isn't enough, because GitHub keeps only the newest pending run in a group and cancels the one it replaces, which would silently skip a release.release-main(cancel-in-progress: false), so releases never overlap. A run that can't release (an unmerged PR closing, a dispatch from a branch) gets a group of its own, so it can't replace a pending release run and then do nothing.planjob runs.github/scripts/release_queue.py, which lists every merged PR still owed a release, in the order their merge commits sit onmain's history, never bymerged_at(it has one-second resolution, so two merges in the same second would tie).mainis fetched after the PRs are listed, so every listed merge has a position; if one doesn't, the run fails rather than guess, and the next run retries. The cutoff is the newest published Release, never just the newest git tag, so a run that tagged but failed before its Release doesn't hide that PR. It pages through closed PRs back to the cutoff PR's merge, so a backlog can't push an owed PR out of view. A PR is owed a release when it merged after the cutoff, has no[skip-release]marker, no Release names it, and its merge commit isn't inside the cutoff Release's tag, so history from before release automation is never swept up. With no Release at all, only the triggering PR is queued.max-parallel: 1, so a cancelled or failed run's PR goes out with the next run.fail-fast: false, so one failure is reported without cancelling the rest.workflow_dispatch) go through the same planner, so a newer PR can't be released ahead of an older one still owed a release.docsjob runs after the releases, even if some failed. It writes a post for every Release thatmainhas no post for (viabackfill_blog_posts.py), rebuilds the README and blog tables once, and force-pushes onedocs/release-notesbranch. Only one blog PR is ever open, and a still-open one is folded into the next run's instead of conflicting with it. Titles staydocs: add release blog for PR #N [skip-release], orrelease blogs for PR #A, #Bwhen a run covers several.permissions: {}.planis read-only,releasehascontents: writeandpull-requests: read, anddocshascontentsandpull-requests: write.Testing
test_release_queue.py: 16 cases, covering ordering (including two merges in the same second, and a listed merge missing frommainstopping the run), released and[skip-release]PRs, history inside the cutoff Release, an unpublished tag not moving the cutoff, the first release, a manual run for a newer PR queuing the older owed one first, paging back to the boundary and stopping, and JSON output. All script tests: 140 passed, 1 skipped.[]withv0.0.12(PR ci(hooks): Lint the staged Markdown, not the working copy #170) as the cutoff, so the switch releases nothing extra. No Release is currently missing its post, so the docs job won't sweep in old posts.yamllint,actionlintandzizmorare clean, andscripts/gate.shpassed.First live run: this PR's own merge runs the new workflow (GitHub uses
main's copy after the merge), with a list of just this PR, and opens its blog PR from the newdocsjob.Refs #144 (item 3, the test matrix, is still open; atelier-store later).
🤖 Generated with Claude Code