Skip to content

Branch Protection and Renovate Auto Merge

Serge Gatezh edited this page Sep 9, 2026 · 6 revisions

Branch Protection and Renovate Auto-Merge

How master requires the CI complete check so Renovate's grouped agent-tool PRs merge themselves on green.

Status: live. Ruleset 22666492 is active as of 2026-09-09. The steps below are kept so it can be rebuilt, audited, or rolled back — you do not need to run them again.


What this sets up

Piece Where it lives State
CI complete aggregate job .github/workflows/ci.yml on master (added in #126)
automerge: true on the agent-tools group .github/renovate.json5 on master
Allow auto-merge repo setting Settings → General enabled
Ruleset requiring CI complete ruleset 22666492 active

Renovate relies on its default platformAutomerge: true, which uses GitHub's native auto-merge. Native auto-merge is only offered on a PR that is blocked by something. With no ruleset, every PR is CLEAN the moment it opens, GitHub never offers auto-merge, and Renovate silently falls back to merging from its own run — which is the race that left #121 open and green for 3.5 weeks. The ruleset is what makes the whole mechanism work.


Step 1 — Confirm Allow auto-merge is on

gh api repos/gatezh/devcontainers --jq '.allow_auto_merge'
# expect: true

If it returns false: Settings → General → Pull Requests → Allow auto-merge.


Step 2 — Drain every PR that predates CI complete

Do this before step 3.

A required status check that never appears blocks a PR forever. Any PR whose head commit branched before CI complete existed in ci.yml produces no such check, so once the ruleset is active it can never satisfy it.

# List open PRs and whether their head SHA has a "CI complete" check
for pr in $(gh pr list --state open --json number --jq '.[].number'); do
  has=$(gh pr view "$pr" --json statusCheckRollup \
        --jq '[.statusCheckRollup[]?.name] | index("CI complete") != null')
  echo "PR #$pr  CI complete present: $has"
done

For every PR reporting false, either merge it now, or rebase it onto a master that already contains CI complete so a fresh run produces the check.

This applies every time the check is renamed, too — changing the job's name: in ci.yml orphans every open PR in exactly the same way, and the ruleset has to be updated to match.


Step 3 — Create the ruleset

Use a ruleset, not a classic branch protection rule. Rulesets are GitHub's current mechanism and are free on public repos.

Create it from the API rather than the UI — it is reproducible, reviewable, and does not depend on click-paths that GitHub's own docs describe inaccurately.

3.1 — Create it disabled

enforcement: "disabled" creates the ruleset without enforcing anything, so this is safe to run and inspect first.

cat > /tmp/ruleset.json <<'JSON'
{
  "name": "master",
  "target": "branch",
  "enforcement": "disabled",
  "bypass_actors": [
    { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }
  ],
  "conditions": {
    "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
  },
  "rules": [
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "do_not_enforce_on_create": false,
        "required_status_checks": [{ "context": "CI complete" }]
      }
    }
  ]
}
JSON

gh api --method POST repos/gatezh/devcontainers/rulesets --input /tmp/ruleset.json

Every field above was verified against the live API on 2026-09-09:

Field Value Why
~DEFAULT_BRANCH accepted Targets master and survives a default-branch rename
actor_type: RepositoryRole, actor_id: 5 Repository admin Rulesets do not exempt admins automatically. The response confirms it with "current_user_can_bypass": "always". GitHub does not document the numeric role ids
strict_required_status_checks_policy: false off true forces a rebase-and-recheck on every PR, reintroducing the race this setup exists to avoid
do_not_enforce_on_create: false off Only exempts branch/repo creation from the check. Irrelevant when targeting an existing default branch
required_status_checks: [{"context": "CI complete"}] the gate Must match the job's name: in ci.yml, not the job id ci-complete

Do not add Renovate as a bypass actor. It has to stay subject to the check — waiting for CI complete is the entire mechanism.

3.2 — Confirm what GitHub stored

gh api repos/gatezh/devcontainers/rulesets \
  --jq '.[] | {id, name, enforcement}'

gh api repos/gatezh/devcontainers/rulesets/<ID> \
  --jq '{enforcement, current_user_can_bypass,
         bypass: [.bypass_actors[] | {actor_type, actor_id, bypass_mode}],
         checks: [.rules[] | select(.type=="required_status_checks") | .parameters]}'

3.3 — Turn it on

Only after step 2 reports no open PR missing CI complete.

gh api --method PUT repos/gatezh/devcontainers/rulesets/<ID> -f enforcement=active

Rollback is the same call with enforcement=disabled — the ruleset stays configured but stops gating.

A partial PUT that sends only enforcement is safe: verified on a throwaway ruleset that the rules, bypass actors, and target conditions all survive untouched.

3.4 — Seeing it in the UI

The live ruleset: https://github.com/gatezh/devcontainers/rules/22666492

Or navigate: Settings → sidebar under Code, planning, and automation → Rules → Rulesets.

Its Rule Insights tab lists every push and merge the ruleset evaluated, and whether it passed, failed, or was bypassed. That is the fastest way to see the ruleset actually doing something.


Workflows cannot push to master

update-and-build-ralphex-fe.yml used to push a version bump straight to master as github-actions[bot] — the repo's only direct-push path, and unfixable by bypass because GitHub Actions cannot be a bypass actor. The picker offers repository roles and installed GitHub Apps; Actions is built into GitHub, so it never appears.

Deleted in #134. ARG BUN_VERSION and ARG HUGO_VERSION in ralphex-fe/Dockerfile now carry # renovate: annotations, so those bumps arrive as PRs like every other update. Manual rebuilds remain available via gh workflow run build-ralphex-fe.yml.

If you add another workflow that pushes to master, it will be blocked and there is no bypass for it. Have it open a PR instead.


Step 4 — Verify

gh api repos/gatezh/devcontainers/rulesets/22666492 --jq \
  '{enforcement, can_bypass: .current_user_can_bypass,
    target: .conditions.ref_name.include,
    bypass: [.bypass_actors[] | "\(.actor_type)/\(.actor_id) \(.bypass_mode)"],
    checks: [.rules[] | select(.type=="required_status_checks")
             | .parameters.required_status_checks[].context]}'

Current expected output:

{
  "enforcement": "active",
  "can_bypass": "always",
  "target": ["~DEFAULT_BRANCH"],
  "bypass": ["RepositoryRole/5 always"],
  "checks": ["CI complete"]
}

A PR reports BLOCKED while CI is running and CLEAN once CI complete passes.

The real end-to-end test is the next Renovate bump: it should open, go green, and merge with no Renovate run in between and no manual click.


Troubleshooting

A PR sits at BLOCKED forever and shows no CI complete check. Its head commit predates the ci-complete job. Rebase it onto current master. This is what step 2 prevents.

A workflow fails at git push to master with a protected-branch rejection. There is no bypass for github-actions[bot] — see Workflows cannot push to master. Change the workflow to open a PR.

Your own git push to master is rejected. The Repository admin bypass is missing. Rulesets do not exempt admins by default.

Renovate PRs go green but still don't merge. Check in this order:

  1. gh api repos/gatezh/devcontainers --jq '.allow_auto_merge' → must be true.
  2. The ruleset is Active, not Disabled.
  3. The required check name is exactly CI complete — it must match the job's name: in ci.yml, not the job id ci-complete.
  4. The Mend portal. Commit 88a2161 records that portal toggles can override a correct renovate.json5. If PRs are being created, Silent mode is off, but automerge can still be overridden there.

CI complete fails with Jobs missing from ci-complete.needs: <name>. Working as intended. Someone added a job to ci.yml without adding it to ci-complete's needs list. Add it. CI complete is the only required check, so an unwatched job would otherwise fail while the gate stayed green.


Why the gate is shaped this way

  • One aggregate check, not N required checks. Per-image builds are path-filtered by detect-changes, so most of them are legitimately skipped on any given PR. Requiring them individually would deadlock every PR that doesn't touch that image.
  • CI complete accepts success or skipped. That is what lets the path-filtered builds skip without blocking.
  • ci.yml has no paths: filter on on: pull_request. A required check that never runs blocks a PR forever, so CI must trigger on every PR. The always-on jobs are lint-only and finish in seconds.
  • The job uses if: always(), not if: !cancelled(). GitHub counts a skipped required check as passing. With !cancelled(), a cancelled run would skip ci-complete, and the ruleset would read that skip as green.
  • Repository admin is on the bypass list. Rulesets do not exempt admins automatically, unlike classic protection's enforce_admins=false. Without it your own direct pushes to master are blocked.
  • Renovate is not on the bypass list. It must stay subject to CI complete — waiting for that check is the entire mechanism.
  • "Require branches to be up to date" is off. Ticking it makes checks strict: every PR must be rebased onto the newest master first, re-running CI on each rebase and reintroducing a variant of the race this setup exists to avoid.
  • No required reviews. Renovate cannot approve its own PR, so a review requirement would block every bump permanently.
  • minimumReleaseAge: '3 days', except claude-code. These bumps merge unreviewed and publishing follows automatically, so non-claude tools soak for three days first. @anthropic-ai/claude-code is tracked at latest deliberately.

References


Sources

  • #116 — introduced automerge: true for the agent-tools group
  • #121 — the bump PR that sat open and green for 3.5 weeks, exposing the race
  • #123 — the docs-only PR that would have deadlocked under a path-filtered CI
  • #126 — origin of this page: the CI complete aggregate check, dropped path filters, and the auto-merge fix

Why this page uses the API and not click-paths: GitHub's documentation lags its settings UI — it described the sidebar section as "Code and automation" (it reads "Code, planning, and automation") and listed Target branches before the Bypass list (the editor shows Bypass first). Instructions written from those docs were wrong three times. Every command on this page was run against this repository instead, so it is reproducible and checkable rather than transcribed.

Last verified: 2026-09-09 — every gh command executed against gatezh/devcontainers; ruleset payload confirmed by reading back what the API stored.