Add CLI publication recovery commands
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
245
cmd/releasekit/recovery.go
Normal 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)
|
||||
}
|
||||
283
cmd/releasekit/recovery_test.go
Normal file
283
cmd/releasekit/recovery_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user