PR Merge Paths
Every pull request to master takes one of two paths, decided only by which files it changes:
- Default path — code review, automated checks, one human approval, and a version tag (plus image build) on merge.
- Auto-merge path — no review and no checks: the PR is merged by a GitHub App within seconds of opening, and the merge creates no tag.
Two categories take the auto-merge path today: documentation content and backend tests. The rule is strict: a PR qualifies only if every changed file is in an auto-merge category. One file anywhere else puts the whole PR on the default path.
| PR changes | Path | Checks | Approval | Tag / image build on merge |
|---|---|---|---|---|
Only docs/src/content/** |
Auto-merge | Skipped | Not needed | No |
Only backend/tests/** (except Pest.php, TestCase.php) |
Auto-merge | Backend Tests and Pint run, but don’t block | Not needed | No |
| Any mix of the two rows above | Auto-merge | As above | Not needed | No |
Only other docs/** files, or backend/tests/Pest.php / TestCase.php |
Default, lighter | Skipped (tests run for the suite files) | Needed | No |
| Anything else | Default | All that apply | Needed | Yes |
The “lighter” row covers files that ship nothing but can still do damage in one line: docs tooling (docs/package.json, the lockfile, docs/astro.config.mjs) builds the docs site and pulls in dependencies, and the suite-wide Pest.php / TestCase.php can skip or weaken every test at once. They skip the review bot and the tag, but a human approves them. backend/phpunit.xml and backend/database/factories/** sit outside backend/tests/, so they take the full default path.
Default Path
Section titled “Default Path”The path for any PR that changes code, configuration, workflows, or anything outside docs/.
On the PR
Section titled “On the PR”| Workflow / check | Runs when | What it does |
|---|---|---|
Lint PR Title (pr-title-check.yml) |
Any change outside docs/ and backend/tests/ |
Enforces type(scope): description (Conventional Commits). The squash commit takes the PR title, and its type drives the version bump. |
Claude Code Review (claude-code-review.yml) |
Any change outside docs/ and backend/tests/, non-draft, title without [skip-ai] |
AI review posted as a PR comment: quality, security, performance, test breakage, docs drift. |
Backend Tests (backend-tests.yml) |
backend/**.php, composer.*, phpunit.xml, phpstan.neon |
Three jobs: the Pest suite on SQLite (PHP 8.5), a Pint style check against backend/pint.json (Pint), and the pgsql group against real PostgreSQL (non-blocking while it is being stabilised). Test results are posted as a PR comment. |
Documentation Check (documentation-check.yml) |
Services, controllers, models, Filament, migrations, config, frontend pages/components/layouts | Posts a reminder to update docs/ when documented areas change. |
Build & Push to ECR (build-images.yml) |
PR labeled ready-to-test |
Builds the Docker images before merge so they can be deployed to staging for testing. |
| Workers Builds: volare (Cloudflare) | Changes under frontend/ |
Preview build of the Astro frontend on a per-branch workers.dev URL. |
| Cloudflare Pages (Cloudflare) | Any push | Preview build of this docs site. |
Pint runs in CI as part of Backend Tests (it only checks, it never rewrites files). PHPStan and Rector do not: they run locally through composer test and the /pr-create gate (see Pull Request Creation).
Merge rules
Section titled “Merge rules”The repository ruleset PRs & conventional commits applies to master:
- Changes must arrive through a pull request (no direct pushes).
- 1 approving review is required, and the approval must come after the last push; new pushes dismiss earlier approvals.
- Linear history: squash merges only, so each PR lands as one commit titled after the PR.
- Force pushes and branch deletion are blocked.
- No status checks are required — the checks above inform the reviewer, who decides whether to approve.
The ruleset also lists a commit-message pattern (^#\d+\s.+), but metadata restrictions need GitHub Enterprise, so it is not enforced.
After merge
Section titled “After merge”- Tag on Merge (
tag-on-merge.yml) creates the next semantic version tag from the commit type (feat→ minor,BREAKING CHANGEin the body → major, everything else → patch) and dispatches the image build on that tag. - Build & Push to ECR builds the images tagged with that version.
- Deploy is manual, from
infra-volare— see Deployment.
The frontend deploys separately: Cloudflare Workers Builds deploys master to production on every push that touches frontend/.
Auto-Merge Path
Section titled “Auto-Merge Path”The path for PRs whose every changed file is in an auto-merge category:
| Category | Allowlist | Typical PR |
|---|---|---|
| Documentation content | docs/src/content/** |
The nightly docs-sync PR, a docs fix |
| Backend tests | backend/tests/** except backend/tests/Pest.php and backend/tests/TestCase.php |
New or fixed Pest tests, test helpers in tests/Support, fixtures |
Both categories merge immediately. For backend tests that is a deliberate choice: the author is trusted to have run the suite, PHPStan and Pint locally (composer test, or /pr-create) before opening the PR. Backend Tests still runs on the PR and again on master after the merge, but it does not hold the merge back — so a test that fails in CI lands on master and turns every open PR’s Backend Tests red until it is fixed. If that happens, fix it forward with another test-only PR (it merges itself too).
What happens
Section titled “What happens”- The PR is opened (or updated, reopened, or marked ready for review).
- The Auto-Merge workflow (
auto-merge.yml) starts. Lint PR Title and Claude Code Review do not run. For a test PR, Backend Tests starts too, in parallel, and keeps running after the merge. - Job Check auto-mergeable lists the PR’s files through the API and checks that each one — including the previous path of a renamed file — is in a category’s allowlist. It also requires the PR to be non-draft and opened from a branch of this repository (not a fork). If anything fails, it prints the offending files and stops; the PR stays open for normal review.
- Job Merge PR mints a short-lived token for the
volare-auto-mergeGitHub App and squash-merges the PR. The PR shows as merged byapp/volare-auto-merge. - The push to
masterdoes not trigger Tag on Merge (it ignoresdocs/**andbackend/tests/**), so no tag and no image build. The docs site still publishes through its Cloudflare Pages project, which deploys on every push tomaster; a test merge runs Backend Tests onmaster.
The whole flow takes about 20–30 seconds. master can therefore carry docs and test commits without a tag; the next code merge’s tag includes them (git describe --tags shows something like v2.275.2-1-g13d057f2 in between).
Why it is safe
Section titled “Why it is safe”Merging without review is only acceptable if the gate cannot be bypassed. Four controls make sure of that:
- The gate runs from
master. The workflow triggers onpull_request_target, which always runs the workflow file from the base branch. A PR cannot loosen the allowlist by editingauto-merge.yml— its edits only take effect once merged through the default path. - PR code is never executed. The workflow does not check out the PR; it only reads the file list from the GitHub API.
- The App’s key is only reachable from
master. The credentials live in theauto-mergeenvironment, which onlymastercan deploy to. A workflow added in a PR branch cannot read them, so it cannot use the App to merge itself. - The App cannot push to
master. Its ruleset bypass is in pull requests only mode: it can merge a PR without the required review, but a direct push is still rejected.
gh pr merge --match-head-commit closes the last gap: if a commit is pushed between the file check and the merge, the merge is refused and the new push starts a fresh run.
Auto-Merge Setup
Section titled “Auto-Merge Setup”A one-time configuration done on 2026-10-01 by an organization owner. Recorded here so it can be audited, rotated, or rebuilt.
Why a GitHub App
Section titled “Why a GitHub App”GitHub’s recommendation for automation that needs more than the built-in GITHUB_TOKEN is a GitHub App. The alternative — enabling Allow GitHub Actions to create and approve pull requests — was rejected: any collaborator with write access could add a workflow to their own PR that approves it, which would void the review requirement for every PR. That setting stays off at both organization and repository level.
1. The GitHub App
Section titled “1. The GitHub App”Created under volare-tech → Settings → Developer settings → GitHub Apps:
| Setting | Value |
|---|---|
| Name | volare-auto-merge (generic, so future categories can reuse it) |
| App ID | 5146421 |
| Owner | volare-tech |
| Homepage URL | https://github.com/volare-tech/volare |
| Callback / redirect URLs, OAuth during install, Device Flow | None / off — the App never signs users in |
| Webhook | Inactive — the App reacts to nothing; workflows call it |
| Repository permissions | Contents: Read and write, Pull requests: Read and write, Metadata: Read-only (automatic). Everything else: No access |
| Organization / account permissions | None |
| Subscribed events | None |
| Installable on | Only this account |
| Installed on | All volare-tech repositories (it can only skip review where a ruleset grants it bypass, which today is volare only) |
No client secret is needed. The App authenticates with its App ID and a private key generated on the App’s page.
2. Ruleset bypass
Section titled “2. Ruleset bypass”In volare → Settings → Rules → PRs & conventional commits → Bypass list, volare-auto-merge is added with mode For pull requests only (not Always allow, which would also let it push directly to master).
3. The auto-merge environment
Section titled “3. The auto-merge environment”In volare → Settings → Environments:
| Setting | Value |
|---|---|
| Name | auto-merge |
| Deployment branches and tags | Selected branches: master only |
Variable AUTO_MERGE_APP_ID |
5146421 |
Secret AUTO_MERGE_APP_PRIVATE_KEY |
Full contents of the App’s .pem private key, including the BEGIN/END lines |
The .pem file is deleted locally after being stored; GitHub keeps no copy, so a lost key is replaced, not recovered.
4. The workflows
Section titled “4. The workflows”.github/workflows/auto-merge.yml— the gate and the merge (added in #2343; backend tests category added later).paths-ignorefordocs/**(#2341) andbackend/tests/**ontag-on-merge.yml,claude-code-review.ymlandpr-title-check.yml.
Operating It
Section titled “Operating It”Rotating the private key
Section titled “Rotating the private key”- On the App’s page, Generate a private key.
- Replace the
AUTO_MERGE_APP_PRIVATE_KEYsecret in theauto-mergeenvironment. - Delete the old key from the App’s page.
Turning auto-merge off
Section titled “Turning auto-merge off”Any one of these stops it immediately; pick the least disruptive:
- Disable the Auto-Merge workflow in the Actions tab (reversible, keeps everything else).
- Remove
volare-auto-mergefrom the ruleset bypass list (merges then fail on the review requirement). - Suspend the App’s installation in the organization settings.
When a PR does not merge itself
Section titled “When a PR does not merge itself”Open the PR’s Auto-Merge run:
- Skipped entirely — the PR is a draft, comes from a fork, or touches nothing under
docs/src/content/orbackend/tests/. - Check auto-mergeable says “Not auto-mergeable” — the log lists the files outside the allowlist. Expected: the PR goes through normal review.
- Merge PR fails at the token step — the App ID variable or private-key secret is missing or wrong, or the App was uninstalled.
- Merge PR fails at the merge step — the bypass was removed or switched mode, or the branch moved after the check (a new run follows).
Adding a Category
Section titled “Adding a Category”New categories (for example frontend tests) go into auto-merge.yml with their own path allowlist and merge condition — never into a separate workflow, because every workflow that can reach the auto-merge environment is another way to merge without review.
Before adding one, decide:
- Merge immediately, or after a check? Docs and backend tests merge immediately and rely on the author having validated locally. A category could instead wait for its CI suite (for example by triggering on the completion of a workflow with
workflow_run), at the cost of a slower merge. Frontend Jest tests have no CI workflow yet, so nothing would ever re-check them after merging. - Which files are truly in the category? Files that change behaviour beyond a single item stay on the default path — like
backend/tests/Pest.php,TestCase.php,phpunit.xml, and factories (also used by seeders). - Should it skip the tag? Add the same paths to
paths-ignoreintag-on-merge.ymlonly if they never affect what ships.
Changes to auto-merge.yml itself always take the default path, so every new category gets a human review.
Related Documentation
Section titled “Related Documentation”- Deployment — the build and deploy chain that follows a default-path merge
- Pull Request Creation — the
/pr-createcommand and its local quality gates