Skip to content

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.

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.md file 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.

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.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 by qualify-build-action
What assembly doesComputes the version, prepends the release block, creates the git tag and the GitHub ReleasePrepends the release block and stops
What the following build doesBuilds and deploys the artifactReads the version back out of the file assembly just wrote, then publishes, tags, and creates the Release
Version of recordThe assembly commitCHANGELOG.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.

Four workflow groups participate in every adopting repository. Names vary; roles do not.

  1. 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 as changelog-check in Variant 1 and as merge-eligibility in Variant 2.
  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.
  3. 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.
  4. changelog-assembly — runs on push: 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 to CHANGELOG.md, validates with clq, and commits with the title chore: 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.
  5. Production build + deploy — runs on push: branches: [main] when the head commit is an assembly commit (title starts with chore: assemble CHANGELOG ). Builds with the production configuration and publishes to the production environment.

Branch-protection ruleset gates:

  • required_approving_review_count: 0
  • require_code_owner_review: true (CODEOWNERS decides per-path approval requirements)
  • required_review_thread_resolution: true
  • required_status_checks: the merge-eligibility gate, plus whatever build checks the repository requires
  • strict_required_status_checks_policy: false (the queue supplies up-to-date check results)
  • merge_queue rule enabled with grouping_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 stateResult
team lacked repository accessCLEAN, zero reviews, mergeable
team granted push accessBLOCKED, 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.

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.

PlantUML diagram

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.

PlantUML diagram

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 gate workflow reads pass/fail and duration from a file (.testbed/apass 0), so a merge-queue or ruleset experiment costs seconds and its outcome is chosen rather than coaxed. Its context workflow dumps the raw event payload for pull_request, merge_group, and push side 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:

QuestionAnswer
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.

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.

  • Variant: 1. Status: live (canonical implementation of Variant 1).
  • Merge method: MERGE — author commit history is preserved on main.
  • CODEOWNERS: * @Arda-cards/engineering with 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-bot App only. No human bypass.
  • Production deploy: cross-repo dispatch to Arda-cards/Arda-cards.github.io, which publishes to the production site at https://arda-cards.github.io/. Preview site is the documentation repo’s own Pages at https://arda-cards.github.io/documentation/.
  • 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/, and docs/). 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), mode always. Will be replaced with the arda-changelog-bot App only when PDEV-474 lands.
  • Production deploy: handled by the repo’s own deploy.yaml workflow (no cross-repo dispatch).
  • 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 devstagedemoprod at max-parallel: 1, every environment past dev behind an approval gate.
  • Bypass list (target): arda-changelog-bot App only; the existing OrganizationAdmin and RepositoryRole 5 bypasses are removed at cutover.
  • 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.
  • Variant: 2. Status: planned (PDEV-1412), cutting over last because its deploy matrix reaches production.
  • Difference from the other two: calls qualify-build-action@v2 directly from ci.yaml rather than through gradle-build-pipeline-action, and its qualification check is named validate-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.
Settingdocumentationarda-frontend-appoperationscommon-moduleinfrastructure
Variant11222
merge_methodMERGESQUASHMERGEMERGEMERGE
Review policyownershipcountownershipownershipownership
Gate check namechangelog-checkchangelog-checkmerge-eligibilitymerge-eligibilitymerge-eligibility
Assembly cuts the tagyesyesnonono
Feature-build publishingn/an/ayesyesno
Production deploy pathcross-repo dispatchin-repo workflowHelm to EKSn/a (library)amm.sh per partition

Two routes carry a changelog entry, and exactly one must be used.

  • PR body — a ## CHANGELOG section 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 onto main.

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).

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.

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.

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.

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.

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.

  1. The gate reports success without evaluating. changelog-check is draft-gated and auto-passes on merge_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.
  2. 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 from CHANGELOG.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.

  • 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.