Design: Queued CI/CD Adoption
Design: Queued CI/CD Adoption
Section titled “Design: Queued CI/CD Adoption”Audience: backend and platform engineers who merge to these repositories; whoever operates the release pipeline. Reading time: ~20 min for the full document, ~4 min for sections 1–5.
operations,common-module, andinfrastructuremove to a GitHub merge queue, with changelog entries authored either in the PR body or as a per-PR file on the branch — never both.- The central adaptation: in these repositories
CHANGELOG.mdis the build-time version source, so assembly must run before the build.changelog-assemblywrites the release block and nothing else (DQ-001); the existing build pipeline fires on the resulting assembly commit and keeps owning version, tag, publish, and Release. - Assembly covers every merge since the previous assembly commit, producing one release per run rather than one per PR — which makes it self-healing against the race that has already lost changelog entries in
documentation. - Three repositories gain four workflows, a PR template, a CODEOWNERS file owning every path, and one branch-protection ruleset; two shared actions gain
merge_groupawareness and an input that lets a repository turn off the introduces-one-new-version check.
This design implements the requirements, which are the authoritative record of what is settled.
Glossary
Section titled “Glossary”| Term | Meaning |
|---|---|
| Assembly commit | A commit on main whose title starts with chore: assemble CHANGELOG , pushed by arda-changelog-bot. The signal that a release block now exists in CHANGELOG.md. |
| Changelog entry | The categories and bullets describing one PR’s change, carried by exactly one of two routes: a ## CHANGELOG section in the PR body or an author/assignee comment, or a per-PR changelog file on the branch. |
| Marked branch | A branch whose changelog file carries feature-build: frontmatter. Publishes a prerelease on every push and cannot merge while marked. Narrower than qualify-build-action’s internal sense of “feature branch”, which means any unprotected ref. |
| Queue build | The build that runs against the merge queue’s synthetic batch head, on a merge_group event. Never publishes. |
| Release build | The build that runs on a push of an assembly commit to main. Publishes, tags, and creates the GitHub Release. |
| ALLGREEN grouping | Merge-queue strategy that merges a batch only when every entry’s checks pass. |
1. Position in Arda’s Architecture
Section titled “1. Position in Arda’s Architecture”This design sits outside the functional decomposition — it changes how artifacts are produced, not what they contain. The Viewpoint Mapping rows for these repositories are the frame of reference.
| Viewpoint | This design’s position |
|---|---|
| Product | No end-user-visible change. The affected persona is the Arda engineer merging to these repositories; the served capability is concurrent, non-conflicting delivery. |
| Functional | n/a as a domain concern — no Domain, Module, Service, or Endpoint changes. The design operates entirely on the build-and-release surface of three repositories. |
| Artifacts | Governs the production of the operations Docker image + Helm chart, the common-module Maven jar, and infrastructure’s CDK deploy. Coordinates, versions, and Release shapes are explicitly unchanged; only the commit that triggers their production moves. |
| Runtime | Indirect. operations deploys to Alpha002 (dev/stage) and Alpha001 (demo/prod); infrastructure runs amm.sh against all four partitions. Both chains keep their ordering and gating; they fire one commit later than today. |
| OAM | New failure surface (assembly can fail after a merge has landed, though the range makes it self-healing), new operational controls (manual-changelog, feature-build: frontmatter), and one App identity on three repositories. Detail in §11. |
| Technology | Infrastructure layer: GitHub Actions, GitHub rulesets, denisa/clq-action, actions/create-github-app-token. No application-stack change. |
2. Module-Interaction Mechanics
Section titled “2. Module-Interaction Mechanics”| Mechanism | Added by this design | Consumed by this design |
|---|---|---|
| API Endpoints / Services | None. | GitHub REST API — pulls, issue comments, rulesets, team memberships — read through gh and actions/github-script. |
| References | None. | None. |
| Data Types | Four textual contracts, each load-bearing between a producer and a consumer: the assembly-commit title (chore: assemble CHANGELOG <semver>), between assembly and each build workflow (DQ-002); the changelog file shape, between authors and assembly; the feature-build: frontmatter key, between authors and qualify-build-action. | The ## [x.y.z] - YYYY-MM-DD release-heading format, already shared between clq-action and every CHANGELOG.md in the workspace. |
| Bindings | Installation tokens for one App — arda-changelog-bot — on three new repositories. | The four .github/clq/changemap.json files, verified identical across documentation, operations, common-module, and infrastructure — so version-bump semantics port with no change. |
3. Overview
Section titled “3. Overview”Every merge to main in these three repositories edits CHANGELOG.md. Over the 90 days to 2026-08-04 that was 50 of 50 first-parent commits in operations and 29 of 29 in common-module — not an incidental rate but a structural one, because the changelog is where the version lives and a release is an edit to that file. Two open PRs therefore conflict by construction, and the second to merge always rebases. That is the cost the queued model removes: PRs never touch CHANGELOG.md, so the queue can batch them.
The obstacle is that these repositories are not shaped like the two that already run the model. In documentation and arda-frontend-app, CHANGELOG.md is not a build input — assembly computes the version after the merge and the production build is gated on the resulting commit. Here, gradle.properties carries version=0.0.0 and qualify-build-action derives the version, git tag, Docker image tag, Helm chart version, Maven coordinates, and Release body from the changelog at build time, on the merge commit. Stop editing CHANGELOG.md in PRs and the merge commit still shows the previous release: the tag already exists and the publish fails or silently does not happen.
This design inverts the order. changelog-assembly runs on the merge commit and writes the release block — and only the release block (DQ-001). The build pipeline then fires on the assembly commit, reads the version it finds, and publishes exactly as it does today. CHANGELOG.md stays the single source of version truth with one writer and one reader. Scope is deliberately narrow: what gets published, how it is versioned, and where it deploys are all unchanged. Rollout runs common-module → operations → infrastructure (DQ-005), each step validating the shared actions against a larger blast radius than the last.
4. Decision Summary
Section titled “4. Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | Who owns version, tag, publish, and Release? | Assembly writes CHANGELOG.md only |
| DQ-002 | Who gates publishing on the assembly commit? | The calling workflow |
| DQ-003 | How deep does the merge-queue gate run? | Full build per batch |
| DQ-004 | What replaces feature-branch publishing? | A Feature-build: directive in the PR-body section |
| DQ-005 | Rollout order across the three repositories? | common-module → operations → infrastructure |
Full rationale and rejected alternatives in decision-log.md.
5. Constraints
Section titled “5. Constraints”CHANGELOG.mdmust remain the version source.qualify-build-actionreads it throughclq-action; changing that would move the artifact-publishing path onto untested ground. The design changes who writes the file and when, not what reads it.- Published artifact shapes must be indistinguishable from today’s. Version, tag, image tag, chart version, Maven coordinates, Release title and body.
qualify-build-actionsupports onlypushandpull_request. A merge queue emitsmerge_group; the action must classify it before any of the three repositories can enable a queue.operationsis pinned togradle-build-pipeline-action@v1.3.7because@v1.3.8stopped classifying a push tomainas a release. That pin and this design are the same decision; the design must resolve it rather than work around it.infrastructurereaches production. Itsdeploymatrix runsamm.shagainstAlpha001/prod. It cuts over last, after two proven migrations.- No human bypass. The target ruleset has
arda-changelog-botas sole bypass actor, so assembly must push through an App installation token —GITHUB_TOKENcannot.
6. Quality Attributes
Section titled “6. Quality Attributes”| Attribute | Target | Satisfied by |
|---|---|---|
| Release fidelity | Byte-identical artifact coordinates and Release shape pre/post migration | DQ-001 — the publish path is untouched |
| Merge concurrency | Two independent PRs land in one ALLGREEN batch | merge_queue rule + PRs never touching CHANGELOG.md |
| Blast-radius containment | A pilot failure costs a version number, not an outage | DQ-005 rollout order |
| Auditability | Every release block on main traceable to one PR body | Assembly is the sole writer of CHANGELOG.md |
| Recoverability | A failed assembly is re-runnable without a revert | Idempotent assembly; see §9.7 |
8. Structural Design
Section titled “8. Structural Design”The component diagram below shows the workflow groups of the queued model as they land in each target repository, the two shared actions they call, the two bypass identities, and the repository state they read and write. Green is new, khaki is modified, grey unchanged. The load-bearing relationship is the pair of arrows into qualify-build-action: CHANGELOG.md and changemap.json are its inputs, and changelog-assembly is what now writes the first of them.
Branch-protection structure
Section titled “Branch-protection structure”The protected branch carries one ruleset, with arda-changelog-bot as its only bypass actor.
| Rule | Parameters |
|---|---|
pull_request | required_approving_review_count: 0, require_code_owner_review: true, required_review_thread_resolution: true |
required_status_checks | the gates; strict_required_status_checks_policy: false |
merge_queue | grouping_strategy: ALLGREEN |
deletion, non_fast_forward | — |
An earlier version split this into Integrity and Review, so a ReviewOverride App could bypass review without also bypassing the build and the queue. That split served REQ-REV-004 alone, which is withdrawn — the waiver cannot be built, because enqueuePullRequest refuses a pull request awaiting code-owner review no matter who asks. With nothing to bypass narrowly, one ruleset expresses the policy and there is no second bypass list to keep correct.
Two things learned building the split constrain any future attempt. required_review_thread_resolution and require_code_owner_review are parameters of the same pull_request rule, so review cannot be separated from thread resolution across rulesets. And bypass is properly scoped per ruleset — an actor bypassing review was still stopped by the merge-queue rule in another one, so whatever replaces the waiver cannot accidentally acquire the power to skip the queue.
Ownership covers every path, with no unowned subtree. Beyond expressing the policy, this is what stops a pull request rewriting the gate that is gating it: workflows run from the head branch, so a pull request editing .github/workflows/ runs the edited version against itself. Measured — a pull request that neutered its own gate and carried no changelog entry was reported green by it. Owning every path means such a change needs a code-owner approval, which no mechanism can waive.
Key Elements
Section titled “Key Elements”merge-eligibility (new)
Section titled “merge-eligibility (new)”-
Role in the diagram: Pre-queue gates.
-
Responsibility: establish everything that must hold before a PR may merge — CODEOWNERS resolves; the PR is neither a draft nor a marked feature build; it carries a changelog entry by exactly one of the two routes (REQ-AUTH-007), valid against
clq, without editingCHANGELOG.md. -
Public surface: one required status check named
merge-eligibility, onpull_requestandmerge_group. -
Members of note: one workflow rather than one per assertion, consolidated during review of the pilot. Three gates shared a trigger that must not drift and duplicated the merge-queue PR resolution between two of them — the dangerous kind of duplication, since fixing one copy and not the other leaves a gate reporting success without evaluating queued entries, the precise hole REQ-GATE-006 exists to close. Three required check names were also three strings that must exist on
mainand match the ruleset. The logic lives in.github/scripts/as shell, leaving the workflow a thin trigger.Not draft-gated, and it re-evaluates on
merge_grouprather than auto-passing — thedocumentationoriginal does both and carries that exposure today. Two assertions exist because GitHub provides neither: a queued PR converted to draft stays queued and merges (failing a required check is also what ejects it), and an unresolvable CODEOWNERS makesrequire_code_owner_reviewvacuous rather than stricter.
changelog-assembly (new)
Section titled “changelog-assembly (new)”- Role in the diagram: Post-merge.
- Responsibility: on a push to
mainthat is not itself an assembly commit, read the changelog entries for every merge commit since the previous assembly commit, compute one semver from their combined categories, prepend one release block toCHANGELOG.md, validate withclq, remove the changelog files it consumed, and push the result asarda-changelog-bot. - Public surface: none — it is triggered by
pushand observed through the assembly commit it produces. - Members of note: the element that diverges most from the
documentationoriginal.- Tag-creation, GitHub-Release, and push-tag steps are removed; the workflow ends after the commit push (DQ-001).
- The unit of work is the range, not the triggering commit (REQ-ASM-004). This makes the workflow self-healing: a failed run leaves its work to the next one, so no entry depends on any single run succeeding. It also removes the race that PDEV-694 describes, rather than serialising around it, which means
cancel-in-progress: truebecomes correct — a later run always covers a superset of an earlier one. - Entries arrive by two routes and are merged per category into a single block.
- Consumed changelog files are deleted in the same commit, which is what keeps them off the tip of
main(REQ-AUTH-006). This is the one respect in which assembly touches a path other thanCHANGELOG.md. - YAML frontmatter is stripped before composing, and that needs a test rather than care:
---is both a frontmatter delimiter and a Markdown horizontal rule, and this system has already leaked an internal delimiter into a published tagged release.
- Design decisions referenced: DQ-001
queue build (modified)
Section titled “queue build (modified)”- Role in the diagram: Merge queue.
- Responsibility: run the repository’s full build against the merge queue’s synthetic batch head, publishing nothing.
- Members of note: a new
merge_grouptrigger on the existing build job. Requiresqualify-build-actionto classifymerge_groupas a test build. - Design decisions referenced: DQ-003
release build (modified)
Section titled “release build (modified)”- Role in the diagram: Post-merge.
- Responsibility: on a push of an assembly commit to
main, qualify, build, publish, tag, and create the GitHub Release — exactly as the build job does today, but one commit later. - Members of note: gated by a job-level condition on the head-commit title. Everything downstream of the condition is unchanged.
- Design decisions referenced: DQ-002
qualify-build-action (modified)
Section titled “qualify-build-action (modified)”-
Role in the diagram: Shared actions.
-
Responsibility: classify the event and ref, read the changelog version, and decide
kind(test/publish),version,tag,target,trigger. -
Public surface: unchanged outputs. One new input classification:
merge_group→trigger = merge_group,kind = test, noversion, notag. -
Members of note: the change is small and was verified by spike on 2026-08-04. The action is a single composite
action.yaml; itsqualify-buildstep is acase "${{ github.event_name }}"overpull_request|pushwith an explicit*) error "Unsupported event"fallthrough, somerge_groupsupport is one added case branch.The
define-targetstep probesgh ruleset check --repo <repo> "${{ github.base_ref || github.ref_name }}"and greps the output for the configured workflow name. Three facts established by spike:- On a
merge_groupeventgithub.base_refis empty andgithub.ref_nameis the queue ref, e.g.gh-readonly-queue/main/pr-156-<sha>. gh ruleset checkagainst that queue ref returns0 rules apply, so the probe yieldstarget = feature.- The probe requires a bare branch name —
refs/heads/mainalso returns0 rules apply, sogithub.event.merge_group.base_refcannot be passed through unmodified.
Fact 2 is harmless rather than a defect: nothing on the
merge_grouppath consumestarget, because the queue build never publishes. The minimal correct change is therefore the case branch alone, leavingdefine-targetuntouched. Rewriting the probe to resolve the real base ref would be strictly more code for no behavioral gain.The action gains no knowledge of the assembly-commit convention (DQ-002).
- On a
gradle-build-pipeline-action (modified)
Section titled “gradle-build-pipeline-action (modified)”- Role in the diagram: Shared actions.
- Responsibility: checkout, build, test, publish, tag, and release a Gradle project, delegating the publish/test decision to
qualify-build-action. - Members of note: inherits the new classification with no logic change of its own. The
operations@v1.3.7pin retires here — once the publish fires on the assembly commit,@v1.3.8’s behavior is the correct one. Verified by spike:v1.3.7...v1.3.8isaction.yaml -122/+29plus anactions/checkout6→7 bump, and its changelog records only “Use shared logic fromArda-cards/qualify-build-action2”. Retiring the pin is a version bump, not a rewrite. - Publish decoupling, verified:
gradle-build.sh(89 lines) branches solely on theKINDandVERSIONenvironment variables and reads nothing from the event payload or the trigger classification. This is what makes Constraint 2 achievable — moving when the publish fires cannot change what it publishes.
arda-changelog-bot (new to these repositories)
Section titled “arda-changelog-bot (new to these repositories)”- Role in the diagram: Bypass identities.
- Responsibility: push assembly commits directly to
mainwithout any human holding bypass. - Members of note: App ID
3683113; permissionsContents: write,Pull requests: read,Metadata: read; credentials in org secretsCHANGELOG_BOT_APP_ID/CHANGELOG_BOT_PRIVATE_KEY. Bypasses both rulesets atalwaysmode, because both carry apull_requestrule and it pushes directly rather than through a PR. The three-step extension procedure — install, grant secret access, add to ruleset bypass — is documented indocumentation/knowledge-base/arda-changelog-bot.md.
9. Behavioral Design
Section titled “9. Behavioral Design”Behaviors are grouped by pipeline stage. Each cross-links to the Key Element that owns it and maps to entries in Behavior Verification.
9.1 Pre-queue gates
Section titled “9.1 Pre-queue gates”Owner: merge-eligibility.
- Changelog-edit rejection. A PR whose diff modifies
CHANGELOG.mdfailsmerge-eligibilitywith an explanatory error, unless it carries themanual-changeloglabel — an emergency hatch, not a supported route (§11). - Exactly one route. An entry arrives either in the PR body or an author/assignee comment, or as a changelog file on the branch — never both, never neither (REQ-AUTH-007). Both present is an error rather than a precedence question: ambiguity about which entry is authoritative is treated as a defect, not resolved silently.
- Entry validation. At least one bullet under at least one category valid per the repository’s
.github/clq/changemap.json, which is identical across all four repositories. The entry is not a complete changelog and cannot be validated byclqstandalone; the gate checks categories, andclqvalidates the assembled file after the release block is written. - The gate evaluates on drafts. It is not draft-gated: a skipped job reports success, which is indistinguishable from having passed (REQ-GATE-006). Running on drafts is also better for the author, who learns the entry is missing while still working.
- Filename-collision warning. Where another open PR claims the same changelog-file path,
merge-eligibilitysurfaces it. Advisory only — nothing re-runs an open PR’s checks when a later PR claims its name — with the queue as the authoritative backstop, since two PRs adding the same path cannot batch together. - Draft refusal. The gate fails when a queued PR is a draft, which is also what ejects it — GitHub keeps a drafted entry and merges it otherwise.
- Comment-driven amendment. For a body-route PR, posting a new
## CHANGELOGblock as a comment supersedes the body — last one wins. For a file-route PR, amendment is an edit to the file; a stray comment carrying an entry would trip the both-present rejection. - Owner resolution. The gate fails when CODEOWNERS does not resolve, because an unresolvable owner turns code-owner review off silently rather than making it stricter (REQ-REV-002).
9.2 Queue
Section titled “9.2 Queue”Owner: queue build.
- Batch formation. The queue forms ALLGREEN batches; entries merge together only when every entry’s checks pass.
- Full build on the batch head. The build job re-runs against the synthetic merge commit, catching pairs of PRs that compile independently but not together (DQ-003).
- Non-publishing qualification.
qualify-build-actionclassifiesmerge_groupas a test build, so no tag is cut and nothing is published from the queue. - Gate re-evaluation. Every gate re-evaluates against the queued commit rather than auto-passing, resolving the PR number from the
gh-readonly-queue/main/pr-N-<sha>ref.
9.3 Assembly
Section titled “9.3 Assembly”Owner: changelog-assembly.
- Self-exclusion. The workflow skips when the head commit title already starts with
chore: assemble CHANGELOG, so its own push does not retrigger it. - Range determination. The unit of work is every merge commit since the previous assembly commit, not the commit that triggered the run (REQ-ASM-004). Whether the queue lands three PRs as one batch or as three merges seconds apart, the run that wins covers all of them and produces one release.
- Mode selection. Three modes carried over from the reference implementation:
normal(merge did not touchCHANGELOG.md),hand-edit(merge added a new## [x.y.z] - YYYY-MM-DDheading at the top), andskip(any other edit shape). - Entry extraction. For each PR in the range, the entry is read from whichever route it used: the
## CHANGELOGsection of the body or an author/assignee comment (last one wins), or the changelog file added on its branch. - Version computation. The highest-impact category present across all entries in the range determines the bump —
Changed/Removedmajor,Added/Deprecatedminor, otherwise patch — applied to the version in the current top release heading. - Per-category merge. Where the range covers several PRs, their entries are combined under one heading per category, carrying the bullets from every contributing PR.
- Release-block prepend and validation. The block is spliced above the first existing
## [heading and validated withclq-actionagainstchangemap.json. - Frontmatter stripping. YAML frontmatter is removed before composing, so no directive reaches
CHANGELOG.md.---is both a frontmatter delimiter and a Markdown horizontal rule, and the reference implementation has already leaked an internal delimiter into a published release — this needs a test, not care. - Consumed-file removal. The changelog files the run consumed are deleted in the same commit, which is what keeps them off the tip of
main. - Push as the bot, and stop. The commit is pushed with an
arda-changelog-botinstallation token. No tag is created and no GitHub Release is made — the divergence from the reference implementation that DQ-001 settles.
9.4 Build and publish
Section titled “9.4 Build and publish”Owner: release build, qualify-build-action.
- Assembly-commit gating. The build job runs on a push to
mainonly when the head commit is an assembly commit. A merge commit produces no build; it produces an assembly run, whose commit produces the build (DQ-002). - Version read-back.
qualify-build-actionreads the version from the release heading assembly just wrote, and returnskind = publishwith that version and itsv-prefixed tag. - Publish, tag, release. Unchanged: image and chart to GHCR/Helm OCI for
operations, Maven jar forcommon-module,ncipollo/release-actionforinfrastructure. - Deploy chain.
operationsfans outdev→stage→demo→prodatmax-parallel: 1;infrastructurefans out its four partitions throughamm.yml. Both are downstream of the publish job and inherit its new timing without change.
The activity diagram below traces how a single build workflow classifies every event it can receive after adoption. Four of the leaves carry detail worth stating in prose rather than on the diagram:
pull_requestrunsmerge-eligibilityand aqualify-buildthat returnskind = test.merge_groupis the new classification:qualify-buildtreats it as a test build, so the queue never publishes.- A merge commit on
maindeliberately produces no build.changelog-assemblyruns on that same push event and pushes the assembly commit, which is what triggers the build. - An assembly commit on
mainis the only path that publishes. The test is on the commit title prefixchore: assemble CHANGELOG(DQ-002).
The feature-branch leaf is where DQ-004 lands.
9.5 Feature builds
Section titled “9.5 Feature builds”Owner: qualify-build-action, merge-eligibility.
Terminology. A marked branch is one carrying the feature-build marker. This is narrower than
qualify-build-action’s internal sense of “feature branch”, which means any ref that is not release-protected — that is, every working branch. Only a marked branch publishes and is barred from merging.
Today a feature branch publishes by hand-editing CHANGELOG.md to a major.minor.patch-user-issue version, with the changelog header instructing the author to reverse the edit before merging. The new authoring model removes the place that signal lives, so it moves into the changelog file’s frontmatter (DQ-004).
Scope: operations and common-module only. infrastructure is excluded and keeps its current behavior. Verified 2026-08-06: an unprotected branch there runs build, the per-app synth matrix, synth-corporate, and validate-release; qualify-build-action may classify kind = publish, but nothing consumes it, because publish is gated on trigger == 'push_to_release_branch' and deploy needs publish. There is no registry artifact for it to publish. For common-module the capability exists today only as an undocumented side effect of the shared action being generic; it becomes explicit, and it is worth having on purpose — consumers sometimes need to build against an unreleased library change without cutting a real version.
- Marking. YAML frontmatter in the changelog file carries the suffix explicitly, e.g.
feature-build: jmpicnic-1408. The suffix is stated rather than derived, becausequalify-build-action’s regex requires two alphanumeric segments and deriving it from the filename would couple two conventions. Marking and unmarking are ordinary file edits, so both work at branch creation or later, and whether or not a PR exists (REQ-FEAT-003). - A marked branch requires a changelog file. The body route is unavailable to it, since a marked branch need not have a PR at all — and the file is what gives the version computation its input at push time.
- Every push builds and publishes. This is why the marker must be readable from the checkout alone, and why it cannot be a PR label.
- A marked branch cannot merge. A required check fails while the marker is present, replacing today’s “must, of course, be reversed before merging to main” with an enforced condition. Unmarking makes it mergeable; the prohibition is on the marked state, not the branch.
- Version derivation. The base is computed from the changelog file’s categories against the current
CHANGELOG.mdhead — the same computation assembly performs. AnAddedentry on a branch off6.14.0publishes6.15.0-jmpicnic-1408-<run>. This sorts before6.15.0under semver prerelease ordering, which is correct: it is a prerelease of the release it anticipates. - No residue on
main. Satisfied by construction rather than discipline — the marker lives in the changelog file, a marked branch cannot merge, and the file is consumed and removed by assembly. There is no state that can survive ontomain. - Deployment stops at
dev, structurally. Inoperationsa feature build deploys todevand nothing else; the feature path’s matrix containsdevalone, and the other environments are not reachable from it (REQ-FEAT-008). It deliberately does not rely on environment approval gates, which are shared with the ordinary release path — a future decision to relax approvals for normal deploys would otherwise relax them for feature branches by omission. A relaxation that cannot reach feature branches is one that cannot be made accidentally.
The deploy constraint is not hypothetical. operations’ deploy job is gated only on needs.build.outputs.chart_name, and gradle-build.sh emits chart_name whenever KIND = publish and a chart exists — with no distinction between a release publish and a feature publish. A feature publish therefore satisfies the deploy job’s only condition and fans out to dev, stage, demo, and prod. The path is dormant only because feature publishing has produced three tags in the repository’s entire history; publishing on every push to a marked branch would activate it.
9.6 End-to-end propagation
Section titled “9.6 End-to-end propagation”The sequence below traces one PR from open to deployed. The two events that matter are the pair of pushes to main: the merge commit triggers assembly and nothing else, and the assembly commit triggers the build. Everything to the right of changelog-assembly behaves exactly as it does today.
9.7 Robustness and failure modes
Section titled “9.7 Robustness and failure modes”Owner: changelog-assembly, release build.
-
Assembly failure cannot lose an entry. Because each run covers everything since the previous assembly commit, a failed run leaves its work to the next one. No entry depends on any single run succeeding, and recovery needs no revert and no hand-edit.
This is the failure mode the design exists to close, and it is not hypothetical. On 2026-07-23 in
documentation, PRs #142, #137, and #135 merged within 80 seconds. Assembly ran for each; the runs for #142 and #137 failed and only #135’s succeeded. The entries for #142 and #137 are absent fromCHANGELOG.mdand nobody noticed. The cause is the race PDEV-694 describes — concurrent runs each computing a version against amainthat moves underneath them. Range-based assembly removes the race rather than serialising around it, and subsumes PDEV-694 for these repositories. -
Superseded runs are safe to cancel. A later run always covers a superset of an earlier one, so
cancel-in-progress: trueis correct — the opposite of the reference implementation’s setting, and correct for the opposite reason. -
Version collision. If a tag for the computed version already exists, the build’s
Tag sourcestep fails loudly rather than overwriting — behavior inherited fromgradle-build-pipeline-action. -
Hand-edit path. A
manual-changelogPR that adds a top-of-file release heading is honored: assembly extracts the version from it and pushes an empty marker commit carrying the canonical title, so the assembly-commit gate still fires. Any other edit shape skips assembly entirely, which also means no publish — the safe default. -
Mixed-convention window. Between cutovers,
common-moduleis on the new model whileoperationsstill editsCHANGELOG.mddirectly. The two are independent; the only shared surface is the shared actions, which must keep behaving correctly for a repository that has not yet moved.
10. Behavior Verification
Section titled “10. Behavior Verification”Subsections mirror Behavioral Design. Verification for a CI/CD design is necessarily mostly integration-level: the units under test are workflows, and the only faithful fixture is a real repository. The pilot repository is the primary fixture (DQ-005).
10.1 Pre-queue gates
Section titled “10.1 Pre-queue gates”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-1-01 | Changelog-edit rejection — a PR touching CHANGELOG.md fails | PR on the pilot repo with a one-line changelog edit | Pilot repository |
| BV-1-02 | Same, overridden by the manual-changelog label | BV-1-01’s PR, label applied | Pilot repository |
| BV-1-03 | Entry validation — a PR with neither route fails | PR with the section deleted and no changelog file | Pilot repository |
| BV-1-04 | Comment-driven amendment — a later comment supersedes the body | BV-1-03’s PR plus a comment carrying a valid section | Pilot repository |
| BV-1-05 | Label-gated review — REVIEW-REQUIRED blocks until a human approves | PR with the label, no approval | Pilot repository |
| BV-1-06 | Exactly one route — a PR with both a body section and a changelog file fails | PR carrying both | Pilot repository |
| BV-1-07 | Exactly one route — the file route alone passes | PR with a changelog file and no body section | Pilot repository |
| BV-1-08 | Comment scope — a comment from someone other than the author or an assignee is ignored | Third party posts a ## CHANGELOG block on a file-route PR | Pilot repository |
| BV-1-09 | Gates evaluate on drafts — a draft PR with no entry fails rather than reporting a skipped success | Draft PR, no entry; inspect the check conclusion | Pilot repository |
| BV-1-10 | Filename-collision warning — two open PRs claiming one path are surfaced | Two PRs adding the same changelog-file path | Concurrent-PR pair |
| BV-1-11 | Draft refusal — a queued PR converted to draft is ejected rather than merged | Queue a PR with a slow check, convert to draft while queued | Pilot repository |
| BV-1-12 | Owner resolution — an unresolvable CODEOWNERS fails the gate rather than silently disabling review | Point CODEOWNERS at a team without repository access | Pilot repository |
10.2 Queue
Section titled “10.2 Queue”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-2-01 | Batch formation — two independent PRs land in one ALLGREEN batch | Two PRs touching disjoint files, both auto-merge enabled | Concurrent-PR pair |
| BV-2-02 | Non-publishing qualification — the queue build cuts no tag and publishes nothing | BV-2-01’s batch; inspect tags and registry before the merge lands | Concurrent-PR pair |
| BV-2-03 | Full build on the batch head — a semantically conflicting pair is caught | Two PRs that compile alone but not together | Conflict-pair fixture |
| BV-2-04 | Gate re-evaluation — merge-eligibility runs against the queued commit rather than auto-passing | Any queued entry; inspect the check run | Concurrent-PR pair |
10.3 Assembly
Section titled “10.3 Assembly”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-3-01 | Version computation — each category yields the right bump | Four PRs, one per bump class, merged in sequence | Pilot repository |
| BV-3-02 | Release-block prepend — the block lands above the previous heading and validates with clq | Any merged PR; diff CHANGELOG.md | Pilot repository |
| BV-3-03 | Push as the bot, and stop — assembly creates no tag and no Release | Merged PR; assert no new tag exists after assembly and before the build | Release-shape baseline |
| BV-3-04 | Self-exclusion — the assembly commit does not retrigger assembly | Observe workflow runs on the assembly commit | Pilot repository |
| BV-3-05 | Range determination — three merges produce one release block combining all three | Three PRs merged in rapid succession | Concurrent-PR pair |
| BV-3-06 | Frontmatter stripping — no frontmatter reaches CHANGELOG.md | Merged PR whose changelog file carries frontmatter | Marked-branch fixture |
| BV-3-07 | Per-category merge — entries from several PRs combine under one heading per category | Three PRs with overlapping categories, merged together | Concurrent-PR pair |
| BV-3-08 | Consumed-file removal — the changelog file is absent from the tip of main after assembly | File-route PR; inspect the tree at the assembly commit | Pilot repository |
| BV-3-09 | Two-route extraction — a range mixing body-route and file-route PRs assembles both | One of each, merged together | Concurrent-PR pair |
| BV-3-10 | Self-healing — an entry survives a failed assembly run | Force a run to fail, then merge another PR; assert both entries appear | Concurrent-PR pair |
10.4 Build and publish
Section titled “10.4 Build and publish”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-4-01 | Assembly-commit gating — a merge commit produces no build | Merged PR; assert the build job is skipped on the merge-commit push | Pilot repository |
| BV-4-02 | Version read-back — the published version equals the assembled one | Merged PR; compare the release heading against the published coordinates | Release-shape baseline |
| BV-4-03 | Publish, tag, release — coordinates and Release shape match the pre-migration baseline | One release before and one after cutover | Release-shape baseline |
| BV-4-04 | Deploy chain — dev → stage → demo → prod fires once, in order | operations cutover release | Deploy-chain observation |
| BV-4-05 | Same for infrastructure’s four-partition amm.yml matrix | infrastructure cutover release | Deploy-chain observation |
10.5 Feature builds
Section titled “10.5 Feature builds”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-5-01 | Marking — a push to a marked branch publishes a suffixed prerelease | Branch whose changelog file carries feature-build: frontmatter | Marked-branch fixture |
| BV-5-02 | Version derivation — the base is computed from the entry’s categories, and the version matches feature_branch_version_regex | BV-5-01 with an Added entry off a known head; inspect the coordinates | Marked-branch fixture |
| BV-5-03 | Unmarked branch — a push publishes nothing | Same branch with the frontmatter removed | Marked-branch fixture |
| BV-5-04 | No PR at all — a marked branch with no pull request still publishes on push | Marked branch, no PR opened | Marked-branch fixture |
| BV-5-05 | A marked branch cannot merge — the required check fails while marked, and passes once unmarked | Marked branch with a PR | Marked-branch fixture |
| BV-5-06 | Deployment stops at dev — a feature publish reaches dev and no other environment | operations feature publish; inspect the deploy runs | Deploy-chain observation |
| BV-5-07 | infrastructure exclusion — a marked branch there publishes and deploys nothing | Marked branch on infrastructure | Pilot repository |
10.6 Robustness
Section titled “10.6 Robustness”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-6-01 | Assembly failure is re-runnable | Force a failure mid-assembly, then re-run the workflow | Pilot repository |
| BV-6-02 | Version collision — an existing tag fails the build loudly | Pre-create the tag the next release would use | Release-shape baseline |
| BV-6-03 | Hand-edit path — a top-of-file release block yields a marker commit and a release | manual-changelog PR supplying its own block | Pilot repository |
| BV-6-04 | Hand-edit path — any other edit shape skips assembly and publishes nothing | manual-changelog PR fixing a historical typo | Pilot repository |
| BV-6-05 | Mixed-convention window — a not-yet-migrated repository still builds correctly against the updated shared actions | operations build on the new action version, before its own cutover | Unmigrated-repo canary |
Testing Elements
Section titled “Testing Elements”Pilot repository
Section titled “Pilot repository”common-module after its cutover. The primary fixture for every behavior that does not require a deploy chain: it carries the full workflow set, publishes a real artifact, and has no environment deploys hanging off its publish, so a failed experiment costs a version number.
Concurrent-PR pair
Section titled “Concurrent-PR pair”Two PRs opened against the pilot repository touching disjoint files, each with a valid ## CHANGELOG section and auto-merge enabled. The fixture for everything queue-related. Extended to three entries for BV-3-05.
Conflict-pair fixture
Section titled “Conflict-pair fixture”Two PRs that each compile in isolation but not together — for example, one renaming an internal function and one adding a caller of the old name. The only fixture that demonstrates what the full-build queue gate (DQ-003) buys over the cheap-gate alternative.
Release-shape baseline
Section titled “Release-shape baseline”A record of the last pre-cutover release in each repository: version, git tag, Docker image tag, Helm chart version, Maven coordinates, GitHub Release title and body. Captured before the cutover PR merges and compared field by field against the first post-cutover release. This is the fixture that discharges Constraint 2.
Deploy-chain observation
Section titled “Deploy-chain observation”The Actions run graph for the first post-cutover release, used to assert that each environment job fired exactly once and in the declared order. For infrastructure this includes confirming that Alpha001/prod fired only after the three preceding partitions.
Marked-branch fixture
Section titled “Marked-branch fixture”A branch on the pilot repository whose changelog file carries feature-build: frontmatter. Used for everything in §10.5, and deliberately exercised both with and without an open pull request, because working without one is the property that ruled out every label-based marking scheme.
Unmigrated-repo canary
Section titled “Unmigrated-repo canary”A build of a repository that has not yet cut over, running against the already-updated shared actions. Guards the mixed-convention window: the shared-action changes must be backward compatible, because operations and infrastructure keep building through them for as long as the rollout takes.
11. Operations Impact
Section titled “11. Operations Impact”Telemetry / observability
Section titled “Telemetry / observability”- Workflow-run history is the primary signal. Three new workflow names appear per repository:
Changelog Gate,Review Required Gate,Post-Merge: Changelog Assembly. - The assembly commit itself is the observable release marker on
main.git log --grep '^chore: assemble CHANGELOG 'enumerates every release. - A failed assembly is a failed workflow run on
mainwith no accompanying publish — the shape on-call should recognize.
Operational controls
Section titled “Operational controls”| Control | Mechanism | Effect when set |
|---|---|---|
manual-changelog | PR label | merge-eligibility accepts a CHANGELOG.md edit; assembly honors a top-of-file release block or skips. Exceptional use only — see below |
| Merge queue | Ruleset merge_queue rule | Disabling it reverts to direct merges; PR-body changelogs keep working |
check_response_timeout_minutes | Ruleset parameter | How long the queue waits for the full build before dropping an entry |
feature-build: frontmatter | Changelog file on a branch | Publishes a prerelease on every push and blocks the branch from merging |
On manual-changelog. It is an emergency hatch, not a workflow. Reaching for it re-introduces the exact CHANGELOG.md edit this project exists to remove, and a PR carrying it can conflict with any other PR in flight — which is why it must not become a habit. 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 the entry by posting a ## CHANGELOG comment on the PR instead. Anyone applying the label should expect to justify it in review, and a rising usage rate is a signal that the assembler has a gap worth fixing rather than routing around.
Cost / capacity
Section titled “Cost / capacity”- Build counts per change, today:
operationsandcommon-modulecarry an unfilteredpush:trigger alongsidepull_request, so every push to a PR branch fires the build twice — once aspush, once aspull_request— plus once more on the merge commit. After adoption the merge-commit build moves to the assembly commit and the queue batch head adds one, so the total rises by one build per batch. - That existing double-run is worth removing in the same change: narrowing the
push:trigger tobranches: [main]halves PR-time build cost and is a prerequisite for the assembly-commit gate to read cleanly. It is a strict improvement independent of this project. - The net cost is partially offset by the rebase-and-rebuild cycles the queue eliminates — measurable, since today every second concurrent PR rebases.
operations’ build is the expensive one (containerized Postgres integration tests). Queue parameters should start conservative —max_entries_to_build: 1, a generouscheck_response_timeout_minutes— and tighten with evidence.- No runtime cost change. Nothing about the deployed artifacts moves.
Runbook hooks
Section titled “Runbook hooks”- Assembly failed; merge already on
main. Nothing was published and no tag was cut, and the next merge’s assembly will cover the missed one — the range is what makes this self-healing. If nothing further is due to merge, re-run the workflow; it recomputes the same range. If an entry was malformed, fix it and re-run. - An unbuilt or bad chart reached
dev. It stops there: every environment beyonddevrequires human authorisation, and REQ-ROLL-003 keeps that true for the whole migration. To recover, dispatch a deploy of the last healthy tag back todev; no rollback ofmainis needed, because the chart is an artifact rather than a branch state. - A waiver did nothing. The waiver enqueues rather than merges, and entry to the queue requires green checks. If the checks were red the enqueue failed; the workflow posts the refusal. Fix the checks and re-issue the command — the waiver does not queue up intent.
- Release landed with no deploy. Check whether the head commit on
mainis an assembly commit. A merge commit that never produced one means assembly failed or skipped; askip-mode assembly is silent by design. - Tag collision on publish. The build fails at
Tag source. Someone hand-edited a version that already shipped, or an assembly ran twice. InspectCHANGELOG.md’s top heading againstgit ls-remote --tags. - Queue not draining. Entries dropping on timeout means the full build exceeds
check_response_timeout_minutes. Raise the timeout or reconsider DQ-003 in favor of tiered gates.
Migration matrix
Section titled “Migration matrix”All three repositories additionally move from required_approving_review_count: 1 and no CODEOWNERS, to required_approving_review_count: 0, require_code_owner_review: true, and a * @Arda-cards/engineering @systems-arda CODEOWNERS file owning every path (DQ-006).
The net review requirement is unchanged in strength — one approval, and GitHub already forbids a PR author from approving their own PR — but it must now come from the engineering team or systems-arda rather than from anyone with repository access. The file and the ruleset change must land together; the ruleset change alone drops required review to zero, because with no CODEOWNERS nothing is owned and nothing is required.
| Repository | Before | After | Cutover position |
|---|---|---|---|
common-module | build on merge commit publishes; no bypass actors; required check build | Assembly then publish; one ruleset; App-only bypass; required checks build, merge-eligibility | First |
operations | As above, plus OrganizationAdmin + RepositoryRole 5 bypass and a four-environment deploy chain | As above; bypass reduced to the App; @v1.3.7 pin retired | Second |
infrastructure | validate-release + publish + four-partition amm.yml; qualify-build-action@v2 called directly | As above, adapted to ci.yaml; validate-release remains the qualification check name | Third |
12. Implementation Artifacts
Section titled “12. Implementation Artifacts”Paths are repository-relative. Every path below was verified against the repositories at 2026-08-04.
merge-eligibility
Section titled “merge-eligibility”| File | Construct | Content |
|---|---|---|
.github/workflows/merge-eligibility.yaml | workflow, pull_request + merge_group | New in all three. A thin trigger: resolves the pull request once — including out of the gh-readonly-queue/main/pr-N-<sha> ref — then runs the three scripts below. Publishes the single required check. Not draft-gated, and it re-evaluates in the queue rather than auto-passing. |
.github/scripts/check-codeowners.sh | script | Fails when repos/{owner}/{repo}/codeowners/errors is non-empty. An unresolvable owner makes require_code_owner_review vacuous rather than stricter, and nothing else reports it (REQ-REV-002). Also run post-merge by changelog-assembly, since owners can break after a merge. Queries the ref under test, not the default branch — corrected 2026-08-07 after the pilot’s own pull request 404’d on it, because operations has no CODEOWNERS on main until that pull request merges. Checking the head ref also turns “a pull request breaks CODEOWNERS” from a post-merge discovery into a pre-merge refusal, and a 404 is treated as the failure it is rather than as an absence of errors. |
.github/scripts/check-mergeable.sh | script | Fails when the pull request is a draft — GitHub keeps a drafted entry queued and merges it, and failing a required check is what ejects it — or when it carries a feature-build: marker (REQ-FEAT-004). |
.github/scripts/check-changelog.sh | script | Rejects a CHANGELOG.md edit without the manual-changelog label, then composes the resolved entry into a candidate release block and validates it with the same clq that guards the real file. |
.github/scripts/changelog-entry.sh | script | Resolves the entry from exactly one of the two routes (REQ-AUTH-007); shared with changelog-assembly, which needs the same resolution per merged pull request. Refusals go to stderr: every caller reads the entry through command substitution, so a message on stdout is captured as though it were the entry and the author is left with a bare exit code — observed and corrected 2026-08-07. |
Why one workflow. Three gates shared a trigger that must not drift and duplicated the merge-queue resolution between two of them — fix one copy and not the other and a gate reports success without evaluating queued entries, the hole REQ-GATE-006 exists to close. Three required check names were also three strings that must exist on main and match the ruleset exactly, each a chance at the deadlock REQ-ROLL-005 describes.
Why not a shared action yet. An interface should be shaped by two callers rather than one. common-module’s adoption is the moment to extract these scripts; until then the extraction would be guesswork, and the workflow is already thin enough that it will be mechanical.
changelog-assembly
Section titled “changelog-assembly”| File | Construct | Content |
|---|---|---|
.github/workflows/changelog-assembly.yaml | workflow Post-Merge: Changelog Assembly | New in all three. Ported from documentation minus tag-creation, tag-push, and GitHub Release (DQ-001); plus range determination over merge commits since the previous assembly commit, two-route entry extraction, per-category merging, frontmatter stripping, and deletion of consumed changelog files. Concurrency flips to cancel-in-progress: true, which range-based assembly makes correct. |
queue build and release build
Section titled “queue build and release build”| File | Construct | Content |
|---|---|---|
operations/.github/workflows/cicd.yaml | job build | Add merge_group to on:; narrow the unfiltered push: trigger to branches: [main] (removes an existing double-run on PR branches); add the assembly-commit condition to the push path; move off the @v1.3.7 pin. |
operations/.github/workflows/cicd.yaml | job deploy | Split by path. The release path keeps the four-environment matrix. The feature path gets a matrix containing dev alone, with the other environments structurally unreachable rather than approval-gated (REQ-FEAT-008). Today’s single if: needs.build.outputs.chart_name condition does not distinguish a release publish from a feature publish, and would fan a feature build out to prod. |
common-module/.github/workflows/cicd.yaml | job build | Same changes; keep fetch-depth: 0 for the Spotless ratchet (PDEV-1300). |
infrastructure/.github/workflows/ci.yaml | jobs validate-release, publish | Add merge_group; move the publish condition from the merge commit to the assembly commit. |
infrastructure/.github/workflows/ci.yaml | job all-synth-results | Candidate required check for the queue — see §13. |
operations/.github/workflows/api-tests-local.yaml | trigger block | Confirm the push: branches: [main] path still fires as intended when main receives two pushes per change. |
Shared actions
Section titled “Shared actions”| File | Construct | Content |
|---|---|---|
qualify-build-action/action.yaml | step qualify-build, the case "${{ github.event_name }}" | Add merge_group to the existing pull_request|push) branch, replacing the *) error "Unsupported event" fallthrough for that event. A merge_group build then reports merge_group_to_release_branch — or merge_group_to_feature_branch where the destination branch’s ruleset does not require the configured workflow_name check, since the trigger is composed from the event and the resolved target rather than fixed. Either value falls through define-build to kind = test with no change there: neither matches the push_to_release_branch test nor the push test that is_publishable_feature_branch requires. |
qualify-build-action/action.yaml | step define-target | Changed — supersedes the earlier “leave untouched”. Resolve the branch as github.event.merge_group.base_ref || github.base_ref || github.ref_name, stripping refs/heads/. Without it a queued build probes the temporary gh-readonly-queue/… ref, finds no rules, and concludes target = feature — which is not harmless as first assessed, because clq-extract takes its mode from target and would validate the changelog in feature mode at the last checkpoint before merge, weaker than the release mode the pull request itself was held to. The fix degrades safely: if the payload field is absent the expression falls through to the previous behaviour. |
qualify-build-action/README.md | docs | Update the trigger and event tables; the current text states only push and pull_request are supported. |
gradle-build-pipeline-action/action.yaml | composite | No change — verified 2026-08-06. Every publish, tag and release step is gated on trigger == 'push_to_release_branch', which a queued build never produces. This repository therefore needs no release of its own: once qualify-build-action 2.1.0 ships, its floating @v2 reference picks the change up. |
gradle-build-pipeline-action/gradle-build.sh | script | No change. Verified to branch only on KIND and VERSION. Listed so the implementer does not go looking. |
Shared-action consumers and release staging
Section titled “Shared-action consumers and release staging”Who else runs this code, and how a change reaches them. Verified 2026-08-06.
| Repository | Pin | Reaches qualify-build-action |
|---|---|---|
operations | gradle@v1.3.7 → v1.3.9 | transitively, floating @v2 |
common-module | gradle@v1 | transitively, floating @v2 |
accounts | gradle@v1 | transitively, floating @v2 |
bastion | gradle@v1 | transitively, floating @v2 |
pdf-render | gradle@v1 | transitively, floating @v2 |
qr-lookup | gradle@v1 | transitively, floating @v2 |
infrastructure | qualify@v2 | directly |
The transitive column is the one that matters: gradle-build-pipeline-action/action.yaml references qualify-build-action@v2 from inside its own composite, so an exact pin on the wrapper does not freeze what the wrapper calls. REQ-ROLL-004 is the constraint this imposes; work branches and prerelease versions are the two mechanisms that satisfy it.
operations’ @v1.3.7 pin was held because v1.3.8 stopped classifying a push to main as a release, silently skipping chart publish and deploy. v1.3.9 corrected that by asking for context:build, which is what the ruleset on both operations and common-module requires; common-module has published every release since on that version. Retiring the pin is therefore a prerequisite of this work rather than part of it, and is staged separately for exactly that reason.
Three runs, in order, each isolating one delta:
| Change under test | Path exercised | What a red result means | |
|---|---|---|---|
| A | operations → released v1.3.9 | test only | Pre-existing; nothing to do with this project |
| B | operations → action work branches | test only | The merge_group change |
| C | B, plus a feature version in CHANGELOG.md | publish + deploy to dev | Publish or deploy regression |
A and B both ran green on 2026-08-06. A (PR #263) confirmed the stale pin was the only thing keeping operations off v1.3.9. B (run 31132103910) reported target is feature, trigger is push_to_feature_branch, kind is test, version is undefined — the modified action leaving the existing push path exactly as it was.
A and B stop short of publishing, because a pull-request build only ever yields kind = test and the deploy job is gated on a chart name that a test build never sets. C closes that gap using the repository’s own documented feature-branch capability: a version of the form 7.1.0-jmpicnic-1408 makes the push publish a chart and deploy it to dev, where the run then parks on stage’s required reviewers and is cancelled. dev is restored by dispatching deploy.yaml with the previous chart version. Nothing beyond dev is reachable without a human approving it, which is what REQ-ROLL-003 requires of any new path.
C leaves a Docker image and a Helm chart at the feature version in GPR, which need clearing afterwards. It leaves no git tag: gradle-build-pipeline-action’s Tag source step tags only when the trigger is push_to_release_branch, so a feature build publishes artifacts without marking the history (verified 2026-08-06). C also displaces whatever is running on dev until the restore, and dispatches the api-test suite against it.
A deploy has no concurrency group in cicd.yaml, reusable_deployment.yaml, or deploy.yaml, so two runs reaching the same environment are not serialised — which C proved by colliding with the release merged while it ran. See REQ-PUB-005; the fix lands in Stage 2 and merges before the queue is enabled, so it is proven under today’s traffic first.
C’s own results, 2026-08-06 (run 31132470432): the build published 7.0.1-jmpicnic-1408-31132470432.2251.1 under the modified action, dev deployed successfully, and stage blocked on required reviewers with no action taken — REQ-FEAT-008 demonstrated rather than asserted. The run was cancelled at that gate and dev restored by dispatching deploy.yaml.
Repository configuration
Section titled “Repository configuration”| File | Construct | Content |
|---|---|---|
operations/.github/workflows/reusable_deployment.yaml | concurrency on the deploy job | Folded in, not deferred. A concurrency group keyed deploy-<component>-<purpose> with cancel-in-progress: false (REQ-PUB-005). Placed in the reusable workflow rather than its callers so the automatic chain and deploy.yaml’s dispatch share one group — a manual restore racing an automatic deploy is the same collision. |
helm-deploy-pipeline-action/action.yaml | precondition step | REQ-PUB-006 lands here, not in operations — corrected 2026-08-06. The guard must compare the chart version already released in the namespace against the incoming one, and only this action has cluster access: it configures AWS credentials at action.yaml:101 and runs helm upgrade at :263. A step in operations would have nothing to query. This makes it a fourth shared-action repository with its own review requirement, so it should be sequenced early rather than last. |
operations/.github/workflows/deploy.yaml | force input | Plumbs the deliberate-rollback escape through to the guard above, since deploy.yaml exists precisely to put an environment back on an earlier version. Added with the guard rather than ahead of it. |
infrastructure/.github/workflows/amm.yml | environment input | Add Alpha002/stage to the choice. ci.yaml’s matrix deploys it on every release but no dispatch can reach it, so it has no manual recovery path — which both REQ-FEAT-008 and REQ-PUB-005’s recovery story assume. Absorbs PDEV-1429. |
.github/pull_request_template.md | template | New for operations and common-module (neither has one). Carries the ## CHANGELOG placeholder. |
infrastructure/.github/PULL_REQUEST_TEMPLATE.md | template | Modified, not created. Its existing ## CHANGELOG section is a checkbox asserting CHANGELOG.md was updated — the exact opposite of the new convention, and a heading collision with what merge-eligibility parses. Must be replaced with the entry-carrying form. |
.github/CODEOWNERS | config | New in all three; none currently has one, and arda-frontend-app has none either (verified at root, .github/, and docs/). Content is * @Arda-cards/engineering @systems-arda, matching documentation minus its unowned-roadmap exception (DQ-006). Load-bearing: with required_approving_review_count: 0 and require_code_owner_review: true, this file is the review policy and a missing one drops review to zero. Verified 2026-08-06 — @Arda-cards/engineering holds direct push on all three and systems-arda is a User with admin; CODEOWNERS requires direct access, not subteam-inherited. Deliberately flat, with no path-scoped unowned exception: in documentation such an exception is defeated in practice, because a pre-commit hook regenerates the root-level documentation-manifest.yaml on essentially every content change and the default * rule then pulls the PR back under code-owner review regardless. A repository with a generated root-level file cannot have a meaningful unowned subtree unless that file is unowned too. |
| Branch-protection ruleset | GitHub config | One ruleset per repo, amending the existing one (operations 7127991, common-module 3829305, infrastructure 3314240): pull_request (thread resolution, count 0, require_code_owner_review: true), required_status_checks with strict_required_status_checks_policy: false, merge_queue with grouping_strategy: ALLGREEN, deletion, non_fast_forward; bypass arda-changelog-bot at always. Existing human bypass is removed — operations currently carries OrganizationAdmin and RepositoryRole 5. Applied after the change set merges, per REQ-ROLL-005. |
CHANGELOG.md | header prose | Update the header block in all three: the categories list stays; the feature-branch paragraph is rewritten for the frontmatter marker (DQ-004). Note the asymmetry — operations and infrastructure rewrite an existing paragraph, common-module gains one it never had. |
CLAUDE.md, knowledge-base/ | docs | Record the new convention per repository, following documentation/knowledge-base/pr-body-changelog.md. |
Documentation artifacts
Section titled “Documentation artifacts”| File | Construct | Content |
|---|---|---|
current-system/oam/configuration/deployment/queued-cicd.md | spec | Done. Restructured around the two variants — who owns the release, rather than who runs first, since both variants assemble before they build. Adds per-repository rows for the three new adopters, the CODEOWNERS-fails-open measurement, and a section on cicd-testbed as the reference implementation. |
process/craft/deployment-and-release/backend-pr-process.md | how-to | Done. The backend counterpart to frontend-pr-process.md: the two authoring routes, the merge queue, feature builds, and the symptom-to-remedy table. |
Out of Scope
Section titled “Out of Scope”- Tiered fast/queue gates. Revisit when: queue latency causes entries to drop on
check_response_timeout_minutes, or PR-time feedback becomes the bottleneck. See DQ-003. - PDEV-694 external queueing for assembly. The GHA
concurrencygroup stays as-is. Revisit when: ordered assembly demonstrably fails under the increased repository count. arda-frontend-appbypass-list normalization (PDEV-474). Independent of this project, though it shares the App. Revisit when: that ticket is scheduled.workspace/instructions/claude/rules/changelog.md. The rule enumerates which repositories are on PR-body mode and must gain these three — but noworkspaceworktree exists for this project. Revisit when: the first repository cuts over; the rule is wrong from that moment until updated.- Retiring the
manual-changelogescape hatch. Kept, but as an emergency hatch reserved for exceptional circumstances rather than a supported workflow (§11). Revisit when: usage rises above the occasional — that would indicate a gap in the assembler being routed around rather than fixed. accounts-component. Explicitly excluded (Miguel, 2026-08-04). It shares the Gradle pipeline and the same changelog shape, so it will keep building through the updated shared actions without adopting the model — which is what makes the unmigrated-repo canary a permanent obligation rather than a transitional one: the shared actions must stay backward compatible indefinitely, not just for the length of the rollout. Revisit when: someone chooses to migrate it.
13. Open Questions
Section titled “13. Open Questions”The requirements are the authoritative record of what is settled and what is not; this section lists only what still blocks design or implementation.
-
infrastructurequeue gate composition. The synth matrix fans out per CDK app. Re-running all of it per batch may exceed any workablecheck_response_timeout_minutes;all-synth-resultsis the natural aggregating required check, but whether the whole fan-out belongs in the queue is unresolved. Owner: Miguel. Blocking: PDEV-1412’s ruleset change. -
Queue parameters per repository.
max_entries_to_build,max_entries_to_merge,check_response_timeout_minutes.documentationuses 2/3/15;operations’ build is far slower. To be set with evidence from the pilot. Owner: Miguel. Blocking: nothing — defaults can be tightened later. -
Whether amending an entry should re-run the gate.
merge-eligibilitytriggers on pushes and label changes, so a## CHANGELOGcorrected in the pull-request body — or supplied in a comment, which §9.1 offers as the amendment route — leaves the check red until it is re-run by hand. The reference implementations addpull_request: [edited]andissue_commentand do not have this gap.The fix is not a free two-line addition, which is why it is a question rather than a defect. An
issue_commentworkflow runs from the default branch, so the checked-out tree ismainrather than the branch under test — andchangelog-entry.shreads a file-route entry from the working tree. On that event it would find no.changelog/file and silently fall back to the body route, reporting success on a pull request that is actually carrying both. Closing the gap properly means resolving the entry through the API rather than the checkout, which is a real change to the shared script.Meanwhile
gh run rerun --failedis sufficient and is documented in the contributor how-to; the gate reads the body at run time, so a re-run sees the correction. Owner: Miguel. Blocking: nothing.
Pilot verifications
Section titled “Pilot verifications”Three platform behaviors this design depends on are documented in principle but untested here. They are exercised on a dedicated testbed repository rather than on the pilot: each concerns ruleset and queue semantics rather than the pilot’s build, and a testbed answers them with synthetic checks in minutes where operations costs 18–27 minutes per iteration.
-
Ruleset composition.Answered 2026-08-06 — the structure works. Twopull_requestrules from different rulesets both apply tomainsimultaneously and compose to the stricter, with bypass evaluated per ruleset. Measured oncicd-testbed:repos/.../rules/branches/mainreports both rules, one withrequired_approving_review_count: 0and the other addingrequire_code_owner_review: true, and the pull request was blocked by the second while the first required nothing.The same experiment turned up something the design had asserted but never tested, and which turned out to be worse than asserted — see REQ-REV-002. A CODEOWNERS file whose owner does not resolve is equivalent to no file at all: the identical pull request under the identical rulesets was mergeable with zero reviews while the owning team lacked repository access, and blocked once it was granted. The ruleset advertises
require_code_owner_review: truein both states and nothing on the pull request distinguishes them. Hence the CODEOWNERS assertion inmerge-eligibility. -
Bypass × merge queue.Answered 2026-08-06, and it is why REQ-REV-004 is withdrawn. A bypass actor can neither enqueue a pull request awaiting code-owner review —enqueuePullRequestevaluates the pull request’s state, not the caller’s privileges, with both bypass modes tried — nor merge it directly, because the merge-queue rule belongs to a ruleset it does not bypass. The waiver has no working path. -
Draft conversion while queued.Answered 2026-08-06 — it does not. A queued pull request converted to draft stayed at position 1, ran every check, and merged withisDraft: true, after which assembly cut a release from it. REQ-GATE-007 described behaviour GitHub does not have;merge-eligibilitynow asserts it, and a failing required check is what ejects the entry.
Resolved, retained for traceability:
-
CODEOWNERS ownership rules.Settled as DQ-006, amended 2026-08-06 to* @Arda-cards/engineering @systems-arda. -
Feature-build signalling.Settled 2026-08-06 as frontmatter in the changelog file, with the suffix stated explicitly. The no-PR case dissolved: a marked branch carries its changelog file regardless of whether a PR exists. -
Answered 2026-08-04, amended and then measured 2026-08-06. The original reading needed two corrections.merge_groupruleset probe.gh ruleset checkalso returned0 rules applyforrefs/heads/main, which had cast doubt on the probe itself; that was an artifact of a local token, not of the probe —common-moduleruns it in CI on every merge and has published every release since v14.0.0 through it. And thefeatureverdict is not harmless:clq-extracttakes itsmodefromtarget, so a queued build would validate the changelog in feature mode at the last checkpoint before merge.define-targetis therefore changed as well, not just the eventcase— see Shared actions.Measured directly on
cicd-testbed(run 31132061181). On amerge_groupeventgithub.base_refis empty,github.ref_nameis the queue branch, andgithub.event.merge_group.base_refcarriesrefs/heads/main— a full ref, so it needs stripping before the probe. The same run reports5 rules apply to branch mainagainst0 rules applyfor the queue ref, which is what makes the unfixed expression concludefeature. On apull_requestevent bothmerge_groupfields are empty whilegithub.base_refismain, so the new expression falls through to the term it used before and that path is unchanged by construction.
References
Section titled “References”- Requirements — the authoritative record of what this design must satisfy, and of what remains open.
- Decision Log — settled decisions for this design.
- Goal — project goal this design serves.
- Queued CI/CD — canonical specification of the model being adopted.
- Phase 2 — Queued PRs and Tiered Gates — the
arda-frontend-approllout and its tiered-gate design. - Backend PR Process — the contributor how-to for the repositories this design covers.
- Frontend PR Process — its counterpart for
arda-frontend-app. Arda-cards/cicd-testbed— the reference implementation, and the findings table recording what each platform experiment established.- Viewpoint Mapping — source-to-artifact-to-runtime cross-reference used in §1.
documentation/knowledge-base/pr-body-changelog.md(repo-local) — authoring rules and the three assembly modes.documentation/knowledge-base/arda-changelog-bot.md(repo-local) — App identity, secrets, rotation, and the three-step repository-extension procedure.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved