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.
When to use this model
Section titled “When to use this model”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.
The problem it solves
Section titled “The problem it solves”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.
Design objectives
Section titled “Design objectives”| Parallel merges | Pull requests never touch a shared file, so the queue can batch them |
main is always a tested state | The queue builds the batch, not the branches |
| The version is derived, not chosen | Categories used in the entry decide the semver bump |
| No human bypass | Post-merge commits are pushed by an App identity, not by a privileged person |
| One owner per decision | Every 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.
Four decisions, four owners
Section titled “Four decisions, four owners”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.
| # | Question | Owner |
|---|---|---|
| 1 | What is this ref’s changelog entry? | synthesize-changelog-entry |
| 2 | Is this a feature build, and with what marker? | the same component, from the same manifest |
| 3 | Which environments does this build reach? | the deploy matrix, by construction |
| 4 | Who 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.
Two variants
Section titled “Two variants”Every adopting repository runs the same pre-merge machinery. They divide on one question, and it decides the whole post-merge design:
Is
CHANGELOG.mdan input to the build, or only an output of the release?
| Variant 1 — assembly owns the release | Variant 2 — the build owns the release | |
|---|---|---|
| Repositories | documentation, arda-frontend-app | operations, common-module, infrastructure |
| Where the version lives | Nowhere until assembly computes it | CHANGELOG.md, read at build time |
| What assembly does | Computes the version, prepends the block, tags and creates the Release | Prepends the block and stops |
| What the next build does | Builds and deploys | Reads 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 pipeline, end to end
Section titled “The pipeline, end to end”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.
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.
Components
Section titled “Components”Read the testbed’s copies; they are the form a new adopter wants.
The merge-eligibility gate
Section titled “The merge-eligibility gate”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>), becausegithub.event.pull_requestis absent there.
Entry resolution
Section titled “Entry resolution”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:
- Exactly one route — a
.changelog/file or a## CHANGELOGsection, never both. - One manifest, one marker — checked on markers as well as entries.
- 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.
- 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.
- The marker never reaches
CHANGELOG.md— it is read and stripped in the same place. - 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.
Assembly
Section titled “Assembly”Three steps with one owner each — changelog-assembly.yaml:
| Step | Owner |
|---|---|
| Which pull requests are pending? | pending-prs.sh |
| What does each manifest say? | the resolver, given the list |
| Compose one release from them | assemble.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 --mergeslists merges made inside pull-request branches too. Measured onoperations: 175 reachable merges, 140 on the mainline, and two of the remaining 35 carry aMerge pull request #Nsubject — 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).
The release identity
Section titled “The release identity”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.
Repository configuration
Section titled “Repository configuration”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.
Branch protection
Section titled “Branch protection”| Setting | Requirement |
|---|---|
merge_queue rule | present, grouping_strategy: ALLGREEN |
merge_method | MERGE, and merge commits should be the only allowed method — see below |
strict_required_status_checks_policy | false — the queue tests the merged result, which is stronger, and true forces a rebuild per entry |
required_status_checks | the merge-eligibility gate, plus the build |
| review policy | ownership-based (CODEOWNERS) or count-based; the queue is indifferent |
required_review_thread_resolution | true |
| bypass actors | the 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 state | Result |
|---|---|
| team lacked repository access | CLEAN, zero reviews, mergeable |
| team granted push access | BLOCKED, 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).
Environments
Section titled “Environments”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.
Adopting the model
Section titled “Adopting the model”- Decide the variant. Is
CHANGELOG.mda build input? If yes, Variant 2. - Copy the machinery from
cicd-testbed: the gate workflow, the four scripts, the assembly workflow,.changelog/README.md, the pull-request template, CODEOWNERS. - Pin the shared actions to released versions; find the current ones on each action repository’s releases page.
- Grant the App access and add it as a bypass actor.
- Add the gate as a required check — but only once it exists on
main, or it blocks every open pull request (REQ-ROLL-005). - Enable the queue and size its parameters as above.
- 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.
Platform behaviour worth knowing
Section titled “Platform behaviour worth knowing”Measured on cicd-testbed; its README carries the full findings table with the run that settled each.
| Question | Answer |
|---|---|
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. |
Further reading
Section titled “Further reading”- Backend PR Process — the contributor’s guide for the Variant 2 repositories.
- Frontend PR Process — the same for
arda-frontend-app. - Queued CI/CD Adoption — goal, requirements, design and decision log.
cicd-testbed,synthesize-changelog-entry,qualify-build-action,gradle-build-pipeline-action.knowledge-base/arda-changelog-bot.md(repo-local) — App identity, secrets, rotation.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved