diff --git a/cmd/releasekit/recovery.go b/cmd/releasekit/recovery.go index ebba622..55b9cfc 100644 --- a/cmd/releasekit/recovery.go +++ b/cmd/releasekit/recovery.go @@ -102,14 +102,17 @@ func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr for _, item := range listed.Diagnostics { diagnostics = append(diagnostics, diagnostic(item)) } - writeJSON(stdout, attemptsDocument{ + if writeErr := writeJSON(stdout, attemptsDocument{ SchemaVersion: 1, Command: "publication-attempts", RecordDir: git.DisplayDiagnostic(listed.RecordDir), Attempts: attempts, Diagnostics: diagnostics, Diagnostic: diagnostic(err), - }) + }); writeErr != nil { + fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr)) + return 1 + } } else { fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir)) for _, item := range listed.Attempts { @@ -149,7 +152,10 @@ func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr dto.ObservedCommit = result.ObservedCommit } dto.Diagnostic = diagnostic(err) - writeJSON(stdout, dto) + if writeErr := writeJSON(stdout, dto); writeErr != nil { + fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr)) + return 1 + } } else if err == nil { fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State) if result.State == releasekit.PublicationConflict { @@ -243,7 +249,7 @@ func humanDiagnostic(err error) string { return git.DisplayError(err).Error() } -func writeJSON(writer io.Writer, value any) { +func writeJSON(writer io.Writer, value any) error { encoder := json.NewEncoder(writer) - _ = encoder.Encode(value) + return encoder.Encode(value) } diff --git a/cmd/releasekit/recovery_test.go b/cmd/releasekit/recovery_test.go index 44b0757..ef60df7 100644 --- a/cmd/releasekit/recovery_test.go +++ b/cmd/releasekit/recovery_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -14,6 +15,41 @@ import ( "go.maximumdirect.net/releasekit/internal/publish" ) +type failingRecoveryWriter struct{ err error } + +func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err } + +func TestRecoveryCommandsReportJSONWriteFailure(t *testing.T) { + repo := newCLIRepository(t) + cliGitRun(t, repo, "tag", "-a", "v1.2.3", "-m", "Release") + store := t.TempDir() + path, err := publish.SaveEvidence(t.Context(), repo, store, publish.Evidence{ + RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", + CandidateCommit: cliGitOutput(t, repo, "rev-parse", "HEAD"), + TagObject: cliGitOutput(t, repo, "rev-parse", "v1.2.3"), + }) + if err != nil { + t.Fatal(err) + } + failure := errors.New("output unavailable: https://user:audit-secret@example.invalid/output") + for _, args := range [][]string{{"reconcile", "--record", path, "--json"}, {"publication-attempts", "--record-dir", store, "--json"}} { + var normal, stderr bytes.Buffer + if status := run(t.Context(), args, &normal, &stderr); status != 0 { + t.Fatalf("normal output status=%d: %s", status, &stderr) + } + stderr.Reset() + if status := run(t.Context(), args, failingRecoveryWriter{failure}, &stderr); status != 1 { + t.Fatalf("failed output status=%d: %s", status, &stderr) + } + if !strings.Contains(stderr.String(), "output unavailable") || strings.Contains(stderr.String(), "audit-secret") { + t.Fatalf("output diagnostic: %s", &stderr) + } + if args[0] == "reconcile" && !strings.Contains(normal.String(), `"state":"published"`) { + t.Fatalf("observation changed: %s", &normal) + } + } +} + func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) { secret := "audit-secret" raw := "https://audit-user:" + secret + "@example.invalid/attempt.json" diff --git a/docs/cli.md b/docs/cli.md index f8bde05..b915ebe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -188,6 +188,11 @@ using current supported transport settings for its recorded remote name. | Discovery completes without record diagnostics, including an existing empty store | 0 | | Discovery is incomplete or fails, including cancellation | 1 | | Discovery argument or usage error | 2 | +| Either command cannot write its JSON output | 1 | + +A JSON write failure reports a local output diagnostic on standard error. This +status describes reporting failure and leaves the observed publication state +unchanged. The CLI JSON schema and safe evidence fields are documented in the [publication record format](integrations/publication-records.md). Recovery diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index d1c4209..db0e964 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -210,6 +210,11 @@ endpoint-only case. No new credential-storage policy is needed. ## R06: Propagate structured-output write failures — P2 +**Completed:** Both recovery commands now report JSON encoder failures on +standard error and return status 1. Failing-writer regressions passed alongside +ordinary recovery status tests; observed publication state remains unchanged. +The CLI reference records the reporting-failure status. + **Relevant code:** `cmd/releasekit/recovery.go:242` and its two `writeJSON` call sites.