Classify recovery setup failures by their actual cause

This commit is contained in:
2026-11-03 00:15:36 +00:00
parent dfaa652098
commit 2a7bed87db
7 changed files with 144 additions and 24 deletions

View File

@@ -224,7 +224,7 @@ func recoveryGuidance(err error) string {
case "endpoint_changed": case "endpoint_changed":
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying." return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
case "transport_configuration": case "transport_configuration":
return "Review the current supported transport settings for the recorded remote, then retry observation." return "Review Git configuration and the supported transport settings, then retry observation."
case "canceled": case "canceled":
return "Retry observation with a context that allows it to complete." return "Retry observation with a context that allows it to complete."
case "remote_observation", "inconsistent_observation": case "remote_observation", "inconsistent_observation":

View File

@@ -7,7 +7,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -15,6 +17,98 @@ import (
"go.maximumdirect.net/releasekit/internal/publish" "go.maximumdirect.net/releasekit/internal/publish"
) )
func TestRecoveryEndpointSetupFailureDiagnostics(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture injects Git failures through a POSIX launcher")
}
for _, test := range []struct {
name, remote, kind, guidance string
exitCode int
}{
{"unnamed configuration", "", "transport_configuration", "Git configuration", 128},
{"named configuration", "origin", "transport_configuration", "Git configuration", 128},
{"rewrite", "", "endpoint_changed", "URL rewrite", 0},
{"tooling", "", "remote_observation", "Git diagnostic", 71},
} {
t.Run(test.name, func(t *testing.T) {
repo := newCLIRepository(t)
evidence := publish.Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", Remote: test.remote}
path, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
if test.kind == "transport_configuration" {
file, err := os.OpenFile(filepath.Join(repo, ".git", "config"), os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString("\n[broken\n"); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
} else if test.kind == "endpoint_changed" {
cliGitRun(t, repo, "config", "url."+t.TempDir()+".insteadOf", repo)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
marker := filepath.Join(t.TempDir(), "queried")
bin := t.TempDir()
failValidation := ""
if test.name == "tooling" {
failValidation = "[ \"$arg\" = check-ref-format ] && exit 71\n"
}
script := "#!/bin/sh\nquery=0\nget_url=0\nfor arg in \"$@\"; do\n" + failValidation + "[ \"$arg\" = ls-remote ] && query=1\n[ \"$arg\" = --get-url ] && get_url=1\ndone\nif [ \"$query\" = 1 ] && [ \"$get_url\" = 0 ]; then : > " + shellQuote(marker) + "; exit 72; fi\nexec " + shellQuote(realGit) + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
loaded, err := releasekit.LoadPublicationEvidence(path)
if err != nil {
t.Fatal(err)
}
result, err := releasekit.ReconcilePublication(t.Context(), loaded)
var typed *releasekit.PublicationRecoveryError
if !errors.As(err, &typed) || typed.Kind != test.kind || result.State != releasekit.PublicationUnknown {
t.Fatalf("setup classification = %#v, %v", result, err)
}
if test.exitCode != 0 {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != test.exitCode {
t.Fatalf("lost Git cause: %v", err)
}
}
for _, structured := range []bool{false, true} {
args := []string{"reconcile", "--record", path}
if structured {
args = append(args, "--json")
}
var stdout, stderr bytes.Buffer
if status := run(t.Context(), args, &stdout, &stderr); status != 3 {
t.Fatalf("setup status=%d: %s%s", status, &stdout, &stderr)
}
if structured {
var document reconciliationDocument
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
t.Fatal(err)
}
if document.State != "unknown" || document.Diagnostic == nil || document.Diagnostic.Kind != test.kind {
t.Fatalf("setup JSON: %s", &stdout)
}
} else if !strings.Contains(stderr.String(), test.kind) || !strings.Contains(stderr.String(), test.guidance) {
t.Fatalf("setup guidance: %s", &stderr)
}
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("setup failure reached remote query: %v", err)
}
})
}
}
type failingRecoveryWriter struct{ err error } type failingRecoveryWriter struct{ err error }
func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err } func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err }

View File

@@ -67,7 +67,12 @@ classification without sharing publication prerequisites.
ref handling. Reading transport settings requires no current fetch/push URL ref handling. Reading transport settings requires no current fetch/push URL
equality check. Missing settings, including after a remote is renamed or equality check. Missing settings, including after a remote is renamed or
removed, permit observation; actual configuration-read failures retain their removed, permit observation; actual configuration-read failures retain their
causes. Reuse the supported transport keys already implemented. causes. Reuse the supported transport keys already implemented. Mark
configuration reads and scoped-URL configuration resolution at the Git
boundary so failures map to `transport_configuration` independently of the
optional remote name. Positively detected rewrites use `endpoint_changed`;
other operational failures use `remote_observation`, with cancellation taking
its existing precedence and causes retained throughout.
4. Extract classification so the immediate path can supply its already captured 4. Extract classification so the immediate path can supply its already captured
endpoint and transport settings. Keep `reconcilePush` as the publication endpoint and transport settings. Keep `reconcilePush` as the publication
adapter: retain its existing detached, bounded recovery context, phase adapter: retain its existing detached, bounded recovery context, phase
@@ -122,6 +127,11 @@ current transport settings, repaired settings, removed/renamed remotes, fixed
URLs, and configuration-read failure. Preserve the existing lost-acknowledgment, URLs, and configuration-read failure. Preserve the existing lost-acknowledgment,
rejected-push, endpoint-change, and cancellation behavior. rejected-push, endpoint-change, and cancellation behavior.
**R16 follow-up completed:** Configuration, rewrite, and operational failures
map by their known cause, with facade/CLI coverage for named and unnamed
configuration, positive rewrites, and tooling failures. Setup failure and rewrite
mismatch tests confirm that no remote tag query occurs.
## Stage 2: Implement private attempt records ## Stage 2: Implement private attempt records
**Completed:** 2026-11-02 **Completed:** 2026-11-02
@@ -388,6 +398,9 @@ releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]
commit evidence when that is what conflicts. Explain possible next actions commit evidence when that is what conflicts. Explain possible next actions
from known causes, without guessing that a generic Git failure is an from known causes, without guessing that a generic Git failure is an
authentication or connectivity failure. authentication or connectivity failure.
Configuration failures include endpoint/global settings when the remote name
is absent. Use the same cause-based recovery kinds from Stage 1 for JSON
diagnostics and human guidance.
4. Human listing shows path, module identity, full tag, candidate commit, and 4. Human listing shows path, module identity, full tag, candidate commit, and
recording time when available. Show per-record diagnostics alongside usable recording time when available. Show per-record diagnostics alongside usable
entries and make incomplete discovery clear. Distinguish an enumeration entries and make incomplete discovery clear. Distinguish an enumeration
@@ -507,6 +520,8 @@ contracts and a short consumer recovery workflow.
alongside cold registered-tool and temporary-launcher recovery. alongside cold registered-tool and temporary-launcher recovery.
R15 adds canceled publication and preparation-failure handoff cases using R15 adds canceled publication and preparation-failure handoff cases using
explicit push readiness and fresh later invocations. explicit push readiness and fresh later invocations.
R16 covers setup failure classifications and human/JSON guidance without
querying an invalid or substituted endpoint.
2. Establish read-only behavior through resulting refs, worktree/branch state, 2. Establish read-only behavior through resulting refs, worktree/branch state,
saved records, and controlled observations of candidate/hosted boundaries. saved records, and controlled observations of candidate/hosted boundaries.
Reuse lower-level classification and codec tests. Tests use local remotes, Reuse lower-level classification and codec tests. Tests use local remotes,

View File

@@ -694,6 +694,12 @@ no-recovery-step behavior for successful publication.
## R16: Classify endpoint setup failures by their actual cause — P3 ## R16: Classify endpoint setup failures by their actual cause — P3
**Completed:** The Git boundary marks configuration-read/resolution failures;
observation maps those, positive rewrites, and other operational errors to the
existing kinds while preserving causes. Named/unnamed malformed configuration,
rewrite, tooling, cancellation, facade, and CLI diagnostic tests passed. Tests
also verify no tag query after setup failure; Stage 1/6/8 guidance is updated.
**Relevant code:** `internal/publish/reconcile.go:116–128` and **Relevant code:** `internal/publish/reconcile.go:116–128` and
`internal/git/remote.go`'s endpoint setup. Affects Stages 1 and 6. `internal/git/remote.go`'s endpoint setup. Affects Stages 1 and 6.

View File

@@ -13,13 +13,14 @@ import (
) )
var ( var (
ErrInvalidRemote = errors.New("invalid Git remote") ErrInvalidRemote = errors.New("invalid Git remote")
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs") ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrEndpointChanged = errors.New("Git publication endpoint changed")
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrTransportConfiguration = errors.New("Git transport configuration is unreadable")
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
) )
// Endpoint identifies the single effective publication destination captured // Endpoint identifies the single effective publication destination captured
@@ -202,7 +203,7 @@ func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, ta
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error { func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName) readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
if err != nil { if err != nil {
return fmt.Errorf("resolve scoped read URL: %w", err) return fmt.Errorf("%w: resolve scoped read URL: %w", ErrTransportConfiguration, err)
} }
resolved := outputLines(readURL) resolved := outputLines(readURL)
if len(resolved) != 1 || resolved[0] != endpoint.PushURL { if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
@@ -238,7 +239,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
continue continue
} }
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", DisplayURL(remoteName), key, err) return nil, fmt.Errorf("%w: read transport setting remote.%s.%s: %w", ErrTransportConfiguration, DisplayURL(remoteName), key, err)
} }
for _, value := range outputLines(output) { for _, value := range outputLines(output) {
config = append(config, "remote."+scopedName+"."+key+"="+value) config = append(config, "remote."+scopedName+"."+key+"="+value)

View File

@@ -108,28 +108,18 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) {
} }
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag) valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil { if err != nil {
return Observation{Evidence: evidence, State: Unknown}, recoveryFailure(ctx, "remote_observation", err) return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
} }
if !valid { if !valid {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"} return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
} }
endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote) endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote)
if err != nil { if err != nil {
if ctx.Err() != nil { return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "canceled", cause: ctx.Err()}
}
kind := "endpoint_changed"
if errors.Is(err, git.ErrEndpointChanged) {
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: kind, cause: err}
}
if evidence.Remote != "" {
kind = "transport_configuration"
}
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: kind, cause: err}
} }
ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag) ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag)
if err != nil { if err != nil {
return Observation{Evidence: evidence, State: Unknown}, recoveryFailure(ctx, "remote_observation", err) return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
} }
result, err := classify(evidence, ref) result, err := classify(evidence, ref)
if err != nil && ctx.Err() != nil { if err != nil && ctx.Err() != nil {
@@ -138,6 +128,17 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) {
return result, err return result, err
} }
func observationFailure(ctx context.Context, err error) *RecoveryError {
kind := "remote_observation"
switch {
case errors.Is(err, git.ErrEndpointChanged):
kind = "endpoint_changed"
case errors.Is(err, git.ErrTransportConfiguration):
kind = "transport_configuration"
}
return recoveryFailure(ctx, kind, err)
}
// Reconcile observes the recorded endpoint and classifies its exact tag. // Reconcile observes the recorded endpoint and classifies its exact tag.
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) { func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
return observe(ctx, evidence) return observe(ctx, evidence)

View File

@@ -42,6 +42,9 @@ type PublicationReconciliationResult struct {
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed, // Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
// transport_configuration, remote_observation, inconsistent_observation, // transport_configuration, remote_observation, inconsistent_observation,
// record_format, record_read, record_write, and storage_enumeration. // record_format, record_read, record_write, and storage_enumeration.
// endpoint_changed identifies a confirmed URL-resolution mismatch;
// transport_configuration identifies configuration-read or resolution failures,
// including when the original remote name is absent.
type PublicationRecoveryError struct { type PublicationRecoveryError struct {
Kind string Kind string
RecordPath string // selected or affected record path, when applicable. RecordPath string // selected or affected record path, when applicable.