Skip to content

feat: add merge queue versioning workflows - #122

Open
dblane-digicatapult wants to merge 7 commits into
mainfrom
merge-queue-versioning
Open

dblane-digicatapult wants to merge 7 commits into
mainfrom
merge-queue-versioning

Conversation

@dblane-digicatapult

@dblane-digicatapult dblane-digicatapult commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Checklist

  • Have you read Digital Catapult's Code of Conduct?
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation.
  • My changes generate no new warnings.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.

Validated in the hello-world canary rather than by unit tests, which is how reusable workflows are verified here. Evidence below.

PR Type

  • Feature
  • Bug Fix

Linked tickets

High level description

Adds an opt-in versioning model for repositories on a GitHub merge queue, where a pull request carries only the intent to bump (a v: label) and the version number is computed on trunk after merge. Also fixes five existing workflows that break, or silently misbehave, under merge_group.

Additive throughout. The synchronise-*-version workflows are untouched, so repositories not adopting a queue are unaffected.

Detailed description

Why the current gate cannot survive a queue. Requiring package.json to be ahead of the last release puts an absolute number on the branch, so two approved pull requests compute the same one. The queue merges the first and publishes that tag; the second now equals the latest tag, fails check-version, and is evicted. Recovering it needs another bump, which dismisses its approval. That is the loop the queue was adopted to remove.

New workflows

Workflow Purpose
require-version-label.yml Gates a PR on exactly one v: label. Order independent, so every queued PR satisfies it at once. No token, no permissions.
apply-version-npm.yml / apply-version-poetry.yml Compute and commit the version on trunk after merge. Same arithmetic as synchronise-pr-version-*, different location. Resolves the bump across every commit in the push, so batched queue merges work.

Why the App token, and why a merge produces two runs. build-docker, generate-sbom and release-github all read the version from the checked-out tree, so the released commit must be the one carrying the new version. GITHUB_TOKEN pushes do not trigger workflows, so a version commit pushed with it would never be built and nothing would be published. Using the App token means the version commit triggers a second run, and that run releases. A shared chore(release): prefix guards both sides against triggering each other.

This is the same App the synchronise-* workflows already use, so adopters need no new app and no new secrets. It does need adding to the trunk ruleset bypass.

Bug fixes

Fix Impact
migration-checks-npm/poetry resolve merge_group A merge_group payload has neither pull_request nor before, so the base sha resolved empty and the immutability lint, ordering lint and seeded-upgrade silently no-opped for every queued PR. Lost coverage, not just a compatibility nicety.
tests-npm coverage comment guarded to pull_request No PR to comment on under merge_group; would fail the job and stall the queue for any repo with coverage: true.
tests-npm / tests-poetry job names The name interpolated the branch, so the check name carried the ephemeral gh-readonly-queue/... ref, unique per PR and per merge attempt. Such a name can never be a required status check, so the queue would wait forever.
static-checks-npm/poetry SARIF upload skipped on merge_group The queue deletes its ref before code scanning can attach the analysis, failing with "ref not found". Observed on a real queue run. Would block every merge once lint and test are required.
release-github release notes Built from gh pr list --limit 1, which orders by created date, not merged date, so a long-lived PR merged today loses to a newer one merged last week. Also credits only one PR, misattributing any batched merge. Now resolved from the commits since the previous release tag.
build-docker gains fail_on_same_version (default true) Its check-version call defaulted to failing when the version matches the latest release. For repos with no separate check-version job that is the PR version gate, so it cannot simply be disabled. Queue repos set false; default is unchanged for everyone else.

Documentation

README gains a "Choosing a versioning model" section presenting the two models as alternatives, and a "Merge queue compatibility" table recording the merge_group behaviour once rather than in each file. Three new examples docs.

Describe alternatives you've considered

Pushing the version commit with GITHUB_TOKEN. Preferred initially because it cannot loop. Rejected: such pushes never trigger a run, so the release keeps running on the pre-bump commit and silently publishes nothing. Making it work needs a ref or version input plumbed through build-docker, generate-sbom and release-github, a much larger and non-additive change.

Deriving the version from git tags and dropping it from package.json. Cleanest conceptually, but changes anything reading package.json at build or deploy time.

Replacing the per-PR bump outright. Rejected. Both models are kept and documented so repositories can adopt a queue at their own pace.

Disabling build-docker's same-version failure rather than adding an input. Rejected: it is the de facto PR version gate for repos without a separate check-version job.

Operational impact

Additive for every existing consumer. Behaviour changes only on code paths that are currently broken or unreachable on pull_request and push.

A repository adopting the new model must:

  1. Add merge_group to whichever workflow provides its required checks, or the queue waits indefinitely.
  2. Set fail_on_same_version: false on build-docker in its PR workflow.
  3. Add the GitHub App to the trunk ruleset bypass.
  4. Use one model or the other, never both, since they compete over the same v: labels.

Additional context

Design writeup: Merge Queue for testbed-portal.

Verified in a real merge queue on hello-world (against a throwaway branch, main untouched):

  • Two PRs both labelled v:patch merged together with no version on either branch. PR 31's queue branch was built on PR 30's commit, confirming stacking.
  • A PR failing migration-checks was refused entry: "Pull request has failing required statuses".
  • Full suite green on the queue's own branch, 38 jobs: run 30699160605.

Embedded shell logic (label matching, version arithmetic, precedence, prefix guard, release-note tag detection and range) was executed against a case table including multi-label, no-label, null payload, two-digit components, a force-moved latest tag and near-miss labels such as v:majorish.

Known gap: apply-version-poetry.yml is not exercised by the canary, which syncs only package.json. A Poetry repo needs its own canary before adopting.

Repos on a GitHub merge queue cannot use the existing version gate. That gate
requires an absolute version number on the pull request branch, so two approved
PRs necessarily compute the same next version and the second to reach the queue
fails check-version and is evicted. Recovering it means bumping again, which
dismisses its approval and drops it from the queue, which is the loop the merge
queue was meant to remove.

Add an alternative model where the PR carries only the intent to bump and the
number is computed on trunk after merge:

- require-version-label.yml gates a PR on carrying exactly one v: label. It is
  order independent, so every queued PR satisfies it at once. No permissions
  needed, labels come from the event payload.
- apply-version-npm.yml / apply-version-poetry.yml compute and commit the
  version on trunk after merge, using the same arithmetic as the existing
  synchronise-*-version workflows.

The version commit is pushed with the GitHub App token rather than
GITHUB_TOKEN. build-docker, generate-sbom and release-github all read the
version from the checked out tree, so the released commit must be the one
carrying the new version. GITHUB_TOKEN pushes do not trigger workflows, so the
version commit would never be built. A merge therefore produces two runs on
trunk: one applies the version, the second releases it. The commit prefix guard
keeps those two from triggering each other.

A merge queue can land several PRs in one push, so the bump is resolved across
every commit in the push and the strongest label wins.

Also fix two workflows that silently misbehave under merge_group:

- migration-checks-npm/poetry resolved no base sha on merge_group, so the
  immutability lint, ordering lint and seeded-upgrade all no-opped for every
  queued PR.
- tests-npm posted its coverage comment unconditionally, which has no PR to
  attach to on merge_group and would stall the queue when coverage is required.

The existing synchronise-*-version workflows are untouched, so repos not
adopting a merge queue are unaffected.
gh pr list --state merged --limit 1 was wrong in two independent ways. It
orders by created date rather than merged date, so a long-lived PR merged today
loses to a newer one merged last week. And it credits only one PR, so any
release containing several silently attributes the whole release to one
arbitrary change. That is every batched merge queue merge, and any release
covering more than one push.

Resolve the PRs from the commits since the previous release tag instead. This
is also what makes release notes correct under merge queue versioning, where
the released commit is a bot version commit with no PR of its own.

Requires full history, so checkout now uses fetch-depth: 0. The floating latest
tag is excluded from tag detection or the range would always be empty.

Notes are written to a file and passed via body_path rather than through a
GITHUB_OUTPUT heredoc, which also removes the risk of a PR body containing the
delimiter and corrupting the release.
The tests job name interpolated matrix.branch, so the resulting check name
contained the branch: 'Run tests - test:init - my-feature-branch'. Under a
merge queue that becomes the ephemeral gh-readonly-queue/main/pr-N-<sha> ref,
so the check name is unique per pull request and per merge attempt and can
never be listed as a required status check. The queue would wait indefinitely
for a name that never appears again.

Use 'current' and 'main' instead, which is all that is needed to tell the two
coverage matrix legs apart.

This breaks no existing consumer: the name already varied per pull request, so
no repository can currently have these checks in its required list.
The merge queue deletes its ephemeral gh-readonly-queue/... ref as soon as the
entry merges, so code scanning has no ref to attach the analysis to and the
upload fails with 'ref ... not found in this repository'. Observed on a real
queue run: static-checks-npm and static-checks-poetry both failed on
merge_group for this reason alone.

That matters because lint and test must be a required check for the queue to be
worth having. A queue that merges failing CI is worse than no queue. With this
unfixed, making static-checks required would block every merge for a reason
unrelated to the code.

The scan itself still runs on merge_group, so a real finding still fails the
job. Only the upload is skipped, and the same commit is uploaded from its pull
request run and again on push to trunk, so no coverage is lost.
The comments had grown into essays repeating the same reasoning in seven files.
Trimmed each to the non-obvious fact plus a pointer, and documented the
merge_group behaviour once in a README table instead.
build-docker's check-version call had no fail_on_same_version, so it defaulted
to true: fail when the package version already matches the latest release. For
repos with no separate check-version job (testbed-portal among them) that is
the de facto pull request version gate, so it cannot simply be turned off.

Under merge queue versioning the branch deliberately carries no version bump, so
in steady state the version equals the latest tag and this would fail every
pull request. Queue repos set the input to false and rely on
require-version-label instead.

Default is true, so no existing caller changes behaviour.

Worth noting the canary did not catch this: hello-world's package.json is 2.0.0
against a latest release tag of v1.1.3, so it was already ahead and the check
passed by accident.
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.

1 participant