Report JSON output failures from recovery commands

This commit is contained in:
2026-11-02 22:23:15 +00:00
parent 648c87bb8a
commit 19a14bfeb6
4 changed files with 57 additions and 5 deletions

View File

@@ -102,14 +102,17 @@ func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr
for _, item := range listed.Diagnostics { for _, item := range listed.Diagnostics {
diagnostics = append(diagnostics, diagnostic(item)) diagnostics = append(diagnostics, diagnostic(item))
} }
writeJSON(stdout, attemptsDocument{ if writeErr := writeJSON(stdout, attemptsDocument{
SchemaVersion: 1, SchemaVersion: 1,
Command: "publication-attempts", Command: "publication-attempts",
RecordDir: git.DisplayDiagnostic(listed.RecordDir), RecordDir: git.DisplayDiagnostic(listed.RecordDir),
Attempts: attempts, Attempts: attempts,
Diagnostics: diagnostics, Diagnostics: diagnostics,
Diagnostic: diagnostic(err), Diagnostic: diagnostic(err),
}) }); writeErr != nil {
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
return 1
}
} else { } else {
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir)) fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
for _, item := range listed.Attempts { for _, item := range listed.Attempts {
@@ -149,7 +152,10 @@ func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr
dto.ObservedCommit = result.ObservedCommit dto.ObservedCommit = result.ObservedCommit
} }
dto.Diagnostic = diagnostic(err) 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 { } 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) 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 { if result.State == releasekit.PublicationConflict {
@@ -243,7 +249,7 @@ func humanDiagnostic(err error) string {
return git.DisplayError(err).Error() 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 := json.NewEncoder(writer)
_ = encoder.Encode(value) return encoder.Encode(value)
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -14,6 +15,41 @@ import (
"go.maximumdirect.net/releasekit/internal/publish" "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) { func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
secret := "audit-secret" secret := "audit-secret"
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json" raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"

View File

@@ -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 completes without record diagnostics, including an existing empty store | 0 |
| Discovery is incomplete or fails, including cancellation | 1 | | Discovery is incomplete or fails, including cancellation | 1 |
| Discovery argument or usage error | 2 | | 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 The CLI JSON schema and safe evidence fields are documented in the
[publication record format](integrations/publication-records.md). Recovery [publication record format](integrations/publication-records.md). Recovery

View File

@@ -210,6 +210,11 @@ endpoint-only case. No new credential-storage policy is needed.
## R06: Propagate structured-output write failures — P2 ## 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` **Relevant code:** `cmd/releasekit/recovery.go:242` and its two `writeJSON`
call sites. call sites.