From 6fe5156156371d36d18d9f8b9fa7ac79f376121a Mon Sep 17 00:00:00 2001 From: Serge Gatezh <2880401+gatezh@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:19:22 -0600 Subject: [PATCH 1/2] fix(ci): make Renovate auto-merge actually fire `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). --- .github/renovate.json5 | 16 ++++++++++------ .github/workflows/ci.yml | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index d0d24c4..9c4fbf4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -29,11 +29,15 @@ }, { // Group the four tools into one PR and auto-merge once CI passes. - // platformAutomerge:false => Renovate performs the merge itself only - // after it observes the branch tests are green, so CI gating needs no - // branch-protection rule. (Optional hardening: enable branch protection - // requiring the CI checks and set platformAutomerge:true for native - // GitHub auto-merge.) + // platformAutomerge:true => GitHub's native auto-merge merges the PR as + // soon as the required checks go green, with no further Renovate run + // needed. That is load-bearing here: @anthropic-ai/claude-code ships ~2 + // releases/day, so with platformAutomerge:false every Renovate run found a + // newer version, force-pushed the branch (resetting CI to pending) and + // ended seconds later -- no run ever observed a green branch, and nothing + // was ever merged (#121 sat open and green for 3.5 weeks). + // Requires: repo setting "Allow auto-merge" enabled, and branch protection + // on master requiring the "CI complete" check (.github/workflows/ci.yml). matchPackageNames: [ 'rtk-ai/rtk', 'umputun/ralphex', @@ -42,7 +46,7 @@ ], groupName: 'devcontainer agent tools', automerge: true, - platformAutomerge: false, + platformAutomerge: true, }, ], } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c559235..34d21a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,10 @@ name: CI on: pull_request: branches: [master] - paths: - - '**/Dockerfile' - - '**/*.sh' - - '.github/workflows/**' - - '.hadolint.yaml' + # Deliberately unfiltered: "CI complete" below is the required status check + # for master, and a required check that never runs blocks a PR forever -- a + # docs-only PR would deadlock. Image builds are still skipped per-image via + # detect-changes; the always-on jobs are lint-only and take seconds. concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} @@ -194,3 +193,29 @@ jobs: IMAGE_TAG: ${{ matrix.image }}:test VERIFY_CMD: ${{ matrix.verify }} run: docker run --rm --entrypoint sh "$IMAGE_TAG" -c "$VERIFY_CMD" + + # -- Single required status check for branch protection ------------------ + # Aggregates every job above so master needs exactly one required check. + # Passes when each job succeeded or was legitimately skipped (path-filtered + # builds), and fails if any job failed or was cancelled. + ci-complete: + name: CI complete + if: always() + needs: [lint-dockerfiles, lint-workflows, detect-changes, build-and-verify] + runs-on: ubuntu-latest + steps: + - name: Check results of all CI jobs + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "Job results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success | skipped) ;; + *) + echo "::error::A CI job finished with result '$result'" + exit 1 + ;; + esac + done + echo "All CI jobs passed or were skipped." From 130654ae998f35182379d1d13c63f2795fce82e8 Mon Sep 17 00:00:00 2001 From: Serge Gatezh <2880401+gatezh@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:36:15 -0600 Subject: [PATCH 2/2] fix(ci): drop redundant platformAutomerge, soak non-claude bumps, guard 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. --- .github/renovate.json5 | 33 ++++++++++++++++++++--------- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 9c4fbf4..ec3a5e4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -29,15 +29,17 @@ }, { // Group the four tools into one PR and auto-merge once CI passes. - // platformAutomerge:true => GitHub's native auto-merge merges the PR as - // soon as the required checks go green, with no further Renovate run - // needed. That is load-bearing here: @anthropic-ai/claude-code ships ~2 - // releases/day, so with platformAutomerge:false every Renovate run found a - // newer version, force-pushed the branch (resetting CI to pending) and - // ended seconds later -- no run ever observed a green branch, and nothing - // was ever merged (#121 sat open and green for 3.5 weeks). - // Requires: repo setting "Allow auto-merge" enabled, and branch protection - // on master requiring the "CI complete" check (.github/workflows/ci.yml). + // + // Relies on Renovate's default platformAutomerge:true — GitHub's native + // auto-merge merges on green with no second Renovate run. The previous + // explicit platformAutomerge:false is what broke this: only a Renovate + // run could merge, and every run found a newer claude-code, force-pushed + // the branch and ended before CI finished (#121 sat green for 3.5 weeks). + // + // Requires: repo setting "Allow auto-merge", and a ruleset on master + // requiring the "CI complete" check (.github/workflows/ci.yml). Without + // that required check nothing blocks the PR, GitHub never offers native + // auto-merge, and Renovate silently falls back to the broken path. matchPackageNames: [ 'rtk-ai/rtk', 'umputun/ralphex', @@ -46,7 +48,18 @@ ], groupName: 'devcontainer agent tools', automerge: true, - platformAutomerge: true, + + // These bumps merge unreviewed and publish straight to ghcr.io, so let a + // release soak before adopting it. internalChecksFilter defaults to + // 'strict', so a too-young version is simply not offered yet — the group + // PR carries whichever tools are currently eligible. + minimumReleaseAge: '3 days', + }, + { + // ...except claude-code, which is tracked at latest deliberately. + // Later packageRules win, so this clears the soak period above. + matchPackageNames: ['@anthropic-ai/claude-code'], + minimumReleaseAge: null, }, ], } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d21a2..08e8c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,24 +198,47 @@ jobs: # Aggregates every job above so master needs exactly one required check. # Passes when each job succeeded or was legitimately skipped (path-filtered # builds), and fails if any job failed or was cancelled. + # + # Any job added to this workflow must also be added to `needs` below: this is + # the only required check on master and Renovate auto-merges on it, so an + # unwatched job would go red while the gate stayed green. The first step + # enforces that rather than trusting anyone to remember. ci-complete: name: CI complete + # always(), not !cancelled(): GitHub counts a *skipped* required check as + # passing, so !cancelled() would let a cancelled run report a green gate. if: always() needs: [lint-dockerfiles, lint-workflows, detect-changes, build-and-verify] runs-on: ubuntu-latest steps: - - name: Check results of all CI jobs + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Verify every job is watched env: - RESULTS: ${{ join(needs.*.result, ' ') }} + WATCHED: ${{ toJSON(needs) }} run: | - echo "Job results: $RESULTS" - for result in $RESULTS; do - case "$result" in - success | skipped) ;; - *) - echo "::error::A CI job finished with result '$result'" - exit 1 - ;; - esac + missing="" + for job in $(yq '.jobs | keys | .[]' .github/workflows/ci.yml); do + [ "$job" = "ci-complete" ] && continue + jq -e --arg j "$job" 'has($j)' <<< "$WATCHED" > /dev/null || missing="$missing $job" done + if [ -n "$missing" ]; then + echo "::error::Jobs missing from ci-complete.needs:$missing" + exit 1 + fi + + - name: Check results of all CI jobs + env: + WATCHED: ${{ toJSON(needs) }} + run: | + jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$WATCHED" + bad=$(jq -r ' + to_entries[] + | select(.value.result != "success" and .value.result != "skipped") + | "\(.key) (\(.value.result))"' <<< "$WATCHED") + if [ -n "$bad" ]; then + echo "::error::CI jobs did not pass: $(echo "$bad" | tr '\n' ' ')" + exit 1 + fi echo "All CI jobs passed or were skipped."