Skip to content

fix(mage): make headless Claude actually edit files in CI - #60

Merged
josephschorr merged 1 commit into
mainfrom
fix/claude-headless-permissions
Aug 26, 2026
Merged

josephschorr merged 1 commit into
mainfrom
fix/claude-headless-permissions

Conversation

@josephschorr

Copy link
Copy Markdown
Member

Fixes a defect confirmed on real GitHub Actions runs, not inferred. Without it, every automatic client regeneration would report success while producing an empty PR.

The defect

Two runs on zz/claude-smoke, differing by exactly one flag:

Run Invocation Result
33010304938 claude --print step exited 0; Claude replied "I don't have permission to write this file"; no file written
33010790114 + --permission-mode bypassPermissions file written

Headless Claude answers and edits nothing without a permission mode, while still exiting 0. In this pipeline that is a silent green: mage gen:all succeeds, nothing changes, the PR is empty, and no check anywhere reports a problem.

A second defect surfaced while scoping: all 14 runClaude helpers invoked bare claude with no --print — an interactive shape that has never run on a TTY-less runner.

What changed

1. --print and --permission-mode bypassPermissions, gated on CI_REGENERATION — in all 14 runClaude helpers plus the root's runClaudeOutput. Both flags come from one return statement, so they cannot get out of sync; a mismatch would reproduce a subtler version of the original silent failure.

Local behavior is byte-for-byte unchanged. claudeArgs() returns nil when CI_REGENERATION is unset, and exec.Command("claude", nil...) is exactly exec.Command("claude"). The root keeps --print unconditional because its caller parses stdout.

bypassPermissions grants unrestricted tool use, so the confinement matters: it is reachable only under CI_REGENERATION, which is set by exactly one workflow — the one holding Claude credentials, running on an ephemeral single-purpose container. The App backing that workflow has no Workflows: write, so a regeneration cannot rewrite CI, and PR CI still gates the output.

2. The claudeAvailable gate the idiomatic tier was missing — the 7 proto Magefiles had it; the 7 idiomatic ones had none.

This became load-bearing rather than tidy-up. Before change 1, an ungated idiomatic runClaude in CI would merely error. After it, with CI_REGENERATION set, it would succeed. Until now the idiomatic tier was protected only by an accident: meta.yaml's checkout is shallow, so HEAD~1 does not resolve and every client skips with fatal: bad revision 'HEAD~1'. Anyone adding fetch-depth: 0 to meta.yaml — for any unrelated reason — would have broken gen-nodiff with no obvious connection to their change.

3. Root unit tests for the new helper, covering both the unset and set branches via t.Setenv.

Verification

  • 13/13 root mage tests pass, including 2 new ones
  • go vet -tags mage clean everywhere it can run; gofmt clean on all 16 touched files
  • All 14 runClaude bodies remain byte-identical to one another — deliberate standalone copies across separate //go:build mage modules, where uniformity is what makes them auditable

Note proto-clients/spicedb-go-proto and spicedb-go each hold two Go packages in one directory and cannot compile with -tags mage at all. Both predate this change and are checked with gofmt instead.

Still to validate

The smoke test that proved the flag is synthetic — it exercised claude --print directly, not the mage code path. Real acceptance is a genuine mage gen:* run in CI once the regeneration workflow lands.

Confirmed on GitHub Actions: without a permission mode, claude replies
"I don't have permission to write this file", edits nothing, and exits 0 --
so a regeneration reports success and produces an empty PR. Runs
33010304938 (fails) vs 33010790114 (passes) differ only by the flag.

All 14 runClaude helpers also invoked bare `claude` with no --print, an
interactive shape that has never run on a TTY-less runner.

Both flags are gated on CI_REGENERATION so local runs are unchanged. Also
adds the claudeAvailable gate the idiomatic tier was missing -- previously
it was protected only by meta.yaml's shallow clone leaving HEAD~1
unresolvable, which would break silently if anyone set fetch-depth: 0.
@josephschorr
josephschorr merged commit 89866a3 into main Aug 26, 2026
43 checks passed
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