Skip to content

Backend PR Process

This guide explains how to create, review, and merge a pull request against operations, common-module, or infrastructure. It is the backend counterpart to the Frontend PR Process, and the human companion to the pr-steward agent skill.

For the model these repositories run and why the backend variant differs, see Queued CI/CD. For the design rationale, see Queued CI/CD Adoption.

The conventions below are being rolled out per repository, common-module first. Until a repository has cut over it still uses the old model — edit CHANGELOG.md in the PR, no merge queue. Check whether the repository has a .changelog/ directory: if it does, this guide applies; if it does not, Release Lifecycle still describes what to do.

The one thing that is different from the frontend

Section titled “The one thing that is different from the frontend”

In these repositories CHANGELOG.md is the version. gradle.properties carries version=0.0.0 and everything downstream — the git tag, the Docker image tag, the Helm chart version, the Maven coordinates, the GitHub Release body — is derived from the top release heading in that file at build time.

That has one consequence you will feel: the build that publishes your change runs one commit later than the merge. Your PR merges, changelog-assembly writes the release block and pushes an assembly commit, and that commit is what builds, publishes, tags, and deploys. If you go looking for the build on your merge commit, you will not find one, and that is correct.

  1. Branch from latest main as <github-username>/<description>.
  2. Make your change. Run the local gate before pushing.
  3. Open a PR. Fill in the ## CHANGELOG section, or add a file under .changelog/ — one or the other, never both, never neither.
  4. merge-eligibility and build run. Fix anything red.
  5. Get a code-owner approval and resolve every review thread.
  6. Enable auto-merge → the PR enters the merge queue.
  7. The queue re-runs the full build against the batch head and merges.
  8. Post-merge: assembly writes CHANGELOG.md, the assembly commit builds and publishes, and operations deploys to dev.
Terminal window
git -C <workspace>/operations fetch origin main
git -C <workspace>/operations checkout -b <username>/<description> origin/main

For project work, prefer a worktree under projects/<project-name>-worktrees/ rather than touching the main clone. Agent branches take the form <username>-ag/<description>-<ticket>.

Follow the repo conventions in the repository’s own CLAUDE.md and its knowledge-base/. For operations in particular: modules share state through injected Kotlin services rather than cross-module database access, and inter-module communication goes through gRPC/Protobuf.

Run the local gate before every push, not just the first.

Terminal window
./gradlew check # tests + static analysis
make clqLint # CHANGELOG format

./gradlew check has two known blind spots: it does not generate the OpenAPI document and it does not exercise runtime DBIO wiring. A dependency bump is not verified until the local api-tests suite has run against it.

Exactly one of two routes. Both present is rejected; neither is rejected.

Route A — the PR body. The PR template scaffolds it:

## CHANGELOG
### Fixed
- Item lookup no longer returns soft-deleted supplies.

Route B — a file under .changelog/. Name it with a prefix of your own; your GitHub username is the obvious choice.

Terminal window
cat > .changelog/jmpicnic-item-lookup.md <<'EOF'
### Fixed
- Item lookup no longer returns soft-deleted supplies.
EOF

Names are author-chosen rather than fixed so that two PRs in one merge batch cannot collide on the same path — a fixed name would guarantee the collision the queue exists to avoid.

Use Route B when the entry belongs with the code (a large change developed over several sessions), or when you want a feature build — see below. Use Route A otherwise; it is one less file to remember.

CategorySemVer bumpUse for
ChangedmajorAPI-breaking changes to existing functionality
RemovedmajorRemoved functionality
AddedminorNew features
DeprecatedminorFeatures marked for future removal
FixedpatchBugfixes, and all internal work
SecuritypatchVulnerability fixes

Be deliberate: the category is what decides the version bump. Changed and Removed are for API-breaking changes only — internal refactors, however large, are Fixed. Order sections larger-impact-first within an entry.

Write what the change enables or fixes, not what files moved. A changelog entry is read by someone deciding whether to upgrade.

The file is assembly’s output, not its input. merge-eligibility rejects a PR that touches it. If you genuinely need a hand-edit — correcting a historical entry — apply the manual-changelog label, and expect to justify it in review.

Fill in the template: What and why, Closes (only Linear IDs this PR actually resolves — any PDEV-XXX in the body transitions that issue regardless of keyword), and CHANGELOG.

If you are an agent or contributing on someone’s behalf, append the attribution block to the PR body and to every comment:

> [!note]
> Authored by <Tool Name> for <github-username>
Required checkWhat it does
merge-eligibilityOne check covering four assertions: CODEOWNERS resolves; the PR is not a draft; it is not a marked feature build; it carries exactly one valid changelog entry and does not edit CHANGELOG.md.
buildThe full Gradle build — compile, test, static analysis, containerized integration tests.

merge-eligibility runs on drafts. That is deliberate: a skipped job publishes a successful check run, which GitHub cannot distinguish from a passing one, so the gate never skips. It also means you learn your entry is missing while you are still working, which is the better time.

The build on a pull request is a test build. It publishes nothing and cuts no tag.

The review policy is ownership-based: required_approving_review_count: 0 with require_code_owner_review: true, and a CODEOWNERS file that owns every path. In practice that means one approval, from @Arda-cards/engineering or systems-arda, on every PR.

Owning every path is not bureaucracy. Workflows run from the head branch, so a PR that edits .github/workflows/ runs the edited version against itself — a PR that neuters its own gate is reported green by it. An owned path is the only thing that stops that, and no mechanism can waive it.

Address every reviewer comment and resolve every thread before merge — thread resolution is enforced by the ruleset.

Once approved and green:

Terminal window
gh pr merge <num> --auto

The queue forms ALLGREEN batches and re-runs the full build against the synthetic batch head, which is what catches two PRs that compile independently but not together. merge-eligibility re-evaluates against the queued commit rather than passing through.

Do not push to a branch that is in the queue. And note that converting a queued PR back to draft does not remove it — GitHub keeps it and will merge it. merge-eligibility fails on a draft precisely so that the failure ejects the entry; that is the only lever available.

  • Success — the PR merges. On to post-merge.
  • Batch failure — GitHub bisects; the offending PR is removed and yours re-enters automatically.
  • Ejection — your PR caused the failure. It is removed and reopened. Fix, re-approve, re-enter.

Two pushes to main follow, in order.

  1. The merge commit triggers changelog-assembly and nothing else. It collects the entries for every merge since the last release tag, computes one semver from their combined categories, prepends one release block to CHANGELOG.md, deletes any .changelog/ files it consumed, and pushes as arda-changelog-bot.

    The range is what makes this safe: if a run fails, its work is still pending and the next run collects it. Nothing depends on a single run succeeding.

  2. The assembly commit triggers the build. qualify-build-action reads the version out of the release heading assembly just wrote, and the build publishes the image and chart, cuts the vx.y.z tag, and creates the GitHub Release. For operations, deploy then fans out devstagedemoprod at one at a time, with every environment past dev behind an approval gate.

If several PRs merged close together, they share one release block and one version. That is intended — a release reads as a description of the release rather than as a list of pull requests.

operations and common-module only. Useful when a consumer needs to build against an unreleased change, or when you want the change on dev before it merges.

Add feature-build: frontmatter to your .changelog/ file, naming the suffix explicitly:

---
feature-build: jmpicnic-1408
---
### Added
- Item scanning by GTIN.

Then:

  • Every push to the branch publishes. An Added entry off 6.14.0 publishes 6.15.0-jmpicnic-1408-<run>, which sorts before 6.15.0 under semver prerelease ordering — correct, since it is a prerelease of the release it anticipates.
  • operations deploys it to dev and nowhere else. Structurally: the feature path’s deploy matrix contains dev alone. It deliberately does not rely on the approval gates, which are shared with the release path.
  • A marked branch cannot merge. merge-eligibility fails while the marker is present. Delete the frontmatter to make it mergeable.
  • No PR is required. The marker lives in a file on the branch, so this works whether or not a pull request exists — which is why it is not a label.

Nothing survives onto main: the branch cannot merge while marked, and assembly deletes the file.

To put dev back on the released version afterwards, dispatch deploy.yaml with the previous chart version.

SymptomWhat to do
merge-eligibility fails with “no changelog entry found”Add a ## CHANGELOG section to the PR body, or a file under .changelog/. Then re-run the check — see the row below.
I fixed the entry in the PR body but the check is still redThe gate does not re-run on a body edit or a new comment; it triggers on pushes and label changes only. Re-run it (gh run rerun <id> --failed) or push. The re-run reads the current body, so nothing else is needed.
merge-eligibility fails with “both a .changelog/ file and a ## CHANGELOG section are present”Pick one. Ambiguity about which entry is authoritative is treated as a defect, not resolved silently.
merge-eligibility fails with “CODEOWNERS has N unresolved owner(s)“Someone renamed a team or lost repository access. Until it resolves, code-owner review is not being enforced at all — this is why the check exists. Fix the file or restore the access.
The entry needs changing after the PR is openBody route: post a comment with a new ## CHANGELOG section — last one wins, and only the author or an assignee counts. File route: edit the file.
No build ran on my merge commitCorrect. The build runs on the assembly commit that follows. If no assembly commit appeared, assembly failed or skipped — check the run on main.
Assembly failedNothing was published and no tag was cut. The next merge’s assembly covers yours too. If nothing further is due, re-run the workflow; it recomputes the same range.
The build fails at Tag sourceThe version already shipped. Someone hand-edited a version, or an assembly ran twice. Compare CHANGELOG.md’s top heading against git ls-remote --tags.
An unbuilt or bad chart reached devIt stops there. Dispatch deploy.yaml with the last healthy chart version; no rollback of main is needed, because the chart is an artifact rather than a branch state.
Push rejected with branch is in merge queueWait for the queue. If the PR is ejected, push the fix to the reopened branch.
Queue entries dropping on timeoutThe full build is exceeding check_response_timeout_minutes. Raise it.
Agent skillSourceWhat it automates
pr-stewardworkspace/instructions/claude/skills/pr-steward/SKILL.mdWatches checks, triages reviewer comments, replies and resolves threads, monitors merge_group runs through to merge or ejection.
changelog ruleworkspace/instructions/claude/rules/changelog.mdWhich repositories use which changelog model, and what a good entry says.

The skill never enters the merge queue or merges autonomously — both require an explicit affirmative response in the same session as the request.