Add CLI publication recovery commands

This commit is contained in:
2026-11-02 19:17:42 +00:00
parent fb5947ea3a
commit 4afdd9b826
6 changed files with 596 additions and 7 deletions

View File

@@ -17,17 +17,19 @@ import (
"go.maximumdirect.net/releasekit/internal/versioncontract"
)
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG"
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]"
type Command string
const (
VersionCommand Command = "version"
CheckAction Command = "check"
PublishAction Command = "publish"
BuildAction Command = "build"
DeliveryAction Command = "deliver"
CIDeliveryAction Command = "ci-deliver"
VersionCommand Command = "version"
CheckAction Command = "check"
PublishAction Command = "publish"
BuildAction Command = "build"
DeliveryAction Command = "deliver"
CIDeliveryAction Command = "ci-deliver"
ReconcileAction Command = "reconcile"
PublicationAttemptsAction Command = "publication-attempts"
)
// Invocation contains parsed command settings owned by the CLI adapter.
@@ -43,6 +45,9 @@ type Invocation struct {
ExpectedModule string
ExpectedTag string
Prepared bool
RecordPath string
RecordDir string
JSON bool
}
// Parse accepts only commands that have been implemented by the executable.
@@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("a command is required")
}
switch args[0] {
case string(ReconcileAction), string(PublicationAttemptsAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
record := ""
recordDir := ""
repository := "."
if action == ReconcileAction {
flags.StringVar(&record, "record", "", "attempt record file")
} else {
flags.StringVar(&repository, "repo", ".", "repository directory")
flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory")
}
structured := flags.Bool("json", false, "write structured JSON")
if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err
}
if flags.NArg() != 0 {
return Invocation{}, fmt.Errorf("%s takes no positional arguments", action)
}
if action == ReconcileAction && record == "" {
return Invocation{}, errors.New("reconcile requires --record FILE")
}
return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil
case string(VersionCommand):
if len(args) != 1 {
return Invocation{}, errors.New("version takes no arguments")

View File

@@ -19,6 +19,33 @@ func TestParse(t *testing.T) {
if invocation.Command != VersionCommand {
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
}
reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"})
if err != nil {
t.Fatal(err)
}
if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON {
t.Fatalf("Parse(reconcile) = %#v", reconcile)
}
attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"})
if err != nil {
t.Fatal(err)
}
if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON {
t.Fatalf("Parse(publication-attempts) = %#v", attempts)
}
for _, args := range [][]string{
{"reconcile"},
{"reconcile", "record.json"},
{"reconcile", "--record", "record.json", "--repo", "."},
{"reconcile", "--record", "record.json", "--record-dir", "records"},
{"reconcile", "--record", "record.json", "VERSION"},
{"publication-attempts", "--record", "record.json"},
{"publication-attempts", "VERSION"},
} {
if _, err := Parse(args); err == nil {
t.Errorf("Parse(%q) succeeded, want a usage error", args)
}
}
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
if err != nil {
t.Fatal(err)

View File

@@ -32,6 +32,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
return 2
}
if invocation.Command == cli.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction {
return runRecovery(ctx, invocation, stdout, stderr)
}
if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil {

245
cmd/releasekit/recovery.go Normal file
View File

@@ -0,0 +1,245 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"go.maximumdirect.net/releasekit"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/git"
)
type recoveryDiagnostic struct {
Kind string `json:"kind"`
Message string `json:"message"`
RecordPath string `json:"record_path,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
}
type safeEvidence struct {
RepositoryDir string `json:"repository_dir,omitempty"`
ModuleDir string `json:"module_dir,omitempty"`
ModulePath string `json:"module_path,omitempty"`
Version string `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
CandidateCommit string `json:"candidate_commit,omitempty"`
TagObject string `json:"tag_object,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Remote string `json:"remote,omitempty"`
RecordedAt string `json:"recorded_at,omitempty"`
}
type attemptDocument struct {
RecordPath string `json:"record_path"`
Evidence safeEvidence `json:"evidence"`
}
type reconciliationDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordPath string `json:"record_path"`
Evidence *safeEvidence `json:"evidence,omitempty"`
State string `json:"state,omitempty"`
ObservedTagObject string `json:"observed_tag_object,omitempty"`
ObservedCommit string `json:"observed_commit,omitempty"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
type attemptsDocument struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
RecordDir string `json:"record_dir"`
Attempts []attemptDocument `json:"attempts"`
Diagnostics []*recoveryDiagnostic `json:"diagnostics"`
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
}
func safe(e releasekit.PublicationEvidence) safeEvidence {
return safeEvidence{
RepositoryDir: e.RepositoryDir,
ModuleDir: e.ModuleDir,
ModulePath: e.ModulePath,
Version: e.Version,
Tag: e.Tag,
CandidateCommit: e.CandidateCommit,
TagObject: e.TagObject,
Endpoint: git.DisplayURL(e.Endpoint),
Remote: e.Remote,
RecordedAt: e.RecordedAt,
}
}
func diagnostic(err error) *recoveryDiagnostic {
if err == nil {
return nil
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
return &recoveryDiagnostic{Kind: typed.Kind, Message: err.Error(), RecordPath: typed.RecordPath, MissingFields: typed.MissingFields}
}
return &recoveryDiagnostic{Kind: "invocation", Message: err.Error()}
}
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
if invocation.Command == cli.ReconcileAction {
return runReconcile(ctx, invocation, stdout, stderr)
}
listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir})
if invocation.JSON {
attempts := make([]attemptDocument, 0, len(listed.Attempts))
for _, item := range listed.Attempts {
attempts = append(attempts, attemptDocument{RecordPath: item.RecordPath, Evidence: safe(item.Evidence)})
}
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
for _, item := range listed.Diagnostics {
diagnostics = append(diagnostics, diagnostic(item))
}
writeJSON(stdout, attemptsDocument{
SchemaVersion: 1,
Command: "publication-attempts",
RecordDir: listed.RecordDir,
Attempts: attempts,
Diagnostics: diagnostics,
Diagnostic: diagnostic(err),
})
} else {
fmt.Fprintf(stdout, "publication records in %s\n", listed.RecordDir)
for _, item := range listed.Attempts {
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", item.RecordPath, item.Evidence.ModulePath, item.Evidence.Tag, item.Evidence.CandidateCommit, item.Evidence.RecordedAt)
}
if err != nil || len(listed.Diagnostics) > 0 {
fmt.Fprintln(stderr, "discovery is incomplete")
}
for _, item := range listed.Diagnostics {
fmt.Fprintf(stderr, "record %s: %s\n", item.RecordPath, humanDiagnostic(item))
}
if err != nil {
fmt.Fprintf(stderr, "discovery failed: %v\n", err)
}
}
if err != nil || len(listed.Diagnostics) > 0 {
return 1
}
return 0
}
func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath)
var result releasekit.PublicationReconciliationResult
err := loadErr
if err == nil {
result, err = releasekit.ReconcilePublication(ctx, evidence)
}
status := reconciliationStatus(result.State, err)
if invocation.JSON {
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: invocation.RecordPath}
if loadErr == nil {
safeEvidence := safe(evidence)
dto.Evidence = &safeEvidence
dto.State = string(result.State)
dto.ObservedTagObject = result.ObservedTagObject
dto.ObservedCommit = result.ObservedCommit
}
dto.Diagnostic = diagnostic(err)
writeJSON(stdout, dto)
} else if err == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State)
if result.State == releasekit.PublicationConflict {
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", evidence.TagObject, evidence.CandidateCommit, result.ObservedTagObject, result.ObservedCommit)
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
if guidance := reconciliationGuidance(result.State); guidance != "" {
fmt.Fprintf(stdout, "%s\n", guidance)
}
}
if err != nil && !invocation.JSON {
if loadErr == nil {
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State)
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
}
}
fmt.Fprintf(stderr, "reconcile %s: %s\n", invocation.RecordPath, humanDiagnostic(err))
if guidance := recoveryGuidance(err); guidance != "" {
fmt.Fprintf(stderr, "%s\n", guidance)
}
}
return status
}
func reconciliationStatus(state releasekit.PublicationState, err error) int {
if err == nil {
switch state {
case releasekit.PublicationPublished:
return 0
case releasekit.PublicationNotPublished, releasekit.PublicationConflict:
return 1
case releasekit.PublicationUnknown:
return 3
default:
return 2
}
}
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) {
switch typed.Kind {
case "record_read", "record_format":
return 2
default:
return 3
}
}
return 2
}
func recoveryGuidance(err error) string {
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) {
return ""
}
switch typed.Kind {
case "missing_evidence":
return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API."
case "invalid_evidence":
return "Use a record with a valid full tag and Git object ID."
case "endpoint_changed":
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
case "transport_configuration":
return "Review the current supported transport settings for the recorded remote, then retry observation."
case "canceled":
return "Retry observation with a context that allows it to complete."
case "remote_observation", "inconsistent_observation":
return "Review the Git diagnostic and the remote tag refs before taking any corrective action."
default:
return ""
}
}
func reconciliationGuidance(state releasekit.PublicationState) string {
switch state {
case releasekit.PublicationNotPublished:
return "This reports current absence only; review publication history and version policy before retrying."
case releasekit.PublicationConflict:
return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure."
default:
return ""
}
}
func humanDiagnostic(err error) string {
var typed *releasekit.PublicationRecoveryError
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
return fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error())
}
return err.Error()
}
func writeJSON(writer io.Writer, value any) {
encoder := json.NewEncoder(writer)
_ = encoder.Encode(value)
}

View File

@@ -0,0 +1,283 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
repository := newCLIRepository(t)
remote := filepath.Join(t.TempDir(), "bare remote.git")
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
store := filepath.Join(t.TempDir(), "records with ' punctuation")
evidence := publish.Evidence{
RepositoryDir: repository,
ModuleDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
CandidateCommit: commit,
TagObject: tagObject,
Endpoint: remote,
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
record, err := publish.SaveEvidence(repository, store, evidence)
if err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "switch", "-c", "changed-head")
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD")
cliGitRun(t, repository, "tag", "-d", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil {
t.Fatal(err)
}
otherDirectory := t.TempDir()
relativeRecord, err := filepath.Rel(otherDirectory, record)
if err != nil {
t.Fatal(err)
}
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(otherDirectory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.Chdir(previousDirectory); err != nil {
t.Errorf("restore working directory: %v", err)
}
})
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr)
if status != 0 || stderr.Len() != 0 {
t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
var published map[string]any
if err := json.Unmarshal(stdout.Bytes(), &published); err != nil {
t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String())
}
if published["state"] != string("published") || published["record_path"] != relativeRecord {
t.Fatalf("published JSON = %#v", published)
}
if _, ok := published["diagnostic"]; ok {
t.Fatalf("successful reconciliation includes a diagnostic: %#v", published)
}
evidenceJSON, ok := published["evidence"].(map[string]any)
if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject {
t.Fatalf("safe evidence = %#v", published["evidence"])
}
if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil {
t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON)
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object")
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
var conflictOut, conflictErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr)
if status != 1 || conflictErr.Len() != 0 {
t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String())
}
var conflict map[string]any
if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil {
t.Fatal(err)
}
if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject {
t.Fatalf("conflict JSON = %#v", conflict)
}
var humanOut, humanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr)
if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") {
t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String())
}
partialEvidence := evidence
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
partialEvidence.CandidateCommit = ""
partialRecord, err := publish.SaveEvidence(repository, store, partialEvidence)
if err != nil {
t.Fatal(err)
}
var unknownOut, unknownErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr)
if status != 3 || unknownErr.Len() != 0 {
t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String())
}
var unknown map[string]any
if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil {
t.Fatal(err)
}
unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any)
if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" {
t.Fatalf("unknown JSON = %#v", unknown)
}
var unknownHumanOut, unknownHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr)
if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") {
t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String())
}
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
var absentOut, absentErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr)
if status != 1 || absentErr.Len() != 0 {
t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String())
}
var absent map[string]any
if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil {
t.Fatal(err)
}
if absent["state"] != "not_published" {
t.Fatalf("absence JSON = %#v", absent)
}
if _, ok := absent["diagnostic"]; ok {
t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent)
}
var absentHumanOut, absentHumanErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr)
if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") {
t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String())
}
brokenRecord := filepath.Join(store, "broken record's.json")
if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
var brokenOut, brokenErr bytes.Buffer
status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr)
if status != 2 || brokenErr.Len() != 0 {
t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String())
}
var broken map[string]any
if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil {
t.Fatal(err)
}
brokenDiagnostic, ok := broken["diagnostic"].(map[string]any)
if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord {
t.Fatalf("record-format JSON = %#v", broken)
}
for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} {
if _, ok := broken[field]; ok {
t.Errorf("record-format JSON includes %q: %#v", field, broken)
}
}
}
func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) {
repository := newCLIRepository(t)
store := filepath.Join(t.TempDir(), "external records")
evidence := publish.Evidence{
RepositoryDir: repository,
ModulePath: "example.com/cli-candidate",
Version: "v1.2.3",
Tag: "v1.2.3",
Endpoint: "https://cli-user:cli-secret@example.test/repository.git",
Remote: "origin",
RecordedAt: "2026-11-02T00:00:00Z",
}
if _, err := publish.SaveEvidence(repository, store, evidence); err != nil {
t.Fatal(err)
}
brokenPath := filepath.Join(store, "damaged.json")
if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr)
if status != 1 || stderr.Len() != 0 {
t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
output := stdout.String()
if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") {
t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output)
}
var listed map[string]any
if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil {
t.Fatal(err)
}
attempts, ok := listed["attempts"].([]any)
if !ok || len(attempts) != 1 {
t.Fatalf("attempts = %#v", listed["attempts"])
}
diagnostics, ok := listed["diagnostics"].([]any)
if !ok || len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", listed["diagnostics"])
}
if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil {
t.Fatalf("listing document = %#v", listed)
}
entry := attempts[0].(map[string]any)
if entry["record_path"] == "" {
t.Fatalf("attempt is missing record_path: %#v", entry)
}
entryEvidence := entry["evidence"].(map[string]any)
if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt {
t.Fatalf("attempt evidence = %#v", entryEvidence)
}
diagnostic := diagnostics[0].(map[string]any)
if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath {
t.Fatalf("record diagnostic = %#v", diagnostic)
}
emptyStore := filepath.Join(t.TempDir(), "empty store")
if err := os.Mkdir(emptyStore, 0o700); err != nil {
t.Fatal(err)
}
var emptyOut, emptyErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr)
if status != 0 || emptyErr.Len() != 0 {
t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String())
}
var empty map[string]any
if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
t.Fatal(err)
}
if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil {
t.Fatalf("empty listing document = %#v", empty)
}
var failedOut, failedErr bytes.Buffer
status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr)
if status != 1 || failedErr.Len() != 0 {
t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String())
}
var failed map[string]any
if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil {
t.Fatal(err)
}
failedDiagnostic, ok := failed["diagnostic"].(map[string]any)
if !ok || failedDiagnostic["kind"] != "storage_enumeration" {
t.Fatalf("enumeration failure JSON = %#v", failed)
}
}
func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr)
if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") {
t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
}
if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) {
t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}

View File

@@ -356,6 +356,8 @@ reuse codec cases rather than repeating all parser permutations here.
## Stage 6: Add CLI recovery, discovery, and structured output
**Completed:** 2026-11-02
**Outcome:** Operators and automation can discover and reconcile explicitly
selected attempts without profiles or diagnostic parsing.