Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,31 @@ func update(allowBreak bool) error {
return nil
}

// claudePermissionArgs returns the extra CLI arguments runClaudeOutput must
// append when CI_REGENERATION is set.
//
// CI_REGENERATION is set only by .github/workflows/regen-from-api.yaml, the
// one workflow holding Claude credentials. Under it, --permission-mode
// bypassPermissions is required: without it, headless Claude replies that it
// lacks write permission, edits nothing, and still exits 0 -- so a
// regeneration reports success and produces an empty PR (observed on runs
// 33010304938 vs 33010790114). --print itself is unaffected by this gate:
// runClaudeOutput already passes it unconditionally, since its caller parses
// stdout as the generated commit message whether this runs locally or in CI.
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container.
func claudePermissionArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--permission-mode", "bypassPermissions"}
}

// runClaudeOutput pipes a prompt to claude and returns stdout as a string.
func runClaudeOutput(prompt string) (string, error) {
cmd := exec.Command("claude", "--print")
args := append([]string{"--print"}, claudePermissionArgs()...)
cmd := exec.Command("claude", args...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
Expand Down
32 changes: 32 additions & 0 deletions magefile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,35 @@ func TestStampLastGenerationErrorsWhenClientDirMissing(t *testing.T) {
t.Fatalf("error should name the offending path, got %v", err)
}
}

// TestClaudePermissionArgsLocalIsUnchanged pins the promise this whole design
// rests on: a developer running mage locally, with CI_REGENERATION unset,
// must see exactly the invocation they see today -- no permission flag added
// on top of runClaudeOutput's unconditional --print.
func TestClaudePermissionArgsLocalIsUnchanged(t *testing.T) {
t.Setenv("CI_REGENERATION", "")

args := claudePermissionArgs()
if args != nil {
t.Fatalf("claudePermissionArgs() = %v, want nil when CI_REGENERATION is unset", args)
}
}

// TestClaudePermissionArgsCIRegenerationAddsPermissionFlag reproduces the fix
// for the defect observed on runs 33010304938 vs 33010790114: without
// --permission-mode bypassPermissions, headless Claude replies that it lacks
// write permission, edits nothing, and still exits 0.
func TestClaudePermissionArgsCIRegenerationAddsPermissionFlag(t *testing.T) {
t.Setenv("CI_REGENERATION", "1")

got := claudePermissionArgs()
want := []string{"--permission-mode", "bypassPermissions"}
if len(got) != len(want) {
t.Fatalf("claudePermissionArgs() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("claudePermissionArgs() = %v, want %v", got, want)
}
}
}
26 changes: 25 additions & 1 deletion proto-clients/spicedb-csharp-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,33 @@ func Test() error {
return sh.RunV("dotnet", "test", "--verbosity", "normal")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-go-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,33 @@ func Test() error {
return sh.RunV("go", "test", "-v", "./...")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-java-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,33 @@ func Test() error {
return sh.RunV("gradle", "test")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-python-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,33 @@ func Test() error {
return sh.RunV("uv", "run", "pytest", "-v")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-ruby-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,33 @@ func Test() error {
return sh.RunV("bundle", "exec", "rspec")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-rust-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,33 @@ func Test() error {
return sh.RunV("cargo", "test")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
26 changes: 25 additions & 1 deletion proto-clients/spicedb-typescript-proto/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,33 @@ func Test() error {
return sh.RunV("pnpm", "test")
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
49 changes: 48 additions & 1 deletion spicedb-csharp/Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ const (
// the path is what distinguishes a real project from a folder.
var slnProjectLine = regexp.MustCompile(`^Project\("\{[^}]+\}"\) = "[^"]*", "([^"]+)", "(\{[^}]+\})"`)

// claudeAvailable returns true if the claude CLI is installed and usable.
// Returns false when running in CI (CI env var set) because the claude binary
// may be present but not authenticated.
//
// CI_REGENERATION is the one exception, and it is set by exactly one workflow:
// .github/workflows/regen-from-api.yaml, which is also the only workflow holding
// Claude credentials. Every other CI job -- notably meta.yaml's gen-nodiff --
// must keep taking the false branch here, or `mage gen:all` starts making
// unreviewed changes inside a check whose whole purpose is asserting that
// generation produces no diff.
func claudeAvailable() bool {
if os.Getenv("CI") != "" && os.Getenv("CI_REGENERATION") == "" {
return false
}
_, err := exec.LookPath("claude")
return err == nil
}

// Gen updates the idiomatic client based on proto client changes.
func Gen() error {
// Read last generation baseline
Expand All @@ -65,6 +83,11 @@ func Gen() error {
return nil
}

if !claudeAvailable() {
fmt.Println("==> claude not available; skipping idiomatic client update (gen-nodiff mode).")
return nil
}

prompt := fmt.Sprintf(
"The proto client has changed. Here is the diff:\n\n%s\n\n"+
"Read ../DESIGN.md and ./DESIGN.md. Update this client accordingly. "+
Expand Down Expand Up @@ -542,9 +565,33 @@ func waitForReady(addr string, timeout time.Duration) error {
return fmt.Errorf("SpiceDB not ready at %s after %s", addr, timeout)
}

// claudeArgs returns the CLI arguments for a Claude invocation.
//
// Under CI_REGENERATION -- set only by .github/workflows/regen-from-api.yaml,
// the one workflow holding Claude credentials -- two flags are required that
// local runs must not get:
//
// --print a CI runner has no TTY; the bare
// interactive form has never run there
// --permission-mode bypassPermissions without it Claude replies "I don't
// have permission to write this file",
// edits nothing, and still exits 0 --
// so a regeneration reports success and
// produces an empty PR (observed on
// runs 33010304938 vs 33010790114)
//
// The bypass is confined to that workflow, which runs on an ephemeral
// single-purpose container. Local runs keep normal interactive prompting.
func claudeArgs() []string {
if os.Getenv("CI_REGENERATION") == "" {
return nil
}
return []string{"--print", "--permission-mode", "bypassPermissions"}
}

// runClaude pipes the prompt to claude via stdin so output streams in real time.
func runClaude(prompt string) error {
cmd := exec.Command("claude")
cmd := exec.Command("claude", claudeArgs()...)
cmd.Stdin = strings.NewReader(prompt)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
Expand Down
Loading
Loading