Skip to content

PR Merge Paths

Every pull request to master takes one of two paths, decided only by which files it changes:

  1. Default path — code review, automated checks, one human approval, and a version tag (plus image build) on merge.
  2. 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.

The path for any PR that changes code, configuration, workflows, or anything outside docs/.

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

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.

  1. Tag on Merge (tag-on-merge.yml) creates the next semantic version tag from the commit type (feat → minor, BREAKING CHANGE in the body → major, everything else → patch) and dispatches the image build on that tag.
  2. Build & Push to ECR builds the images tagged with that version.
  3. 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/.

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

  1. The PR is opened (or updated, reopened, or marked ready for review).
  2. 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.
  3. 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.
  4. Job Merge PR mints a short-lived token for the volare-auto-merge GitHub App and squash-merges the PR. The PR shows as merged by app/volare-auto-merge.
  5. The push to master does not trigger Tag on Merge (it ignores docs/** and backend/tests/**), so no tag and no image build. The docs site still publishes through its Cloudflare Pages project, which deploys on every push to master; a test merge runs Backend Tests on master.

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

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 on pull_request_target, which always runs the workflow file from the base branch. A PR cannot loosen the allowlist by editing auto-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 the auto-merge environment, which only master can 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.

A one-time configuration done on 2026-10-01 by an organization owner. Recorded here so it can be audited, rotated, or rebuilt.

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.

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.

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

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.

  • .github/workflows/auto-merge.yml — the gate and the merge (added in #2343; backend tests category added later).
  • paths-ignore for docs/** (#2341) and backend/tests/** on tag-on-merge.yml, claude-code-review.yml and pr-title-check.yml.
  1. On the App’s page, Generate a private key.
  2. Replace the AUTO_MERGE_APP_PRIVATE_KEY secret in the auto-merge environment.
  3. Delete the old key from the App’s page.

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-merge from the ruleset bypass list (merges then fail on the review requirement).
  • Suspend the App’s installation in the organization settings.

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/ or backend/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).

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-ignore in tag-on-merge.yml only 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.