Skip to content

ci(release): Release merged PRs one at a time, in merge order - #172

Merged
mpaulosky merged 5 commits into
mainfrom
squad/144-serialize-releases
Sep 30, 2026
Merged

mpaulosky merged 5 commits into
mainfrom
squad/144-serialize-releases

Conversation

@mpaulosky

@mpaulosky mpaulosky commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

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).

🤖 Generated with Claude Code

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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 01:24
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Test Results Summary

483 tests  ±0   483 ✅ ±0   18s ⏱️ -2s
  9 suites ±0     0 💤 ±0 
  9 files   ±0     0 ❌ ±0 

Results for commit c1074c3. ± Comparison against base commit ef64fe0.

♻️ This comment has been updated with latest results.

@codecov

codecov Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ 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.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #172   +/-   ##
=======================================
  Coverage   85.40%   85.40%           
=======================================
  Files          77       77           
  Lines        1596     1596           
  Branches      150      150           
=======================================
  Hits         1363     1363           
  Misses        189      189           
  Partials       44       44           

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Queue gaps and conflicting generated-docs branches can leave releases incomplete.

Review effort: Balanced
Findings: 3 High severity · 1 Medium severity

Open (4)
What changed in this PR

Introduces ordered, serialized release processing with catch-up support for missed PRs.

Changes:

  • Adds a shared release concurrency group and queued matrix.
  • Adds release-queue discovery and ordering logic.
  • Adds unit tests for queue behavior.
File Description
.github/​workflows/​release.yml Serializes releases and processes queued PRs.
.github/​scripts/​release_queue.py Discovers unreleased merged PRs.
.github/​scripts/​tests/​test_release_queue.py Tests queue filtering and ordering.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/scripts/release_queue.py Outdated
Comment thread .github/scripts/release_queue.py Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml
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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 01:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Release permissions and several queue edge cases can block or skip pending releases.

Review effort: Balanced
Findings: 2 High severity

Open (2)
Resolved since last review (4)

Comment thread .github/scripts/release_queue.py Outdated
Comment thread .github/workflows/release.yml Outdated
…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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 01:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Same-second post-checkout merges can still be misordered, and queues exceeding GitHub’s matrix limit cannot recover.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity 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.

Comment thread .github/scripts/release_queue.py Outdated
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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 01:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Missing pull-request permissions can stop releases, while draft releases can permanently block the queue.

Review effort: Balanced
Findings: 2 High severity

Open (2)
Resolved since last review (1)

Comment thread .github/scripts/release_queue.py
Comment thread .github/workflows/release.yml
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>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 02:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The release ordering, retry behavior, permissions, documentation aggregation, and key edge cases are coherently implemented and tested.

Review effort: Balanced
Findings: None

Resolved since last review (2)

@mpaulosky
mpaulosky merged commit d96fd20 into main Sep 30, 2026
28 checks passed
@mpaulosky
mpaulosky deleted the squad/144-serialize-releases branch September 30, 2026 02:04
mpaulosky added a commit that referenced this pull request Sep 30, 2026
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants