Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ For security concerns, please review our [Security Policy](docs/SECURITY.md).

| Version | Date | Title | Blog post |
| ------- | ---- | ----- | --------- |
| [v0.0.13](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.13) | 2026-09-30 | ci(release): Release merged PRs one at a time, in merge order | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-30-pr-172-ci-release-release-merged-prs-one-at-a-time-in-merge-order.md) |
| [v0.0.12](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.12) | 2026-09-30 | ci(hooks): Lint the staged Markdown, not the working copy | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-30-pr-170-ci-hooks-lint-the-staged-markdown-not-the-working-copy.md) |
| [v0.0.11](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.11) | 2026-09-30 | refactor(CoreBusiness): Rename ApprovedForRelease to Approved | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-30-pr-168-refactor-corebusiness-rename-approvedforrelease-to-approved.md) |
| [v0.0.10](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.10) | 2026-09-30 | fix(UI): Name the Statuses as CONTEXT.md does | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-30-pr-166-fix-ui-name-the-statuses-as-context-md-does.md) |
Expand All @@ -89,7 +90,6 @@ For security concerns, please review our [Security Policy](docs/SECURITY.md).
| [v0.0.6](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.6) | 2026-09-29 | ci(release): Drop featured_image from release blog posts | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-29-pr-157-ci-release-drop-featured-image-from-release-blog-posts.md) |
| [v0.0.5](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.5) | 2026-09-29 | docs: Add a CONTEXT.md glossary of the domain language | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-29-pr-153-docs-add-a-context-md-glossary-of-the-domain-language.md) |
| [v0.0.4](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.4) | 2026-09-29 | ci: Run code metrics by hand and drop unused Auth0 settings | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-29-pr-148-ci-run-code-metrics-by-hand-and-drop-unused-auth0-settings.md) |
| [v0.0.3](https://github.com/mpaulosky/IssueTracker/releases/tag/v0.0.3) | 2026-09-29 | docs: Write agent guidance for IssueTracker | [Post](https://github.com/mpaulosky/IssueTracker/blob/main/docs/blogs/2026-09-29-pr-146-docs-write-agent-guidance-for-issuetracker.md) |

<!-- RELEASES_END -->

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
post_title: "ci(release): Release merged PRs one at a time, in merge order"
author1: mpaulosky
post_slug: "v0.0.13-pr-172"
microsoft_alias: n/a
categories:
- engineering
tags:
- release:v0.0.13
- automation
ai_note: "Generated by release automation from the PR title, description, commits and changed files. No AI summary."
summary: "Release notes seed for v0.0.13 from PR #172."
post_date: "2026-09-30"
---
# ci(release): Release merged PRs one at a time, in merge order

- **Release tag:** `v0.0.13`
- **Source PR:** [#172](https://github.com/mpaulosky/IssueTracker/pull/172)

## PR description

### 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 #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](https://claude.com/claude-code)

## Commits

- ci(release): Release merged PRs one at a time, in merge order (`c8bad77`)
- ci(release): Cut off at published Releases and send one blog PR per run (`65b9557`)
- ci(release): Order by position on main and isolate runs that can't release (`6488c1d`)
- ci(release): Place every queued PR by main's history, never by time (`c13dd63`)
- ci(release): Treat draft Releases as unreleased throughout (`c1074c3`)

## Files changed

### .github/

- `.github/scripts/release_queue.py` (+173 / -0)
- `.github/scripts/tests/test_release_queue.py` (+230 / -0)
- `.github/workflows/release.yml` (+239 / -185)
1 change: 1 addition & 0 deletions docs/blogs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This directory contains concise release-review posts for merged PR releases.

| Date | Title | Tags |
| ---- | ----- | ---- |
| 2026-09-30 | [ci(release): Release merged PRs one at a time, in merge order](2026-09-30-pr-172-ci-release-release-merged-prs-one-at-a-time-in-merge-order.md) | release,automation |
| 2026-09-30 | [ci(hooks): Lint the staged Markdown, not the working copy](2026-09-30-pr-170-ci-hooks-lint-the-staged-markdown-not-the-working-copy.md) | release,automation |
| 2026-09-30 | [refactor(CoreBusiness): Rename ApprovedForRelease to Approved](2026-09-30-pr-168-refactor-corebusiness-rename-approvedforrelease-to-approved.md) | release,automation |
| 2026-09-30 | [fix(UI): Name the Statuses as CONTEXT.md does](2026-09-30-pr-166-fix-ui-name-the-statuses-as-context-md-does.md) | release,automation |
Expand Down
Loading