Skip to content

fix(ci): make Renovate auto-merge actually fire - #126

Merged
gatezh merged 2 commits into
masterfrom
fix/renovate-automerge
Sep 9, 2026
Merged

gatezh merged 2 commits into
masterfrom
fix/renovate-automerge

Conversation

@gatezh

@gatezh gatezh commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

Warning

This PR changes nothing on its own. It only becomes effective once CI complete is a required check on master (step 2 below). Until then master has no protection at all, every Renovate PR is CLEAN from the moment it opens, GitHub never offers native auto-merge, and Renovate silently falls back to the same broken path this PR is fixing. Merging this and stopping is a no-op.

automerge: true has never merged a PR since it was added in #116. #121 sat open, green and CLEAN for 3.5 weeks before being merged by hand; #124 is on the same path right now.

Root cause

Not a permissions or config-matching problem — everything on the GitHub side checked out clean:

Check Result
Did the packageRule apply? Yes — the PR carried its groupName, so automerge: true was in effect
PR mergeable? mergeable=MERGEABLE, mergeStateStatus=CLEAN
All 11 checks on the head SHA? All green
Branch protection? None
Merge methods? merge, squash, rebase all allowed
Renovate errors? None — the Dependency Dashboard listed the PR plainly

It's a race created by platformAutomerge: false. That setting means only a Renovate run can merge, and a run merges when it observes an already-green branch. But @anthropic-ai/claude-code ships ~2 releases/day (2.1.216 → 2.1.263 = 47 releases in 23 days) while Renovate runs every 2–9 days — 6 force-pushes over the life of #121. So every run found a newer version, force-pushed the branch, reset CI to pending, and ended.

The last run is typical:

19:56:39  Renovate pushes new head commit
19:56:45  Renovate's run ends          <- 8s before the first check finishes
19:56:53  first check goes green
19:59:40  last build goes green        <- nobody is watching any more

The run that could merge is always the run that just invalidated CI.

minimumReleaseAge does not fix this at any threshold — by the next run there are always newly-eligible versions, so the force-push repeats. (It is added below anyway, for an unrelated reason.)

The fix

Let GitHub's native auto-merge do it, so the merge happens on green with no Renovate run involved. It's immune to the race, and it's also the freshest option — zero version-age delay.

That means simply deleting platformAutomerge: false: true is Renovate's own default (renovate-schema.json → platformAutomerge.default = true), so the explicit setting was the whole bug.

Native auto-merge needs something to wait for (a required check), and a required check that never runs blocks a PR forever. CI was path-filtered at the on: level, so a docs-only PR triggered no CI at all — #123 touches only README.md and docs/*.md and would deadlock permanently. Hence:

  • Drop the paths: filter from on: pull_request.
  • Add one CI complete job aggregating the others, passing on success-or-skipped so path-filtered image builds still don't block.

Per-image builds remain gated by detect-changes; the always-on jobs are lint-only and take seconds.

Hardening (second commit)

Supply chain. These bumps merge unreviewed and the merge publishes straight to ghcr.io with packages: write. CI only runs --version smoke checks, which would not notice a malicious release. Added minimumReleaseAge: '3 days' as a soak period, with a second packageRule clearing it back to null for @anthropic-ai/claude-code, which is tracked at latest on purpose. internalChecksFilter defaults to 'strict', so a too-young version is simply never offered — the group PR carries whichever tools are currently eligible, and claude-code is never delayed.

The gate itself. ci-complete is about to be the only thing standing between a red job and an unattended merge, so:

  • A job added to this workflow but forgotten in needs would fail while the gate stayed green. The first step now derives the job list from the workflow file with yq and fails on drift, instead of relying on anyone remembering.
  • join(needs.*.result, ' ') discarded the job names, so a failure only ever said 'failure'. Iterating toJSON(needs) keeps the ids — the error now names the job and its result.
  • Recorded why the job uses always() and not !cancelled(): GitHub counts a skipped required check as passing, so !cancelled() would let a cancelled run report a green gate.

Verification

  • actionlint — exit 0, zero findings.
  • renovate-config-validator (official image, validated as repo config) — "Config validated successfully".
  • Both jq filters and the yq job-list extraction exercised locally against success / skipped / failure / cancelled fixtures, including a simulated unwatched job.
  • This PR is its own test: CI complete should appear as a check and pass here before it's ever made required.

Merge order matters

  1. Merge this PR first — CI complete cannot be a required check until it exists on master.
  2. Then drain every PR that predates it. chore(deps): update devcontainer agent tools #124 and docs(rtk): record RTK_TELEMETRY_DISABLED as the supported opt-out (#117) #125 branched before ci-complete existed, so their head SHAs produce no CI complete check at all — once the ruleset is active they can never satisfy it and would deadlock exactly the way docs: add Docker disk usage runbook and maintenance cheatsheet #123 would have. Merge (or rebase) them before step 3, not after.
  3. Then create a ruleset on master (not a classic branch protection rule — see below) requiring only CI complete, strict policy off, and no required reviews (those would block Renovate, which cannot approve its own PR).
  4. Allow auto-merge is already enabled on the repo (it was false, which would have silently defeated this change).

Everything opened after that auto-merges on green.

Why a ruleset, and why it needs a bypass actor

update-and-build-ralphex-fe.yml:119 pushes a version-bump commit directly to master as github-actions[bot]. ci.yml only triggers on pull_request, so no pushed commit can ever carry a CI complete status — a required-status-check rule would make that push unsatisfiable. Classic branch protection cannot exempt it (enforce_admins=false exempts repo admins, not the bot), but a ruleset can name a GitHub App as a bypass actor. Rulesets are free on public repos.

Add to the ruleset's bypass list:

  • GitHub Actions (app id 15368) — keeps the ralphex-fe bump workflow working.
  • Repository admin — rulesets do not auto-exempt admins the way enforce_admins=false did, so without this your own direct pushes to master would be blocked too.

The UI route (Settings → Rules → Rulesets → New branch ruleset) is preferable, because the bypass dropdown avoids hand-writing role IDs. API equivalent for the rule itself:

{
  "name": "master",
  "target": "branch",
  "enforcement": "active",
  "bypass_actors": [
    { "actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always" }
  ],
  "conditions": { "ref_name": { "include": ["refs/heads/master"], "exclude": [] } },
  "rules": [
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "required_status_checks": [{ "context": "CI complete" }]
      }
    }
  ]
}

If the bypass turns out not to be workable, the alternative is to convert update-and-build-ralphex-fe.yml to open a PR rather than push to master — arguably the better end state anyway, since the bump would then flow through CI and auto-merge like every Renovate bump.

Caveat

88a2161 records that Mend portal toggles can override a correct renovate.json5. PRs are being created, so Silent mode is off — but if auto-merge still doesn't fire after this, the portal is the next place to look, not the config.

Note: the commits on this branch are unsigned (authored in an environment without access to the signing key). Squash-merging through GitHub re-creates and signs them, so master's fully-verified history is preserved.

`automerge: true` has never merged a PR since it was added in #116. #121 sat
open, green and CLEAN for 3.5 weeks; #124 was on the same path.

Root cause is a race created by `platformAutomerge: false`. That setting means
only a Renovate run can merge, and a run merges when it observes an
already-green branch. But @anthropic-ai/claude-code ships ~2 releases/day while
Renovate runs every 2-9 days, so every run found a newer version, force-pushed
the branch (resetting CI to pending) and ended seconds later. The last run is
typical: pushed at 19:56:39, run ended 19:56:45, first check went green at
19:56:53, last at 19:59:40 -- nobody was watching. The run that could merge is
always the run that just invalidated CI.

Note this is not fixable with `minimumReleaseAge`: at any threshold there are
still newly-eligible versions by the next run, so the force-push repeats.

Switch to `platformAutomerge: true` so GitHub's native auto-merge merges on
green with no Renovate run involved. This is also the freshest option -- no
version-age delay at all.

Native auto-merge needs something to wait for, i.e. branch protection with a
required check, and a required check that never runs blocks a PR forever. CI is
currently path-filtered at the `on:` level, so a docs-only PR (#123 touches only
README.md and docs/*.md) triggers no CI at all and would deadlock. So drop the
paths filter and add one `CI complete` job aggregating the others, passing on
success-or-skipped so path-filtered builds still don't block. Per-image builds
are still gated by detect-changes; the always-on jobs are lint-only.

Verified with actionlint (exit 0, no findings).
…rd the gate

Review follow-ups on this branch.

platformAutomerge:true is Renovate's own default (renovate-schema.json:
platformAutomerge.default = true), so the explicit setting was noise. Deleted
it and kept only the part of the comment that is still load-bearing: what the
config depends on being configured on the GitHub side.

These bumps merge unreviewed and publish to ghcr.io, so a compromised upstream
release would reach the published images with no human in the loop. Added
minimumReleaseAge: '3 days' as a soak period, with a second packageRule
clearing it for @anthropic-ai/claude-code, which is tracked at latest on
purpose. internalChecksFilter defaults to 'strict', so a too-young version is
never offered and the group PR simply carries whichever tools are eligible.

ci-complete is about to become the only required check on master, gating
unattended merges, so two hardening changes:

- A new job added to this workflow but omitted from `needs` would fail while
  the gate stayed green. The first step now derives the job list from the
  workflow file with yq and fails if `needs` has drifted.
- The failure message named a bare result ('failure') with no job attached.
  Iterating toJSON(needs) instead of join(needs.*.result) keeps the job ids,
  so the error now says which job failed and how.

Also recorded why this job uses always() rather than !cancelled(): GitHub
counts a skipped required check as passing, so !cancelled() would turn a
cancelled run into a green gate.

Verified: actionlint exit 0; renovate-config-validator "Config validated
successfully"; both jq filters and the yq job-list extraction exercised
locally against success/skipped/failure/cancelled fixtures.
@gatezh
gatezh merged commit 2354351 into master Sep 9, 2026
10 checks passed
gatezh added a commit that referenced this pull request Sep 9, 2026
Resolves the renovate.json5 conflict: master (#126) rewrote the
"devcontainer agent tools" rationale to explain why platformAutomerge
must stay at its default and added a 3-day minimumReleaseAge soak with
a claude-code exemption. Keep master's version verbatim and carry over
only this branch's "four tools" -> "five tools" count.

Effect on happy: it inherits the 3-day soak, so a happy release is not
adopted until it has been public for three days.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant