Skip to content

Queued CI/CD

The CI/CD model shared across Arda’s repositories. It combines GitHub’s native merge queue, a changelog convention that keeps entries out of CHANGELOG.md until after the merge, and an ownership-based reviewer policy — so that several approved pull requests land together without rebase churn while main stays consistent with what was built and deployed.

This page is for engineers adopting the model in another repository. If you only need to author a pull request against a repository that already runs it, read Backend PR Process instead.

Arda-cards/cicd-testbed is the reference implementation and the source of every example below. It is a complete, minimal instance of the model with synthetic checks in place of a real build, so it can be broken freely.

Adopt it when two or more of the following hold:

  • Several authors commit frequently and want to merge in parallel.
  • The repository owns a release artifact that is tagged and deployed automatically.
  • The release notes ship with the artifact.
  • The version is computed from the change’s nature rather than chosen by the author.

Two problems, and they pull in opposite directions.

Entries in CHANGELOG.md serialise everything. Every pull request touches the same lines of one file, so any two in flight conflict, and the second rebases. A merge queue cannot batch what conflicts.

A green pull request is not a green main. Two changes can each pass in isolation and fail together. Testing branches proves nothing about the state that results from merging them.

The model resolves both by moving the entry off CHANGELOG.md until after the merge, and by testing the merged result rather than the branch.

Parallel mergesPull requests never touch a shared file, so the queue can batch them
main is always a tested stateThe queue builds the batch, not the branches
The version is derived, not chosenCategories used in the entry decide the semver bump
No human bypassPost-merge commits are pushed by an App identity, not by a privileged person
One owner per decisionEvery question the pipeline asks is answered in exactly one place

The last is the one that had to be learned. It is the substance of DQ-008, and the rest of this page is largely its consequences.

Between a merged pull request and a deployed artifact the pipeline answers four questions. Each has exactly one owner; the failures that produced this arrangement all came from a question having two.

#QuestionOwner
1What is this ref’s changelog entry?synthesize-changelog-entry
2Is this a feature build, and with what marker?the same component, from the same manifest
3Which environments does this build reach?the deploy matrix, by construction
4Who derives the version, tags, releases and publishes?the build alone

Concerns 1 and 2 are one component because they are one question asked of one manifest. Answering them separately is what let a marker written in a pull-request body be read by nothing and rejected by nothing, and what made “more than one entry file” stand in for “ambiguous marker” — which is what a merge queue stages on every batch.

The structural view below shows who asks whom. The gate, the build and the assembly are peers: none of them parses a manifest, and all three consume the same answer.

PlantUML diagram

Every adopting repository runs the same pre-merge machinery. They divide on one question, and it decides the whole post-merge design:

Is CHANGELOG.md an input to the build, or only an output of the release?

Variant 1 — assembly owns the releaseVariant 2 — the build owns the release
Repositoriesdocumentation, arda-frontend-appoperations, common-module, infrastructure
Where the version livesNowhere until assembly computes itCHANGELOG.md, read at build time
What assembly doesComputes the version, prepends the block, tags and creates the ReleasePrepends the block and stops
What the next build doesBuilds and deploysReads the version back out of the file, publishes, tags, releases

Both run assembly before the build, and in both the production build is gated on the assembly commit. The difference is what assembly is allowed to finish.

In Variant 2 the changelog is load-bearing: the version, git tag, image tag, chart version, Maven coordinates and Release body are all derived from it at build time. So assembly must write the file first, then get out of the way.

Both components claiming the release is not merely redundant — it deadlocks. Ported unchanged from Variant 1, assembly cut the tag and the build then failed on the tag it could not create, leaving a published Release with no artifact behind it. That is DQ-001 and REQ-PUB-007.

The sequence below traces a Variant 2 change from queue entry to deployment. The two pushes to main are the shape worth holding onto: the merge commit triggers assembly and nothing else, and the assembly commit is what triggers the build.

PlantUML diagram

The build is skipped on the merge commit and runs on the assembly commit. Both conditions are one if: in build.yaml — a build on the merge commit would derive the previous version and ship the wrong artifact under the right name.

Read the testbed’s copies; they are the form a new adopter wants.

merge-eligibility.yaml — one workflow, one required check, every pre-merge assertion in it. It resolves the manifest once and hands the answers to the assertions that need them: check-codeowners.sh, check-mergeable.sh, check-changelog.sh.

Three properties are deliberate and easy to lose:

  • Not draft-gated, and it re-evaluates on merge_group. A required check that skips publishes a successful check run, which is indistinguishable from having passed. Combined with the measured fact that a queued pull request converted back to draft is not ejected, that lets a change merge without its entry ever being validated (REQ-GATE-006).
  • One workflow, not one per assertion. They share a trigger that must not drift and the merge-queue pull-request resolution that each of them needs.
  • It resolves the queued entry’s pull request from the merge-group ref (gh-readonly-queue/main/pr-N-<sha>), because github.event.pull_request is absent there.

synthesize-changelog-entry answers what does this branch declare about itself — the entry text, the feature-build marker, and which route it came by. Everything else consumes that answer.

Its rules, all enforced in one place:

  1. Exactly one route — a .changelog/ file or a ## CHANGELOG section, never both.
  2. One manifest, one marker — checked on markers as well as entries.
  3. Ambiguity is more than one marker, not more than one file. A queue batch legitimately stages several branches’ files side by side; counting files fails every batch.
  4. A ref with no open pull request has no body, so the file is the whole manifest. More than one open pull request is an error rather than a guess.
  5. The marker never reaches CHANGELOG.md — it is read and stripped in the same place.
  6. A marker that cannot become a version is rejected, rather than silently producing an ordinary build.

Rule 3 is the one that keeps the queue working; rules 4–6 are DQ-009 and DQ-012.

Three steps with one owner each — changelog-assembly.yaml:

StepOwner
Which pull requests are pending?pending-prs.sh
What does each manifest say?the resolver, given the list
Compose one release from themassemble.sh

Two details in pending-prs.sh are load-bearing:

  • The range is bounded by assembly’s own previous commit, with the last tag as a fallback for a repository’s first run. Bounding on the tag only works while assembly is what creates it; once the build owns tagging, a build that fails after a successful assembly leaves no tag and the next run reaches back over merges it already covered.
  • The walk is --first-parent. git log --merges lists merges made inside pull-request branches too. Measured on operations: 175 reachable merges, 140 on the mainline, and two of the remaining 35 carry a Merge pull request #N subject — each of which would be collected as an entry for a pull request that never landed.

Because the unit of work is a range rather than the triggering commit, a failed assembly run loses nothing: the next run covers a superset (REQ-ASM-005).

arda-changelog-bot, a GitHub App owned by the organisation, is the sole non-human bypass actor on each adopting repository’s ruleset — so assembly commits reach main without granting any person a bypass. Permissions: Contents: write, Pull requests: read, Metadata: read. Credentials are org-level Actions secrets scoped to the adopting repositories; workflows mint one-hour installation tokens with actions/create-github-app-token@v2.

The exact values live in each repository’s ruleset and are its source of truth; read them there rather than from this page. What follows is what each setting has to satisfy.

SettingRequirement
merge_queue rulepresent, grouping_strategy: ALLGREEN
merge_methodMERGE, and merge commits should be the only allowed method — see below
strict_required_status_checks_policyfalse — the queue tests the merged result, which is stronger, and true forces a rebuild per entry
required_status_checksthe merge-eligibility gate, plus the build
review policyownership-based (CODEOWNERS) or count-based; the queue is indifferent
required_review_thread_resolutiontrue
bypass actorsthe App; human bypasses only where an administrator must retain them

Why the merge method is not a preference. Assembly finds pending work by matching Merge pull request #N from … in mainline merge commits. A squash merge produces Title (#N), which that pattern does not match — the entry is skipped with a warning and silently dropped from the release. Restricting the repository to merge commits makes the invariant structural (DQ-013).

Why the response timeout is sized against scheduling, not building. A merge queue cannot distinguish the check failed from the check never started; it ejects on the timeout either way. Measured on cicd-testbed: an entry was ejected after ten minutes because one required check was never allocated a runner, while the other four passed. Re-queued unchanged, the same check started in under five seconds and merged in 51 seconds. Size the timeout to absorb the worst case of scheduling latency plus build duration — in a repository with twenty-minute builds, a false ejection costs another twenty minutes.

CODEOWNERS is the review policy, and it fails open

Section titled “CODEOWNERS is the review policy, and it fails open”

Where required_approving_review_count is 0, CODEOWNERS is what requires review. An owner that does not resolve — a renamed team, a team without direct repository access, a typo — does not make the rule stricter or noisier. It makes it vacuous: the ruleset still advertises require_code_owner_review: true, and the pull request merges with no review at all.

Measured on cicd-testbed, the same pull request under the same rulesets:

CODEOWNERS stateResult
team lacked repository accessCLEAN, zero reviews, mergeable
team granted push accessBLOCKED, review requested

Nothing on the pull request distinguishes those, and repos/{owner}/{repo}/codeowners/errors is the only surface that reports it — which is why the gate queries it on every run, against the ref under test rather than the default branch.

Access must be direct; access inherited through a parent team does not count.

Own every path, including .github/. Workflows run from the head branch, so a pull request editing the gate runs the edited gate against itself. Owning every path is what stops a change rewriting the rule that is gating it. That in turn means the owner list must always contain somebody who can approve — an author cannot approve their own pull request, so a single-owner repository deadlocks whenever its only reviewer is the author (DQ-015).

Approval gates on deployment environments are not the mechanism that keeps feature builds out of production. The deploy matrix is: environments beyond dev are absent from a feature build’s matrix, so they are unreachable rather than gated (REQ-FEAT-008).

That separation is what makes it safe to relax approvals on intermediate environments without also relaxing them for marked branches. Where the deploy chain runs its API suite against dev and waits on the result, a sequential matrix with fail-fast already gates everything downstream — see DQ-014 for what that does and does not cover.

  1. Decide the variant. Is CHANGELOG.md a build input? If yes, Variant 2.
  2. Copy the machinery from cicd-testbed: the gate workflow, the four scripts, the assembly workflow, .changelog/README.md, the pull-request template, CODEOWNERS.
  3. Pin the shared actions to released versions; find the current ones on each action repository’s releases page.
  4. Grant the App access and add it as a bypass actor.
  5. Add the gate as a required check — but only once it exists on main, or it blocks every open pull request (REQ-ROLL-005).
  6. Enable the queue and size its parameters as above.
  7. Prove it with a deliberate test matrix before real traffic.

Step 7 is not ceremony. Every defect that reached production in this model’s own rollout was found by running it, not by reading it — including two contract defects that had passed review. The testbed’s own pull requests are a ready-made matrix: entry by file and by body, marked and unmarked, both routes at once, neither, a hand-edited CHANGELOG.md, and — the case no single pull request can exercise — two entries batched into one merge group.

Measured on cicd-testbed; its README carries the full findings table with the run that settled each.

QuestionAnswer
What names the destination branch on merge_group?github.event.merge_group.base_ref, as a full ref. github.base_ref is empty and github.ref_name is the queue branch.
Does a ruleset apply to the merge-queue ref?No. A probe using ref_name on a queued build concludes the branch is unprotected.
Does converting a queued pull request to draft eject it?No. It merged with isDraft: true. Failing a required check is the eviction mechanism.
Can a bypass actor waive code-owner review?No, in either direction.
Do two rulesets’ pull_request rules compose?Yes, to the stricter, with bypass evaluated per ruleset.
Is a check that never started distinguishable from one that failed?No — to the queue, and barely to a human: the abandoned run reports failure with no steps and no log.


Copyright: (c) Arda Systems 2025-2026, All rights reserved