Queued CI/CD
A shared CI/CD model used across Arda’s repositories. 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. The model lets multiple approved PRs land concurrently without rebase churn while keeping main consistent with what’s been built and deployed.
When to use this model
Section titled “When to use this model”Adopt this model in a repository when two or more of the following apply:
- Multiple authors commit frequently and want to merge in parallel.
- The repository owns a release artifact (Pages site, npm package, Docker image, Maven jar) that is tagged + deployed automatically.
- The release notes are part of the artifact (a
CHANGELOG.mdfile or equivalent). - The release version is computed mechanically from the change’s nature (semver) rather than chosen by the author.
documentation, arda-frontend-app, operations, common-module, and infrastructure all satisfy these.
Two variants
Section titled “Two variants”Every adopting repository runs the same pre-merge machinery. They divide on one question, and it is worth stating plainly because 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 by qualify-build-action |
| What assembly does | Computes the version, prepends the release block, creates the git tag and the GitHub Release | Prepends the release block and stops |
| What the following build does | Builds and deploys the artifact | Reads the version back out of the file assembly just wrote, then publishes, tags, and creates the Release |
| Version of record | The assembly commit | CHANGELOG.md on the assembly commit |
Both variants 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: operations/gradle.properties carries version=0.0.0, and the version, git tag, Docker image tag, Helm chart version, Maven coordinates, and Release body are all derived from CHANGELOG.md at build time. Stop editing that file in pull requests and the merge commit still shows the previous release — the tag already exists and the publish fails or silently does not happen. So assembly has to write the file first, and then get out of the way: it makes no tag and no Release, and the existing publish path is untouched.
The design rationale for Variant 2, including the alternatives rejected, is in Queued CI/CD Adoption.
Common architecture
Section titled “Common architecture”Four workflow groups participate in every adopting repository. Names vary; roles do not.
- Merge-eligibility gate — required status check. Establishes what must be true before a PR may merge: it carries exactly one valid changelog entry, it does not edit
CHANGELOG.md, it is not a draft, and CODEOWNERS resolves. Published aschangelog-checkin Variant 1 and asmerge-eligibilityin Variant 2. - Pre-merge build — runs on
pull_request. Builds the artifact and, where the repository has one, publishes to a per-PR preview environment so reviewers see live content. - Queue build — runs on
merge_group, against the queue’s synthetic batch head. Publishes nothing. This is what catches pairs of PRs that build independently but not together. changelog-assembly— runs onpush: branches: [main]for a commit that is not itself an assembly commit. Reads the merged PRs’ entries, computes the next semver from the categories used, prepends a release block toCHANGELOG.md, validates withclq, and commits with the titlechore: assemble CHANGELOG x.y.z. Pushes via an installation token from a dedicated GitHub App so no human bypass is needed. Variant 1 also tags and creates the Release here; Variant 2 stops after the push.- Production build + deploy — runs on
push: branches: [main]when the head commit is an assembly commit (title starts withchore: assemble CHANGELOG). Builds with the production configuration and publishes to the production environment.
Branch-protection ruleset gates:
required_approving_review_count: 0require_code_owner_review: true(CODEOWNERS decides per-path approval requirements)required_review_thread_resolution: truerequired_status_checks: the merge-eligibility gate, plus whatever build checks the repository requiresstrict_required_status_checks_policy: false(the queue supplies up-to-date check results)merge_queuerule enabled withgrouping_strategy: ALLGREEN- Bypass list contains only the App (no human bypass; emergency fixes still go through PR)
arda-frontend-app is the exception to the review row: it runs the count-based policy (required_approving_review_count: 1, require_code_owner_review: false) because it has no CODEOWNERS file. The two are interchangeable as far as the queue is concerned.
CODEOWNERS is the review policy, and it fails open
Section titled “CODEOWNERS is the review policy, and it fails open”With required_approving_review_count: 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 even 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, 2026-08-06. The same pull request under the same rulesets, differing only in whether the owning team had repository access:
| 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. repos/{owner}/{repo}/codeowners/errors is the only surface that reports it, which is why the merge-eligibility gate queries it on every run. Note that CODEOWNERS requires direct repository access — access inherited through a parent team does not count.
Pipeline diagram
Section titled “Pipeline diagram”The diagram traces a Variant 1 PR end-to-end across four swimlanes: the author opens a PR, the Pre-Queue stage runs the required checks and merge-eligibility decisions and ends with a preview-site publish, the Merge queue re-runs the gates and merges with the repo’s configured merge method, the Post-Merge lane shows the two successive push events (merge commit triggers changelog-assembly; assembly commit triggers production build + deploy), and the Org repo lane shows the cross-repo deploy. It depicts documentation specifically; review-required-gate is that repository’s own addition.
Variant 2 — the post-merge handoff
Section titled “Variant 2 — the post-merge handoff”Everything up to the merge is the same. What changes is the pair of pushes to main: the merge commit triggers assembly and nothing else, and the assembly commit is what triggers the build. The sequence below is the whole of the difference — note that qualify-build reads the version out of the file assembly wrote moments earlier, and that tag and Release are cut by the build, not by assembly.
The release-engineering identity: arda-changelog-bot
Section titled “The release-engineering identity: arda-changelog-bot”A GitHub App owned by the Arda-cards organisation. Acts as the sole bypass actor on each adopting repository’s branch-protection ruleset, so post-merge assembly commits push directly to main without granting any human bypass.
Permissions: Contents: write, Pull requests: read, Metadata: read. Webhook inactive (the App is an identity, not an event handler).
Credentials are exposed via two org-level Actions secrets, CHANGELOG_BOT_APP_ID and CHANGELOG_BOT_PRIVATE_KEY, scoped to the repositories that use them. Workflows mint short-lived (1-hour) installation tokens with actions/create-github-app-token@v2.
The private-key backup lives in 1Password vault Arda-SystemsOAM. For App rotation and operational details, see the repo-local knowledge-base/arda-changelog-bot.md notes in any adopting repository.
The reference implementation: cicd-testbed
Section titled “The reference implementation: cicd-testbed”Arda-cards/cicd-testbed is a working, minimal implementation of the model, with synthetic checks in place of a real build. Read it before adopting the model in a new repository: it is the shortest complete example, and unlike a production repository it can be broken freely.
Two things it is good for:
- Copying. The gate workflow, the entry-resolution and assembly scripts, the CODEOWNERS shape, and the PR template are all there in the form a new adopter wants them.
- Settling platform questions. Its
gateworkflow reads pass/fail and duration from a file (.testbed/a→pass 0), so a merge-queue or ruleset experiment costs seconds and its outcome is chosen rather than coaxed. Itscontextworkflow dumps the raw event payload forpull_request,merge_group, andpushside by side.
Its README carries a Findings table — one row per question settled, with the run that settled it. Several of this page’s non-obvious claims come from there. Notable answers, all measured 2026-08-06 and 2026-08-07:
| Question | Answer |
|---|---|
What names the destination branch on a merge_group event? | github.event.merge_group.base_ref, as a full ref (refs/heads/main). github.base_ref is empty and github.ref_name is the queue branch, so neither is usable. Strip refs/heads/ before use. |
| Does a ruleset apply to the merge-queue ref? | No. gh ruleset check reports 0 rules apply for gh-readonly-queue/main/pr-… and 5 rules apply for main in the same run. A probe that uses ref_name on a queued build concludes the branch is unprotected. |
| Does converting a queued PR back to draft eject it? | No. It stayed at position 1, ran every check, and merged with isDraft: true. Failing a required check is the eviction mechanism; drafting is not. |
| Can a bypass actor waive code-owner review? | No. enqueuePullRequest refuses a PR awaiting code-owner review regardless of the caller’s bypass mode, and a direct merge is refused by the merge-queue rule of any ruleset the actor does not also bypass. |
Do two rulesets’ pull_request rules compose? | Yes, to the stricter of the two, with bypass evaluated per ruleset. |
If you settle a new question about the platform while adopting the model, add a row.
Per-repository variance
Section titled “Per-repository variance”The model is intentionally uniform. Beyond the variant split, three settings can legitimately vary per repo: the merge method, the review policy (ownership-based or count-based), and the queue parameters. Other variations are accidents of history and will normalise over time.
Arda-cards/documentation
Section titled “Arda-cards/documentation”- Variant: 1. Status: live (canonical implementation of Variant 1).
- Merge method:
MERGE— author commit history is preserved onmain. - CODEOWNERS:
* @Arda-cards/engineeringwith an exception for/src/content/docs/roadmap/(unowned, no review required). Roadmap edits can be merged by the author once checks pass. - Required checks:
changelog-check,review-required-gate. - Queue parameters:
max_entries_to_build: 2,max_entries_to_merge: 3,check_response_timeout_minutes: 15. - Bypass list:
arda-changelog-botApp only. No human bypass. - Production deploy: cross-repo dispatch to
Arda-cards/Arda-cards.github.io, which publishes to the production site athttps://arda-cards.github.io/. Preview site is the documentation repo’s own Pages athttps://arda-cards.github.io/documentation/.
Arda-cards/arda-frontend-app
Section titled “Arda-cards/arda-frontend-app”- Variant: 1. Status: partial — uses merge queue + PR-body changelogs + post-merge assembly today, but with a different bot identity (a personal-access token,
CHANGELOG_ASSEMBLY_TOKEN) and a broader bypass list. Full alignment is tracked as PDEV-474. - Merge method:
SQUASH— by repo convention; not changed by the migration. - CODEOWNERS: none (verified 2026-08-04 at root,
.github/, anddocs/). Runs the count-based review policy —required_approving_review_count: 1,require_code_owner_review: false— rather than the ownership-based one. - Required checks:
lint,build,unit-tests-coverage,changelog-check,e2e. - Queue parameters:
max_entries_to_build: 3,max_entries_to_merge: 5,check_response_timeout_minutes: 30. - Bypass list (current):
RepositoryRole 5(write), modealways. Will be replaced with thearda-changelog-botApp only when PDEV-474 lands. - Production deploy: handled by the repo’s own
deploy.yamlworkflow (no cross-repo dispatch).
Arda-cards/operations
Section titled “Arda-cards/operations”- Variant: 2. Status: in migration (PDEV-1410). Pilot for the Variant 2 machinery.
- Merge method:
MERGE. - CODEOWNERS:
* @Arda-cards/engineering @systems-arda, flat, with no unowned subtree. Owning every path is also what stops a pull request rewriting the gate that is gating it — workflows run from the head branch, so a PR editing.github/workflows/runs the edited version against itself. - Required checks:
build,merge-eligibility. - Artifacts: Docker image + Helm chart to GHCR. Deploys
dev→stage→demo→prodatmax-parallel: 1, every environment pastdevbehind an approval gate. - Bypass list (target):
arda-changelog-botApp only; the existingOrganizationAdminandRepositoryRole 5bypasses are removed at cutover.
Arda-cards/common-module
Section titled “Arda-cards/common-module”- Variant: 2. Status: planned (PDEV-1411).
- Artifacts: Maven jar to GitHub Packages. No deploy chain, which is what makes it the safest place to prove the machinery — a failed experiment costs a version number.
Arda-cards/infrastructure
Section titled “Arda-cards/infrastructure”- Variant: 2. Status: planned (PDEV-1412), cutting over last because its deploy matrix reaches production.
- Difference from the other two: calls
qualify-build-action@v2directly fromci.yamlrather than throughgradle-build-pipeline-action, and its qualification check is namedvalidate-release. Same changelog-as-version-source, same ordering. - Feature builds are excluded — an unprotected branch there publishes nothing, because there is no registry artifact for it to publish.
Summary of differences
Section titled “Summary of differences”| Setting | documentation | arda-frontend-app | operations | common-module | infrastructure |
|---|---|---|---|---|---|
| Variant | 1 | 1 | 2 | 2 | 2 |
merge_method | MERGE | SQUASH | MERGE | MERGE | MERGE |
| Review policy | ownership | count | ownership | ownership | ownership |
| Gate check name | changelog-check | changelog-check | merge-eligibility | merge-eligibility | merge-eligibility |
| Assembly cuts the tag | yes | yes | no | no | no |
| Feature-build publishing | n/a | n/a | yes | yes | no |
| Production deploy path | cross-repo dispatch | in-repo workflow | Helm to EKS | n/a (library) | amm.sh per partition |
Operating the model
Section titled “Operating the model”Authoring a PR
Section titled “Authoring a PR”Two routes carry a changelog entry, and exactly one must be used.
- PR body — a
## CHANGELOGsection in the PR description. The repo’s PR template pre-fills it with category placeholders. Available in both variants, and the only route in Variant 1. - Changelog file — a single Markdown file under
.changelog/on the branch, named by its author. Variant 2 only. Assembly consumes and deletes it in the assembly commit, so nothing stale can survive ontomain.
Both present is an error rather than a precedence question. Pick the highest-priority category that applies (major > minor > patch); the category is what decides the version bump.
Do not edit CHANGELOG.md. If you accidentally do, the gate rejects the PR; remove the edit (or add the manual-changelog label if you genuinely need a hand-edit).
Amending a changelog entry
Section titled “Amending a changelog entry”Body route: edit the description, or post a comment with an updated ## CHANGELOG block. The last one found wins, and only comments from the author or an assignee count — anyone may comment on a PR, but only those accountable for the change may rewrite its release note.
File route: edit the file.
The gate reads the current state whenever it runs, but the two variants differ over whether an amendment makes it run. Variant 1 triggers on pull_request: [edited] and issue_comment, so amending re-runs the check. Variant 2 triggers on pushes and label changes only, so after amending without pushing you must re-run the check by hand. The issue_comment trigger is not a free addition — that event runs the workflow from the default branch, which for a file-route entry would read .changelog/ off main rather than off the branch under test.
Forcing a human review
Section titled “Forcing a human review”documentation only. Add the REVIEW-REQUIRED label; review-required-gate fails until a human approves. Once approved the gate stays passing on subsequent pushes — approvals are not dismissed by new commits, and the repo trusts authors to flag material-scope changes.
There is no waiver in the other direction. A bypass actor cannot skip code-owner review: enqueuePullRequest refuses a PR awaiting review regardless of who asks, and a direct merge is refused by the merge-queue rule. That was measured rather than assumed.
Merging concurrently
Section titled “Merging concurrently”Enable auto-merge on each PR. The queue groups passing entries into ALLGREEN batches; the batch merges together. If two PRs would have conflicted on CHANGELOG.md under the old model, they no longer can — PRs never touch CHANGELOG.md, so the queue can batch them.
Publishing a prerelease from a branch (Variant 2)
Section titled “Publishing a prerelease from a branch (Variant 2)”operations and common-module only. Add feature-build: frontmatter to the branch’s changelog file, naming the suffix explicitly:
---feature-build: jmpicnic-1408---
### Added- Item scanning by GTIN.Every push to the branch then publishes a prerelease — 6.15.0-jmpicnic-1408-<run> for an Added entry off 6.14.0 — and operations deploys it to dev and no further, structurally rather than by approval gate. A marked branch cannot merge: the gate fails while the marker is present. Removing the frontmatter makes it mergeable.
The marker lives in the file rather than on a PR label because a branch being built this way need not have a PR at all.
Hand-editing CHANGELOG.md (rare)
Section titled “Hand-editing CHANGELOG.md (rare)”Apply the manual-changelog label to the PR. The gate allows the edit, and assembly either honours a top-of-file release block or skips entirely for any other edit shape.
This is an emergency hatch, not a workflow. Reaching for it re-introduces the exact CHANGELOG.md edit the model exists to remove, and a PR carrying it can conflict with any other PR in flight. Legitimate uses are narrow: correcting a historical entry, or a release whose notes the assembler genuinely cannot produce. “The PR body was awkward to edit” is not one — amend by posting a ## CHANGELOG comment instead. A rising usage rate is a signal that the assembler has a gap worth fixing rather than routing around.
Emergency fixes to main
Section titled “Emergency fixes to main”Open a PR. The gates must still pass; the only acceleration available is a fast-tracked engineering review. There is no human bypass.
Known gaps in the Variant 1 implementations
Section titled “Known gaps in the Variant 1 implementations”Two defects are live in documentation and arda-frontend-app today, tracked as PDEV-1455. Variant 2 is built without them, so the fix is a port rather than a design.
- The gate reports success without evaluating.
changelog-checkis draft-gated and auto-passes onmerge_group. A skipped job publishes a successful check run. Combined with the measured fact that a queued PR converted to draft is not ejected, a PR can merge having never had its entry validated. - A failed assembly run silently loses its entry. Assembly resolves the single PR behind the triggering merge commit, so nothing later re-collects a failed run’s work. On 2026-07-23 in
documentation, PRs #142, #137, and #135 merged within 80 seconds; two of the three assembly runs failed and those entries are absent fromCHANGELOG.md.
Variant 2 closes both: the gate is not draft-gated and re-evaluates in the queue, and assembly’s unit of work is every merge since the last release tag rather than the triggering commit — which makes a failed run harmless, since the next one covers a superset.
Further reading
Section titled “Further reading”- Backend PR Process — contributor how-to for the Variant 2 repositories.
- Frontend PR Process — contributor how-to for
arda-frontend-app. - Queued CI/CD Adoption — the Variant 2 design, requirements, and decision log.
Arda-cards/cicd-testbed— the reference implementation and its findings table.knowledge-base/pr-body-changelog.md(repo-local) — detailed authoring rules.knowledge-base/arda-changelog-bot.md(repo-local) — App identity, secrets, rotation.
Copyright: © Arda Systems 2025-2026, All rights reserved