Skip to content

Releases: raphaelcm/dep-steward

v0.8.2 — A closed PR is owed no verdict

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Aug 13:57
55e0465

The assertion stops paging about PRs nobody can act on

The deliverable assertion demanded an AUTOMERGE-DECISION-V1 block for every non-group PR, including closed ones — which contradicts the pipeline's own policy in the opposite direction. gate.cjs refuses a closed PR with pr_not_open, and the auto-merge job deliberately keeps that code out of its escalation allow-list, because paging about an already-closed PR is the noise that devalues the label. So a Dependabot PR closed or superseded while its review was in flight ended in a red job, a needs-human-review label, and an assignee, for a PR nobody can act on.

Seen live: a replay against a closed, already-escalated, operator-ruled PR. The agent investigated, correctly declined to post a redundant verdict on a closed PR, and the assertion paged anyway. It now skips when the PR is not OPEN, the same scoping it already applies to group PRs and for the same reason: the gate cannot merge it, so no verdict is owed. Reopening fires pull_request: reopened, which re-runs the review with the PR open, so no coverage is lost.

The assertion is finally executed by a test

The deeper gap was that this step had never been run by anything. bash -n proved it parses, which is silent about policy — and both of its live failures were policy: accepting a stale verdict (v0.8.1), then paging about closed PRs (this release).

It is now exercised against a gh stub that pipes canned comment JSON through real jq using the step's own --jq expression, so the timestamp filter under test is the one that ships rather than a re-implementation of it. Five cases: a fresh verdict passes, a stale verdict goes red and labels, a closed PR passes silently, an open singleton with no verdict pages a human, and a group PR passes silently. The stale case and the closed case were each falsified by reverting their fix.

v0.8.1 — The deliverable assertion owns THIS run's comment

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Aug 06:41
37d1bc3

A failed post can no longer hide behind an old comment

The first live replay under 0.8.0 exposed a false green. The review agent drafted a correct CI-free review, then posted it with gh pr comment ... --body-file <file> && rm -f <file>; the && rm made the whole compound deniable, so nothing executed. The agent's self-check found the PR's stale review comment from an earlier run, concluded its post had landed, and reported success — and the deliverable assertion, which only checked that ANY AUTOMERGE-DECISION-V1 comment exists, agreed. Green job, nothing delivered.

The assertion is now run-scoped: a timestamp is captured before the agent starts (minus a 60-second skew margin), and only comments created at or after it count. A replay or a synchronize re-review of an already-reviewed PR can no longer be satisfied by history; a review that fails to deliver is a red job with a needs-human-review label.

The prompt names the body file

The trace showed two stumbles on the way to the fatal compound: the agent first tried writing its draft to /tmp (blocked by the sandbox's working-directory rule) and then wanted to clean up its temp file (the && rm). The prompt now says exactly where the body goes — write .dep-steward-review.md with the Write tool, post with gh pr comment --body-file on that path — and states that the runner is discarded after the run, so the file needs no cleanup. The sanctioned path is the obvious one; no prohibition added.

The diagnose summary counts denials from the data

The result summary printed permission_denials_count: null while ten real denials sat in the SDK's permission_denials array. The count is now derived from the array itself.

v0.8.0 — The reviewer is never asked about CI it cannot read

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Aug 06:19
ed6b76e

The review agent stops being asked about CI

Every review used to carry a **CI status**: line, and every review filled it with a confession: "could not read". The agent's gh runs on the Claude App's own installation token (claude-code-action mints it via OIDC and ignores the step's GH_TOKEN), and that token cannot read the Checks or Actions APIs — the job-level checks: read + actions: read grants never reached it. Independently and decisively, the review runs on pull_request concurrently with CI and finishes minutes before CI does, so even a perfect token would have read pending. The model then escalated PRs citing its own blindness as the risk.

CI truth was never the reviewer's to report. The deterministic gate re-reads the CI conclusion on every wake and refuses anything but success regardless of the verdict, and that is where the composition of "verdict says merge AND tests pass" belongs. The CI investigation step, the comment-template line, the CI clauses in the decision criteria, and the CI-reading tools in the allow-list are deleted. Nothing replaces them: an allow-listed tool is an instruction to use it, so the deletion is the fix.

The autofix agent can finally read the failure it exists to fix

The fixer job fires only when CI fails, and its first step is gh run view --log-failed — which 403'd on the same App token, every time. The fixer step now passes github_token: ${{ secrets.GITHUB_TOKEN }}, so the job's checks:/actions: read grants actually reach the agent doing the reading. Its comments post as github-actions[bot], which is safe there: they carry no decision block and nothing wakes on them.

The identity contract is now written down and enforced

The review step must never take that same input. A comment created with GITHUB_TOKEN fires no workflow triggers (GitHub's recursion guard), so the gate's issue_comment wake-up only works because the review comment comes from the claude[bot] App identity. Pass the token there and any review that finishes after CI strands its PR until the next push. The workflow header now states this, and permissions.test.mjs enforces both sides: an agent without the passthrough may not allow-list any command whose scope the App token lacks, an agent with it must have every needed scope granted, the review step must not carry the input, and the autofix step must.

Also

  • The diagnose step goes red and labels the PR when the agent was denied gh pr diff: a review that never read the diff still posts an escalate-shaped decision block, and it used to stay green.
  • The pinned anthropics/claude-code-action moves to v1.0.187, so a re-render no longer rolls an adopter back to v1.0.183.

v0.7.0 — A refused merge method is tried again another way

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Aug 06:18
b95673f

The gate emits a ranked list of merge methods, and the workflow tries them in order

Whether GitHub accepts a merge method has at least five independent inputs — repo settings, repo rulesets, org rulesets, classic branch protection, and the acting token's App scopes against the PR's changed paths — and two of them are unreadable from a workflow by construction. Classic branch protection needs admin to read, and no permissions: key grants admin; the App-workflow-scope refusal is not a repo setting at all.

So the pre-flight probe is now a prior, never a verdict. gate.cjs emits methods= on every call: the full ranked candidate list (workflow-touching PRs rank merge, rebase, squash; everything else squash, rebase, merge), intersected with what the two readable layers report, and never narrowed by a failed query. The arm step tries the list in order and stops at the first method GitHub accepts.

Escalation only happens when every ranked method is refused, and the escalation carries what GitHub said to each attempt rather than only the first. The incident behind this: a PR sat green and authorized while the gate paged a human about a refused merge commit, with squash and rebase both untried — the ban lived in classic branch protection's required_linear_history, which nothing the workflow can query reports.

The probe reads a third layer where it can

Rulesets now surface required_linear_history in the readable rules response, so where the restriction lives there, the probe narrows honestly (merge commits drop out of the candidates). Where it lives in classic branch protection, the fallback above is what protects the merge.

v0.6.0 — dep-steward is a Claude Code plugin

Choose a tag to compare

@raphaelcm raphaelcm released this 05 Aug 18:41
8a082ee

Install it once, have it everywhere

This repo is now a Claude Code plugin. The two commands below install it from this repo alone — no other repository or registry involved:

/plugin marketplace add raphaelcm/dep-steward
/plugin install dep-steward@dep-steward

That replaces the manual curl into ~/.claude/commands/ the README used to ask for, and adds two commands that did not exist:

Command What it does
/dep-steward:install Preflights the prerequisites in whatever repo you're in, shows the --dry-run plan, then installs.
/dep-steward:summary The former /dep-steward-summary, unchanged — what was auto-merged, what was escalated and why, and an honest time-saved estimate.
/dep-steward:uninstall Removes the pipeline — files, label, and the token from both secret stores (forgetting the second one is the usual half-uninstall).

The install skill is written against how the installer actually behaves

Every interactive prompt in install.sh is guarded by [ -t 0 ], so a non-interactive run never hangs: it uses the flag it was given or stops with a clear message. The skill leans on that rather than on how the script reads.

Two steps genuinely need a browser and cannot be completed by an agent: minting CLAUDE_CODE_OAUTH_TOKEN with claude setup-token, and granting the Claude Code GitHub App. For those the skill hands off with the exact command and names which step needs a person, instead of pretending it can finish.

One copy of the summary command, not two

templates/dep-steward-summary.md moved to skills/summary/SKILL.md — moved, not copied. Two canonical copies of that text would drift, with the plugin serving one and the README's curl fallback serving the other, and nothing would catch it. The README fallback now points at the new path, which works as a standalone command file too.

The body is verbatim. It encodes real pipeline contracts — the AUTOMERGE-DECISION-V1 block, the needs-human-review label, the -minor-patch- branch naming — and rewording it risks drift from what the pipeline actually writes, for no gain.

Version policy

.claude-plugin/plugin.json carries an explicit version0.6.0, this release — rather than relying on git-SHA versioning. The tradeoff stated plainly: users receive a plugin update only when that field moves, so it moves with every release tag from here on.

Testing

New test/plugin.test.mjs pins the invariants that break an install for every user, silently, with nothing else watching: both manifests parse and agree, the marketplace entry's name matches plugin.json and its source is the repo root, every skill carries a description (without one a skill loads but never surfaces), and the README's raw-file links resolve on disk — exactly what rots after moving the summary command. It rides the existing test/*.test.mjs glob, so CI needed no change.

node --test test/*.test.mjs      149 pass, 0 fail
shellcheck -s sh install.sh      clean
claude plugin validate .         Validation passed

v0.5.0 — A refusal that can never resolve now reaches a human

Choose a tag to compare

@raphaelcm raphaelcm released this 30 Jul 21:24
a7f486b

The gap this closes

The gate wakes on every CI completion and every trusted-bot comment, checks its conditions, and on a refusal writes one line to a log nobody reads. That's right for most refusals, which mean "not yet" — CI still running, review not posted — because the gate gets woken again.

Some refusals mean "never", and the gate couldn't tell the difference. It would refuse the same PR forever and tell nobody. A security update that can't land looks exactly like one nobody needed.

I audited every refusal the gate can emit against every notifier that exists. Two are terminal and reach no one:

  • An unparseable review verdict. The review job's assertion greps for the opening marker only, so a comment carrying the marker with truncated or malformed JSON inside passes it green — review SUCCESS, no label, no assignee — while the gate refuses that same comment forever. A truncated agent comment does exactly this.
  • A group PR touching a non-whitelisted path. The assertion skips group PRs by design, and autofix needs a CI failure while CI here is green. Three notifiers, and the PR falls between all of them.

Both now assign, label, and comment — once per PR, not once per wake-up.

Deliberately still silent

Escalation is an allow-list, so anything added later stays quiet by default. Being paged about a PR that fixes itself is what teaches you to ignore the label, and then you miss the real one.

refusal why it stays quiet
review verdict not posted yet the review job already labels, assigns, and goes red
reviewer said escalate / usage affected the reviewer labels the PR itself
CI still running transient by definition
CI cancelled, timed out, awaiting approval a new push re-runs CI and re-wakes the gate, so these usually resolve themselves. Known gap, on purpose
not a Dependabot PR, PR closed, empty diff anomalies — the gate is looking at something it shouldn't act on

One owner for a red build

When CI goes red, the autofix job and the gate wake from the same event and run in parallel. If both escalated you'd be paged about a build the fixer is already fixing. The installer now renders only the taken branch: autofix on → autofix owns it; --no-autofix → the gate does.

And autofix's side is deterministic now. Its no-edits path used to print "The fixer made no edits" and exit quietly, leaving the notification to the agent choosing to label the PR — which its prompt asks for but nothing enforces. An agent that errored, or simply didn't, left a red PR with nobody told. "Covered sometimes" is worse than not covered, because you learn to trust it.

A total outage this release nearly shipped

Refactoring the comment-posting helper added an opening quote and not the closing one. Everything after it parsed inside an unterminated string, which kills the whole auto-merge step — meaning no PR would ever merge again.

The full suite was green. CI shellchecked install.sh and the tests exercised gate.cjs, but nothing had ever parsed the shell those two conspire to generate.

test/workflow-shell.test.mjs closes that: every run: block in the rendered workflow is extracted and parsed with bash -n — the shell GitHub actually runs them with. It names the offending step and line. Mutation-checked against the exact bug that motivated it.

(If you write your own workflow shell, the lesson generalizes: check it with bash -n, not sh -n. On macOS /bin/sh is bash and they look identical; on Ubuntu it's dash, and every <<< fails.)

Upgrade

Re-run the installer. gate.cjs gains a code= output line; if you've hand-edited your installed gate or workflow, re-render rather than merging by hand.

Full changelog: v0.4.0...v0.5.0

v0.4.0 — Arm auto-merge, derive the merge method, unblock the agents' view of CI

Choose a tag to compare

@raphaelcm raphaelcm released this 30 Jul 20:30
02d4b2b

Upgrade if you are on v0.3.x

Two of these are defects that make the pipeline quietly do less than it reports. Re-run the installer to pick them up.

The review agent could not see CI, and said so to nobody

A job-level permissions: block sets every unlisted scope to none. The review job granted contents, pull-requests and id-token only — but the prompt orders gh pr checks (Checks API) and gh run view --log-failed (Actions API), and both are allow-listed in --allowedTools. So the agent got a 403 on the CI it was told to read, reviewed the PR blind, and the job still reported success.

Worse in the autofix job, which fires only on a CI failure: reading that failure is its entire first step.

Both jobs now grant checks: read + actions: read.

New test/permissions.test.mjs locks it semantically: it derives what each job needs from that job's own allow-list and the prompt it loads, then checks the granted scopes are a superset. A hardcoded assertion would pass forever while a newly allow-listed command went un-scoped, and an unmapped command throws rather than being skipped.

A merge that lost a race told nobody

The gate merged imperatively — read CI, then ask GitHub to merge. Anything that changed mergeability in between became a refusal with no retry, because the job only wakes on a new commit or a new comment.

It now arms GitHub's native auto-merge, which holds the request and merges when every requirement is met. Arming is a latch and the gate re-derives on every wake, so a later refusal disarms — a PR gone red, or one whose review posts a superseding escalate, cannot stay armed against the gate's current answer. The disarm fails closed: an unreadable "who armed it" answer disarms rather than skipping. An auto-merge a human enabled is reported, never silently revoked.

Past authorization, every failure now escalates — label, assignee, comment, red job. A merge that fails silently is indistinguishable from one that never ran.

The merge method is now read off your repo, not assumed

GitHub lets you forbid merge commits, squashes, or rebases in two independent places: the repository settings, and the default branch's ruleset (pull_request rule → allowed_merge_methods). Reading only the first is a live trap — it produced a downstream workaround that hardcoded rebase around a restriction that actually lived in the ruleset and later went away.

dep-steward now reads both, intersects them, and gate.cjs picks by ordered preference:

  • workflow-touching → merge, rebase, squash. GITHUB_TOKEN can never hold the workflows permission, so an App-authored new commit editing a workflow file is refused. A merge commit authors none; a rebase rewrites SHAs and may be refused for the same reason; a squash always is.
  • everything else → squash, rebase, merge.

If your repo permits no method at all, the job escalates rather than passing an invalid flag to gh. "Could not read the setting" and "nothing is allowed" stay distinguishable end to end — collapsing those two is what produced the hardcode.

Also

  • claude-code-action pinned to v1.0.183 (from v1.0.165). Both jobs, asserted equal.
  • A per-run agent diagnostic step: prints the turn-by-turn tool calls, the denials, and the final error. A denied tool call is how a review silently degrades while still reporting success — exactly what the permissions defect looked like from the outside.

Breaking-ish

gate.cjs now reads a new optional env var, ALLOWED_MERGE_METHODS, and always prints a third output line, method=. If you have hand-edited your installed gate or workflow, re-render rather than merging by hand — the workflow's new "Resolve the merge methods this repo actually allows" step is what populates it.

Full changelog: v0.3.1...v0.4.0

v0.3.1 — fix: agents blocked by 2.1.207's $VAR permission tightening

Choose a tag to compare

@raphaelcm raphaelcm released this 13 Jul 18:39
a3ebf38

Critical fix: the review + autofix agents work again on current Claude Code

Anyone on v0.3.0 should upgrade — the pipeline silently stops working the moment claude-code-action auto-updates.

What was broken

Claude Code 2.1.207+ (bundled by recent claude-code-action releases) tightened permission matching: an allowlisted Bash(...) command that contains a shell expansion is now rejected as "Contains expansion" (a $VAR could resolve to anything, defeating the allowlist). Every command the prompts tell the agent to run — gh pr diff $PR_NUMBER, gh pr comment $PR_NUMBER, gh pr edit $PR_NUMBER — was denied. The review agent burned its whole turn budget hunting for the PR number and timed out with no verdict; the autofix agent couldn't act. Every singleton/major PR then escalated to a human. Because the pipeline auto-merges Action bumps, this triggers itself on the next update.

The fix

The load_prompt step now substitutes the literal PR number into the prompt (gh pr diff 123), so the allowlist matches. It adapts to the new security model and stays on the current Action version — no version pin, no Dependabot hold, no tech debt.

How it was found

Diagnosed live: with a valid token the review authenticated but hit error_max_turns after 60 turns of printenv PR_NUMBER / gh pr list. Surfacing the Action's execution log (also new — a review that produces no verdict now prints the agent's actual error and denied tool calls) showed ERR Contains expansion. After the fix the same PR got a full, correct MERGE verdict in 32 turns.

Full changelog: v0.3.0...v0.3.1

v0.3.0 — Autofix, token verification, legible errors

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Jul 20:32
623d95f

What's new in v0.3.0

Autofix (on by default)

When a dependency bump breaks CI, a Claude agent drafts the small mechanical fix, bounds-checks it (a few lines, source only, never the manifest or .github/), and pushes it to the PR branch for you to review and merge — it never merges on its own. The injection-safe gate is untouched: an autofixed PR has source changes, so the whitelist keeps the deterministic gate from ever auto-merging it. Opt out with --no-autofix.

Install-time token verification

The installer now verifies your CLAUDE_CODE_OAUTH_TOKEN actually authenticates (a quick claude -p probe) before storing it, and re-prompts on failure. A wrong or expired token fails at install with a clear message instead of silently breaking the pipeline in CI hours later. (Setup-token OAuth tokens last ~a year; a copied keychain access token is short-lived and will expire.)

Real errors, surfaced

When a review or autofix run produces no result, the job now prints the agent's actual error — e.g. 401 Invalid bearer token — read from claude-code-action's execution log, instead of guessing "probably the App." The #1 cause, an invalid/expired token, is now legible at a glance, with the remedy.

Smoother install

  • Surfaces the required Claude Code GitHub App and opens the install page (the token alone isn't enough).
  • After writing the files, offers to commit + push, so "done" means live.

Model

Default review/fix model is claude-opus-4-8 (frontier Opus).

Full changelog: v0.2.0...v0.3.0

v0.2.0

Choose a tag to compare

@raphaelcm raphaelcm released this 10 Jul 05:15
62f8eb8

Routine dependency PRs no longer land in your review queue, plus an installer polish.

Fixed

  • Escalation now fires only when a human is genuinely needed. dep-steward used to assign you and add needs-human-review whenever the Claude review produced no verdict — even on the minor/patch group PRs the deterministic gate auto-merges on its own. Now group PRs (the gate's domain) are never escalated; only singleton/major PRs, which the gate can't merge without a verdict, reach your queue on a missing one. The classic false alarm — an actions-minor-patch bump that touches the review workflow itself and trips claude-code-action's OIDC self-modification refusal — no longer pings you on a PR that auto-merges seconds later. Implemented as a new unprivileged GATE_MODE=classify in the gate (single source of truth for which branches are groups).

Changed

  • The installer's "Done" output no longer prints the optional /dep-steward-summary install aside; mid-install it read as a confusing tangent. The command is still documented in the README.

Added (design only — not yet implemented)

  • docs/design/autonomous-fix.md — the design for autonomous FIX-AND-MERGE: a fixer LLM writes a bounded fix, an independently-scoped adversarial reviewer (blind to the injection vector) tries to refute it, and the deterministic gate merges only if it survives.

Upgrading an existing install: the fix lives in the files dep-steward renders into your repo, so re-run the installer to refresh them, then commit:

sh -c "$(curl -fsSL https://raw.githubusercontent.com/raphaelcm/dep-steward/main/install.sh)"
git add -A && git commit -m "Update dep-steward" && git push

The one-liner always installs from main, so new installs already include everything above — this tag is a milestone marker, not a gate.