Skip to content

fix(janitor): classify grouped PRs per-dependency, fail-closed (supersedes #23) - #54

Open
asachs01 wants to merge 1 commit into
mainfrom
fix/janitor-group-perdep-rebased
Open

fix(janitor): classify grouped PRs per-dependency, fail-closed (supersedes #23)#54
asachs01 wants to merge 1 commit into
mainfrom
fix/janitor-group-perdep-rebased

Conversation

@asachs01

@asachs01 asachs01 commented Aug 17, 2026

Copy link
Copy Markdown
Member

Supersedes #23, rebased onto post-#36/#38 main. Opened as a new branch rather than force-pushing #23's branch.

This hole is live, not latent

classify() on main still treats the word "group" in a PR title as proof the PR is minor/patch:

if grep -qiE '\bgroup\b' <<<"$title"; then echo ELIGIBLE; return; fi

Measured against the current backlog on 2026-08-17 — of the 118 PRs a dry run would merge, 108 are grouped, and:

Grouped would-merge PRs scanned 108
Containing >=1 cross-major bump 69 (64%)
Distinct repos affected 62
Fetch failures 0

Every one of those 69 would merge without a single dependency being inspected.

is_dev_major's allowlist is not a defence here. classify() returns ELIGIBLE for grouped PRs before the dev-major check runs, so the grouped path is strictly more permissive than the single-package path sitting right beside it. Two of the 69 carry a genuinely runtime, non-allowlisted major:

ironscales-mcp#38    node 22-alpine -> 26-alpine
salesbuildr-mcp#55   node 22-alpine -> 26-alpine

Four majors of Docker base image, straight to production on merge.

Sample of the rest:

abnormal-mcp#50 deps=10 bad=6 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; @semantic-release/github 11.0.6->12.0.9; @types/node 25.9.2->26.2.0; eslint 9.39.4->10.8.1; 
afkbot-mcp#36 deps=3 bad=1 actions/setup-node 6->7; 
atera-mcp#65 deps=10 bad=4 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; eslint 9.39.4->10.8.1; 
autotask-mcp#246 deps=9 bad=3 @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; vite 6.4.3->8.2.1; 
auvik-mcp#50 deps=4 bad=2 @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; 
avanan-legacy-mcp#38 deps=8 bad=2 @semantic-release/changelog 6.0.3->7.0.0; vite 6.4.3->8.2.1; 
avanan-mcp#53 deps=10 bad=5 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; @semantic-release/github 11.0.6->12.0.9; eslint 9.39.4->10.8.1; 
blumira-mcp#41 deps=7 bad=2 @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; 
connectwise-manage-mcp#64 deps=10 bad=5 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; @semantic-release/github 11.0.6->12.0.9; eslint 9.39.4->10.8.1; 
crewhu-mcp#54 deps=7 bad=2 @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; 
datto-rmm-mcp#69 deps=10 bad=6 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; @semantic-release/github 11.0.6->12.0.9; @types/node 20.19.32->26.2.0; eslint 9.39.4->10.8.1; 
datto-rmm-mcp#52 deps=7 bad=4 actions/checkout 4->7; actions/setup-node 4.4.0->7.0.0; docker/login-action 3.7.0->4.6.0; docker/metadata-action 5.10.0->6.2.0; 
datto-saas-protection-mcp#65 deps=10 bad=3 @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; vite 6.4.3->8.2.1; 
domotz-mcp#54 deps=9 bad=5 @eslint/js 9.39.4->10.0.1; @semantic-release/changelog 6.0.3->7.0.0; @semantic-release/git 10.0.1->11.0.1; @semantic-release/github 11.0.6->12.0.9; eslint 9.39.4->10.8.1; 

This is the same hole that broke main via node-datto-rmm#46 on 2026-07-21. #36 responded with a downstream guard, but only for grouped PRs with no CI — a grouped PR with green CI still rode the shortcut untouched.

Two changes beyond #23 as authored

1. Body fetch over REST (gh api), not GraphQL (gh pr view).
Fail-closed is correct for a corrupt body, but during a GraphQL outage every grouped PR fails closed and the whole backlog stalls behind a dependency this classifier doesn't need. Observed live while building this: GraphQL 503'd for hours while REST stayed healthy — 40 of 108 PRs unclassifiable via gh pr view, 0 of 108 via gh api.

**2. Version regex uses [^[:space:]], not [^\[:space:]].** Inside a bracket expression the latter is the literal set { ` [ : s p a c e ] }— it does **not** exclude whitespace, so the capture runs greedy across" to "` and yields nothing usable. Merging #23 as authored would have fail-closed a large share of legitimate grouped PRs.

How the "latent" misdiagnosis happened

Worth recording, because it's the same failure shape as the bug being fixed. My first scan reported all 108 grouped PRs clean. It was running under macOS /bin/bash 3.2, where BASH_REMATCH doesn't populate in this construct — marker detection worked (deps=10), but every version comparison was "" vs "", which compares equal.

A validator that reads nothing and a validator that finds nothing wrong emit identical output. Same shape as #38's vacuous-green checks, and as the 27-day janitor outage that produced zero failed runs. dependabot-janitor.test.sh now refuses to run unless a known-cross-major probe parses first.

Tests

.github/scripts/dependabot-janitor.test.sh — 14 assertions, 0 failures. Fixtures are real Dependabot bodies (abnormal-mcp#50, salesbuildr-mcp#55), not invented shapes, per the CHANGELOG's own lesson about fixtures "tested only against invented shapes that encoded the same wrong assumption."

$ bash .github/scripts/dependabot-janitor.test.sh
...
passed=14 failed=0

Grouped PRs that are genuinely same-major still classify ELIGIBLE — covered by a test.

Expected effect on re-enable

This will reduce the merge set: 69 of the 118 move from auto-merge to the majors bucket for human review. That is the intended outcome, not a regression.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Supersedes #23, rebased onto post-#36/#38 main.

classify() no longer treats "group" in a PR title as proof the PR is
minor/patch. It parses the body's per-dep "Updates `pkg` from A to B" markers
and requires every one to be same-major, failing closed on any cross-major,
unparseable marker, zero markers, or failed body fetch.

This was live, not theoretical. Of the 118 PRs a dry run would merge, 108 are
grouped, and 69 of those 108 (64%) contain a cross-major bump across 62 repos.
is_dev_major's allowlist is not a defence: classify() returned ELIGIBLE for
grouped PRs before the dev-major check ran, making the grouped path strictly
more permissive than the single-package path. Two carry runtime,
non-allowlisted majors -- ironscales-mcp#38 and salesbuildr-mcp#55 both bump
the Docker base image node 22-alpine -> 26-alpine.

Same hole that broke main via node-datto-rmm#46. #36 guarded only grouped PRs
with NO CI; grouped + green CI still rode the shortcut.

Two changes beyond #23 as authored:

- Body fetch over REST (gh api), not GraphQL (gh pr view). Fail-closed is right
  for a corrupt body, but a GraphQL outage would fail-close every grouped PR and
  stall the backlog. Observed live 2026-08-17: GraphQL 503 for hours while REST
  stayed healthy -- 40/108 unclassifiable via gh pr view, 0/108 via gh api.

- Version regex uses [^[:space:]], not [^`[:space:]]. The latter is the literal
  set { ` [ : s p a c e ] } inside a bracket expression and does not exclude
  whitespace, so the capture runs greedy across " to ". A scan built on it, run
  under macOS bash 3.2 where BASH_REMATCH also never populated, reported all 108
  grouped PRs clean -- the false negative that initially mis-classified this as
  latent.

Adds dependabot-janitor.test.sh: 14 assertions, fixtures from real Dependabot
bodies, with a BASH_REMATCH self-check that refuses to run rather than
false-pass under bash 3.2.
@asachs01

Copy link
Copy Markdown
Member Author

Automated review (forge):

This is the highest-stakes diff of the five — it changes classify(), the function that decides whether a Dependabot PR auto-merges across the whole *-mcp fleet. Read the full diff, not just the description, and traced the bash by hand.

Correctness — the fix itself is right:

  • For grouped PRs, body is fetched via gh api repos/$ORG/$repo/pulls/$num --jq '.body // ""' (REST) rather than gh pr view (GraphQL) — correct call given the observed GraphQL outage (40/108 unclassifiable vs 0/108 via REST), and the failure path (|| { echo MAJOR; return; }) fails closed on a fetch error, matching the posture of the rest of classify().
  • Marker-line detection (`(Updates|Bumps)[[:space:]]+``) correctly excludes the non-marker summary line ("Bumps the dev-dependencies group with 10 updates:") since it requires a backtick immediately after the whitespace, which the summary line doesn't have. Verified against the real fixture bodies in the test file by hand.
  • The regex bug being fixed ([^\[:space:]]as a bracket expression is the literal set{, [, :, s, p, a, c, e, ]}, not "not backtick or space") is a real and correctly-diagnosed bug — [^[:space:]]` is the right fix.
  • The while read line; do ... done <<<"$body" loop uses a here-string, not a pipe, so return inside it correctly exits classify() immediately on the first bad marker rather than being trapped in a subshell — this matters and it's done correctly.
  • Fail-closed paths (empty body, zero markers, unparseable marker, partial-parse) are all covered by tests and match the code.
  • major_of() unchanged and still correct (strips leading non-digits via sed).

Process note, not a defect: the "latent vs. live" self-correction in the PR body (macOS bash 3.2 not populating BASH_REMATCH in this construct, producing a false "all clean" on the first pass) is a good catch, and the dependabot-janitor.test.sh self-check that refuses to run under a bash where the probe doesn't populate is a solid guard against silently re-shipping that exact failure mode.

CI: actionlint is green on both runs, but add-to-project shows a FAILURE conclusion in the status rollup — worth a quick look before merge to confirm that's the known unrelated automation flakiness (permissions/token issue on that workflow) rather than something specific to this PR, since I can't distinguish the two from the API response alone.

Tests: 14 assertions in dependabot-janitor.test.sh, using real captured Dependabot bodies (abnormal-mcp#50, salesbuildr-mcp#55) rather than invented shapes — good, and it directly encodes the fixture-provenance lesson from the CHANGELOG's own history.

Collision risk: this PR and #50 both edit .github/scripts/dependabot-janitor.sh from the same base blob (5b5f512), which is why both currently show CONFLICTING/DIRTY against current main#55 also touches this file (near the merge call) and is still MERGEABLE/CLEAN for now. All three touch different regions (REPOS grep vs. classify() vs. the merge call), so this isn't a feature-dependency ordering problem, just same-file edit collision — whichever of the three merges first will very likely knock the other two into conflict and they'll need a mechanical rebase.

Verdict: safe to merge pending the add-to-project check sanity-check and the expected rebase depending on merge order with #50/#55. The classifier fix itself is correct and well-tested — this is closing a real, currently-latent (per the PR's own measurement: 0/108 exposed today, but structurally live) hole that could otherwise auto-merge a hidden major.

asachs01 added a commit that referenced this pull request Aug 31, 2026
…ent overlap (#69)

Three open PRs (#26, #50, #54) all showed mergeable=CONFLICTING against
current main. None of their real changes overlap line-for-line — the
conflicts are purely stale-diff artifacts of main having moved on since
each PR's fork point (#66's dual-org repo-enumeration restructure in
particular). This reapplies all three fixes' actual intent by hand onto
current main, in dependency order (both #50 and #54 touch
dependabot-janitor.sh).

#54 (classify() group-PR hardening, supersedes #23): a Dependabot "group"
PR title no longer proves minor/patch. classify() now fetches the PR body
via REST and requires every per-dependency "from A to B" marker to be
same-major, failing closed otherwise. Reapplied verbatim except the body
fetch now calls `repos/$repo/pulls/$num` directly (since $repo is already
"org/name" post-#66) instead of #54's original `repos/$ORG/$repo/...`.
Adds dependabot-janitor.test.sh (14 assertions, real fixtures) — verified
byte-identical to #54's original test file aside from that same $ORG->
$repo adaptation; all 14 assertions pass unmodified.

#50 (cortextos/conduit scope carve-out): reapplied by hand onto the
current multi-org enumeration loop (#66) rather than #50's own stale
single-org grep pipeline — added `|^cortextos$|^conduit$` to the
still-present grep -E inside the `for _org in $ORGS` loop.

#26 (mcp-server-release.yml always() gating): the release job's "Detect
released version" step now runs with `if: always()`, and the docker job's
gate is `if: always() && needs.release.outputs.released == 'true'`, so a
non-fatal failure after semantic-release cuts a release (e.g. a flaky
git-notes push) can't silently skip artifact publishing. Investigated the
apparent second `id: detect` in the current file: it is an unrelated step
in the (post-#26) mcpb job that checks for a pack:mcpb script, not a
duplicate release-detection block — #26's fix applies to exactly the one
occurrence it always targeted. Flagged in the CHANGELOG that mcpb's own
released=='true' gate has an analogous latent exposure, left out of scope
here since it predates neither #26 nor this reconciliation.

Verified: shellcheck clean on dependabot-janitor.sh (baseline was already
clean); dependabot-janitor.test.sh carries only pre-existing info-level
notices inherited from #54's original file (one pre-existing SC2034
warning was fixed by dropping the now-unused $ORG var); all 14 tests
pass; mcp-server-release.yml parses as valid YAML.

Refs: #26, #50, #54
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