Classify recovery setup failures by their actual cause
This commit is contained in:
@@ -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":
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user