GitHub Repository Rules

1. Overview

Every EMS repository enforces the same rule on its integration branch: a change reaches develop only through a pull request whose test suite has reported green. This page is the normative statement of that rule — what must be configured, what must not be, and why each choice is load-bearing.

The rule exists because the alternative was the default. A repository whose workflows all trigger on push runs its suite for the first time after the merge has already landed, on the branch releases are cut from. A reviewer then has no evidence of correctness beyond the author’s word about a local run, and a red suite is discovered on the shared branch rather than on the contributor’s. Which repositories were in that state, and what a green tick means in each, is tracked in Which Test Suites Actually Run.

Two documents sit either side of this one: GitHub Actions CI/CD describes the workflow architecture, and CI and Release Pipeline Traps collects the pipeline failures that are not code problems.

2. The standard

A conforming repository has all four of the following. Each is expanded in the sections below.

Requirement Value

A workflow that runs the suite on pull_request

.github/workflows/pr-non-main.yml, publishing the status context PR checks

Branch protection on develop

Required status check PR checks; strict: false; no review requirement

Branch protection on main

Not gated on PR checks

Administrator enforcement

enforce_admins: false, with a RELEASE_PAT secret on any repository that cuts releases

2.1. The required status check on develop

develop requires exactly one status context, PR checks, and requires it in the non-strict form.

strict: true — GitHub’s "require branches to be up to date before merging" — is deliberately not used. It forces every pull request to rebase onto the latest develop and re-run the suite before it may merge. With a suite measured in double-digit minutes, two pull requests ready at the same time serialise into a rebase-and-wait queue where the second is invalidated by the first merging. The protection it buys — catching semantic conflicts between changes that merge cleanly but interact badly — is not worth that cost at this repository’s rate of change.

2.2. main is deliberately not gated

main receives merges from release and hotfix branches, driven by gitflow:release-finish and gitflow:hotfix-finish. Those merges must not be gated on a pull request check:

  • The code reaching main has already passed the check on its way into develop.

  • The gitflow plugin merges, tags and pushes non-interactively. A required check on main blocks it outright, leaving a half-finished release — the state that Development Workflow describes recovering from by hand.

Gating main therefore adds no assurance and introduces a class of release failure that has to be repaired manually.

2.3. No approving review is required

Neither required_approving_review_count nor require_code_owner_review is set.

This is a deliberate reversal of an earlier standard, and the reason is worth recording, because the earlier version failed silently rather than visibly. A rule requiring one approving review cannot be satisfied by a sole maintainer — GitHub does not permit approving one’s own pull request. Configured as required, it makes every pull request permanently unmergeable; configured with an administrator bypass, it is satisfied by the one person it was written to constrain. The observed outcome was neither: the rulesets carrying it were created and then left at enforcement: disabled, so the repositories ran with no protection at all while appearing, to anyone reading the configuration, to be protected.

A machine-checkable gate does not have that failure mode. The suite either ran and passed or it did not.

Automated reviewers remain valuable and remain advisory. A CoPilot or bot review that raises a real defect should be addressed on its merits, not because a rule compels it.

2.4. CODEOWNERS

Every repository carries .github/CODEOWNERS. With no review requirement in force its effect is to request reviewers automatically, not to block. It is worth keeping accurate: the request is how a reviewer learns a change touching their area exists.

# Default owner for all files (catch-all, lowest priority)
* @christhonie

# Specific patterns override it; later patterns win
/src/main/helm/   @devops-team

3. The PR checks contract

pr-non-main.yml is not free-form. Three properties make the difference between a gate and the appearance of one, and each was arrived at by a failure.

3.1. One aggregating job, always reporting

Branch protection requires a named status context. Requiring a test job directly — Back-end / ✅ Test — does not work, because a path filter legitimately skips that job on a documentation-only change. A required context that is skipped either blocks a good pull request or is treated as satisfied, depending on how the skip is reported.

The workflow therefore ends in a single job named PR checks that always runs, inspects the results of its dependencies itself, and exits non-zero unless every one of them either succeeded or was legitimately skipped. That job — and only that job — is the required context.

3.2. No paths-ignore on the trigger

The pull_request trigger carries no paths-ignore. A required check that never runs leaves a pull request pending forever rather than mergeable, so a documentation-only or chart-only change would become unmergeable if the workflow declined to start.

Filtering happens one level down instead: a paths-filter job decides whether the expensive test job executes, that job skips when irrelevant, and PR checks still reports success. The cost of the always-on run is one short job.

This is the opposite of the convention in the push workflows, which keep their paths-ignore precisely because nothing requires their checks.

3.3. if: always() on the aggregator

Without always(), a failed dependency causes the aggregating job to be skipped too. The required check then reports "skipped", which branch protection can treat as satisfied — a gate that opens exactly when a job fails. That is worse than no gate, because it looks like protection.

  pr-checks:
    name: 'PR checks'
    needs: [paths-filter, test-be]
    if: always()          (1)
    runs-on: ubuntu-latest
1 Load-bearing. Removing this inverts the gate.

3.4. Full suite, no tier split

A conforming workflow runs the whole suite on every pull request that touches code. Splitting it into a fast tier that gates and a slow tier that does not would let a change which passes the fast tier and fails the slow one reach develop — the precise outcome the gate exists to prevent.

Where suite runtime is a concern, the answer is to make the suite faster, not to narrow what the gate covers. Note also that Maven already fails fast without any pipeline machinery: surefire runs before failsafe in the same invocation, so a broken unit test ends the run long before the integration suite starts.

4. Interaction with the release automation

This is the part that breaks a repository if it is missed.

gitflow:release-finish and gitflow:hotfix-finish push directly to main and develop — they merge, tag, bump the version and push, without opening a pull request. Branch protection blocks direct pushes. Two settings keep the automation working.

4.1. enforce_admins stays false

With enforce_admins: false, a repository administrator can still push directly to a protected branch. This is what allows the release automation to complete.

The trade-off is explicit: the same setting lets an administrator merge a pull request whose checks are red. The gate is therefore fully binding on automation and on any non-administrator, and advisory for an administrator who deliberately overrides it. Setting enforce_admins: true would make the gate absolute and break every release, which is not a trade worth making.

4.2. RELEASE_PAT

The default GITHUB_TOKEN pushes as github-actions[bot], which is not an administrator and is therefore blocked by protection regardless of enforce_admins.

Any repository that cuts releases and has a protected develop needs a RELEASE_PAT secret — a personal access token with Contents: read+write belonging to an administrator. The shared release workflow selects it automatically and falls back to GITHUB_TOKEN:

token: ${{ secrets.RELEASE_PAT || github.token }}

The fallback means the omission is silent until a release is cut. Add RELEASE_PAT before enabling protection, not after — otherwise the first release after the change fails part-way through and has to be finished by hand.

Where the repository also runs the shared copilot-review.yml workflow, the same token needs Pull requests: write in addition to Contents: read+write — requesting a reviewer is a pull request write, not a contents write. A classic PAT carrying the repo scope already covers both. See GitHub Actions CI/CD for why that workflow needs a PAT rather than GITHUB_TOKEN.

5. Applying the standard to a repository

The order matters: the check must exist and have reported at least once before it is made required, or the first pull request after the change blocks on a context GitHub has never seen.

  1. Add pr-non-main.yml, adapted from a conforming repository. Confirm the job names — the aggregator must be name: 'PR checks'.

  2. Confirm .github/CODEOWNERS exists.

  3. Add the RELEASE_PAT secret if the repository cuts releases.

  4. Open a pull request and let PR checks report green once.

  5. Sweep the pull requests that are already open, before enabling protection. A pull_request workflow only runs when an event fires, so every pull request opened before the workflow landed — and not pushed to since — has no PR checks context and never will on its own. Enabling protection blocks all of them at once, on a check that cannot appear.

    gh pr list --repo OWNER/REPO --state open --json number --jq '.[].number' |
      while read n; do
        printf '#%s ' "$n"
        gh pr checks "$n" --repo OWNER/REPO 2>/dev/null | grep -q 'PR checks' \
          && echo 'ok' || echo 'MISSING — will block'
      done

    Trigger the missing ones by closing and reopening each, which fires reopened without adding a commit or invalidating review history:

    gh pr close <n> --repo OWNER/REPO && sleep 5 && gh pr reopen <n> --repo OWNER/REPO

    The pause matters: reopening before the close has registered produces no state transition, and therefore no event and no run. Verify the state actually changed rather than assuming it did.

    Budget for it — each triggered pull request runs the full suite, and these are metered private repositories. On a repository with a long tail of stale automated pull requests, decide which are worth the minutes rather than firing all of them.

  6. Enable protection on develop:

    gh api repos/OWNER/REPO/branches/develop/protection \
      --method PUT --input - <<'EOF'
    {
      "required_status_checks": { "strict": false, "contexts": ["PR checks"] },
      "enforce_admins": false,
      "required_pull_request_reviews": null,
      "restrictions": null,
      "allow_force_pushes": false,
      "allow_deletions": false
    }
    EOF

    Every key in that payload is required by the API; omitting one is rejected rather than defaulted.

  7. Verify:

    gh api repos/OWNER/REPO/branches/develop/protection \
      --jq '{checks: .required_status_checks.contexts,
             strict: .required_status_checks.strict,
             admins: .enforce_admins.enabled}'

    Expected: {"checks":["PR checks"],"strict":false,"admins":false}.

5.1. Repositories without a test suite

A repository with nothing to run does not get a PR checks context, and develop is left unprotected rather than gated on a check that will never report. Documentation repositories are the usual case.

5.2. Rulesets versus classic branch protection

Both mechanisms can express this standard; the repository-level classic branch protection API shown above is the one in use, and mixing the two is what makes protection state hard to read. When auditing a repository, check both — a repository can carry a ruleset that appears to protect it while sitting at enforcement: disabled:

gh api repos/OWNER/REPO/rulesets --jq '.[] | "\(.enforcement)\t\(.name)"'
gh api repos/OWNER/REPO/branches/develop/protection --jq '.required_status_checks.contexts'

A ruleset at disabled enforces nothing. It is not a partial protection and it is not a warning mode.

An active ruleset can be just as quiet. The rule Automatically request Copilot code review requests the review on the pull request author’s behalf, and does nothing at all when that author holds no Copilot entitlement — no review, no error, no failed check, and nothing in the pull request timeline to show it was tried. EMS requests Copilot reviews from a workflow running under RELEASE_PAT instead, so that the entitlement spent belongs to the token owner rather than the author. See GitHub Actions CI/CD.

6. Troubleshooting

6.1. A pull request is stuck on an expected check that never appears

The required context name does not match any job the workflow produces. The context is the job’s name:, not its key. Compare against a run:

gh pr checks <pr-number> --repo OWNER/REPO

6.2. A pull request merged with a red suite

Check whether the aggregating job carries if: always(). Without it the job is skipped when a dependency fails, and the required check reports "skipped" rather than failure.

6.3. A release failed part-way, leaving main tagged but develop un-bumped

The release automation was blocked pushing to a protected branch. Confirm RELEASE_PAT exists and belongs to an administrator, then finish the branch by hand — see Development Workflow.

6.4. Jobs queue and cancel at fifteen minutes

Not a protection problem. See Private-repo runner starvation in CI and Release Pipeline Traps: private repositories draw on a metered Actions budget, and queued jobs cancel once it is exhausted.