From 4dff8086abede04fcf93be299e441cf203f09af3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 18:58:48 +0000 Subject: [PATCH] Extract publication recovery observation --- docs/roadmap/implementation.md | 2 + internal/git/remote.go | 67 +++++++++++++- internal/git/remote_test.go | 40 +++++++++ internal/publish/publish.go | 9 +- internal/publish/reconcile.go | 138 +++++++++++++++++++++++++++++ internal/publish/reconcile_test.go | 59 ++++++++++++ 6 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 internal/publish/reconcile.go create mode 100644 internal/publish/reconcile_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6ed650c..e8a243d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -48,6 +48,8 @@ packages depend on internal types. Preserve the small `buildinfo` boundary. ## Stage 1: Extract the shared observer and classification +**Completed:** 2026-11-02 + **Outcome:** Immediate failed-push recovery and a later observation can share classification without sharing publication prerequisites. diff --git a/internal/git/remote.go b/internal/git/remote.go index db3b791..75a9619 100644 --- a/internal/git/remote.go +++ b/internal/git/remote.go @@ -84,6 +84,47 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e return endpoint, nil } +// ObserveEndpoint creates a read-only command-scoped endpoint from previously +// captured publication evidence. The recorded URL is used for both directions; +// current transport settings are copied only when the original remote exists. +func ObserveEndpoint(ctx context.Context, repositoryDir, endpointURL, remoteName string) (Endpoint, error) { + if endpointURL == "" { + return Endpoint{}, fmt.Errorf("empty captured endpoint") + } + scopedName, err := newScopedName() + if err != nil { + return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err) + } + endpoint := Endpoint{RemoteName: remoteName, PushURL: endpointURL, ScopedName: scopedName} + if remoteName != "" { + config, configErr := transportConfig(ctx, repositoryDir, remoteName, scopedName) + if configErr != nil { + return Endpoint{}, configErr + } + endpoint.transport = config + } + if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { + return Endpoint{}, err + } + return endpoint, nil +} + +// EqualObjectID compares supported full object IDs without regard to hex case. +func EqualObjectID(left, right string) bool { + return validObjectID(left) && validObjectID(right) && strings.EqualFold(left, right) +} + +// ValidObjectID reports whether value is a full supported Git object ID. +func ValidObjectID(value string) bool { return validObjectID(value) } + +// DisplayError exposes credential-safe rendering for shared recovery errors. +func DisplayError(err error) error { + if err == nil { + return nil + } + return displayError{cause: err} +} + // Recheck confirms both the selected remote's effective push destination and // the scoped remote's effective read and push URLs still identify this endpoint. func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error { @@ -305,15 +346,33 @@ func finalizeRef(ref RemoteRef) RemoteRef { // CheckTagRef validates a complete tag name using Git ref rules. func CheckTagRef(ctx context.Context, repositoryDir, tag string) error { - if tag == "" { + valid, err := ValidateTagRef(ctx, repositoryDir, tag) + if err != nil { + return err + } + if !valid { return fmt.Errorf("invalid Git tag name %q", tag) } - if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil { - return fmt.Errorf("invalid Git tag name %q: %w", tag, err) - } return nil } +// ValidateTagRef distinguishes a rejected ref from failures to run Git or +// access the selected repository. +func ValidateTagRef(ctx context.Context, repositoryDir, tag string) (bool, error) { + if tag == "" { + return false, nil + } + _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag) + if err == nil { + return true, nil + } + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("check Git tag name: %w", err) +} + func validObjectID(object string) bool { if len(object) != 40 && len(object) != 64 { return false diff --git a/internal/git/remote_test.go b/internal/git/remote_test.go index 0662442..831c3a1 100644 --- a/internal/git/remote_test.go +++ b/internal/git/remote_test.go @@ -42,6 +42,46 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) { } } +func TestObserveEndpointUsesCapturedURLAndCurrentNamedTransportSettings(t *testing.T) { + repo := newRemoteFixture(t) + gitRun(t, repo.work, "remote", "remove", "origin") + gitRun(t, repo.work, "config", "remote.renamed.receivepack", "git-receive-pack") + endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "renamed") + if err != nil { + t.Fatal(err) + } + if endpoint.PushURL != repo.bare || len(endpoint.transport) != 1 || endpoint.transport[0] != "remote."+endpoint.ScopedName+".receivepack=git-receive-pack" { + t.Fatalf("observed endpoint = %#v", endpoint) + } + ref, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3") + if err != nil || ref.State != RefAbsent { + t.Fatalf("QueryTag() = %#v, %v", ref, err) + } +} + +func TestObserveEndpointDoesNotSelectOriginWithoutAName(t *testing.T) { + repo := newRemoteFixture(t) + gitRun(t, repo.work, "config", "remote.origin.receivepack", "custom-receive-pack") + endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "") + if err != nil { + t.Fatal(err) + } + if len(endpoint.transport) != 0 { + t.Fatalf("transport settings = %v; unnamed endpoint must not select origin", endpoint.transport) + } +} + +func TestObserveEndpointRejectsCapturedURLRewriting(t *testing.T) { + repo := newRemoteFixture(t) + replacement := filepath.Join(t.TempDir(), "replacement.git") + gitRun(t, repo.work, "init", "--bare", replacement) + gitRun(t, repo.work, "config", "url."+replacement+".insteadOf", repo.bare) + _, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "") + if !errors.Is(err, ErrEndpointChanged) { + t.Fatalf("ObserveEndpoint() error = %v, want ErrEndpointChanged", err) + } +} + func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) { fixture := newRemoteFixture(t) endpoint, err := Resolve(context.Background(), fixture.work, "origin") diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 9c342cf..cdc10ad 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -179,22 +179,23 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout) defer cancel() remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag) - if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict { + observation, classifyErr := classify(Evidence{RepositoryDir: captured.RepositoryDir, Tag: captured.Tag, CandidateCommit: captured.Commit, TagObject: tagObject, Endpoint: endpoint.PushURL}, remoteTag) + if queryErr != nil || classifyErr != nil { result.State = Unknown if queryErr == nil { if remoteTag.State == git.RefConflict { queryErr = errors.New("authoritative tag query returned inconsistent exact ref records") } else { - queryErr = git.ErrRemoteStateUnreadable + queryErr = classifyErr } } return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr) } - if remoteTag.State == git.RefAbsent { + if observation.State == NotPublished { result.State = NotPublished return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr) } - if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) { + if observation.State == Published { result.Phase = Complete result.State = Published return result, nil diff --git a/internal/publish/reconcile.go b/internal/publish/reconcile.go new file mode 100644 index 0000000..7f9c2cc --- /dev/null +++ b/internal/publish/reconcile.go @@ -0,0 +1,138 @@ +package publish + +import ( + "context" + "errors" + + "go.maximumdirect.net/releasekit/internal/git" +) + +// Evidence is the identity captured when one publication was attempted. +type Evidence struct { + RepositoryDir string + ModuleDir string + ModulePath string + Version string + Tag string + CandidateCommit string + TagObject string + Endpoint string + Remote string + RecordedAt string +} + +// Observation combines the original evidence with authoritative remote state. +type Observation struct { + Evidence Evidence + State State + ObservedTagObject string + ObservedCommit string +} + +// RecoveryError describes why a later publication observation is unresolved. +type RecoveryError struct { + Kind string + MissingFields []string + cause error +} + +func (e *RecoveryError) Error() string { + if e == nil { + return "" + } + message := "publication recovery " + e.Kind + if e.cause != nil { + return message + ": " + git.DisplayError(e.cause).Error() + } + return message +} +func (e *RecoveryError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func classify(evidence Evidence, remoteTag git.RemoteRef) (Observation, error) { + result := Observation{Evidence: evidence, State: Unknown, ObservedTagObject: remoteTag.Object, ObservedCommit: remoteTag.Peeled} + missing := []string{} + if evidence.Endpoint == "" { + missing = append(missing, "endpoint") + } + if evidence.Tag == "" { + missing = append(missing, "tag") + } + if len(missing) > 0 { + return result, &RecoveryError{Kind: "missing_evidence", MissingFields: missing} + } + if remoteTag.State == git.RefAbsent { + result.State = NotPublished + return result, nil + } + if evidence.TagObject == "" { + return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"tag_object"}} + } + if remoteTag.State != git.RefPresent { + return result, &RecoveryError{Kind: "inconsistent_observation", cause: git.ErrRemoteStateUnreadable} + } + if !git.EqualObjectID(remoteTag.Object, evidence.TagObject) { + result.State = Conflict + return result, nil + } + if remoteTag.Peeled != "" { + if evidence.CandidateCommit == "" { + return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"candidate_commit"}} + } + if !git.EqualObjectID(remoteTag.Peeled, evidence.CandidateCommit) { + result.State = Conflict + return result, nil + } + } + result.State = Published + return result, nil +} + +func observe(ctx context.Context, evidence Evidence) (Observation, error) { + if err := ctx.Err(); err != nil { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "canceled", cause: err} + } + if evidence.Endpoint == "" || evidence.Tag == "" { + return classify(evidence, git.RemoteRef{}) + } + if evidence.TagObject != "" && !git.ValidObjectID(evidence.TagObject) { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"} + } + if evidence.CandidateCommit != "" && !git.ValidObjectID(evidence.CandidateCommit) { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"} + } + valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag) + if err != nil { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "remote_observation", cause: err} + } + if !valid { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"} + } + endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote) + if err != nil { + if ctx.Err() != nil { + 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) + if err != nil { + return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "remote_observation", cause: err} + } + result, err := classify(evidence, ref) + if err != nil && ctx.Err() != nil { + return result, &RecoveryError{Kind: "canceled", cause: ctx.Err()} + } + return result, err +} diff --git a/internal/publish/reconcile_test.go b/internal/publish/reconcile_test.go new file mode 100644 index 0000000..a84b891 --- /dev/null +++ b/internal/publish/reconcile_test.go @@ -0,0 +1,59 @@ +package publish + +import ( + "errors" + "strings" + "testing" + + "go.maximumdirect.net/releasekit/internal/git" +) + +func TestClassifyPublicationObservation(t *testing.T) { + tagObject, commit := strings.Repeat("a", 40), strings.Repeat("b", 40) + base := Evidence{Endpoint: "file:///remote.git", Tag: "v1.2.3", TagObject: tagObject, CandidateCommit: commit} + tests := []struct { + name string + evidence Evidence + ref git.RemoteRef + want State + wantKind string + }{ + {"absent with partial identity", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, NotPublished, ""}, + {"matching tag without peel", base, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Published, ""}, + {"matching tag and commit", base, git.RemoteRef{State: git.RefPresent, Object: strings.ToUpper(tagObject), Peeled: strings.ToUpper(commit)}, Published, ""}, + {"tag conflict", base, git.RemoteRef{State: git.RefPresent, Object: strings.Repeat("c", 40), Peeled: commit}, Conflict, ""}, + {"commit conflict", base, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: strings.Repeat("c", 40)}, Conflict, ""}, + {"missing tag object", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Unknown, "missing_evidence"}, + {"missing commit with peel", Evidence{Endpoint: base.Endpoint, Tag: base.Tag, TagObject: tagObject}, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: commit}, Unknown, "missing_evidence"}, + {"unreadable", base, git.RemoteRef{State: git.RefUnreadable}, Unknown, "inconsistent_observation"}, + {"inconsistent", base, git.RemoteRef{State: git.RefConflict}, Unknown, "inconsistent_observation"}, + {"missing endpoint", Evidence{Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"}, + {"missing tag", Evidence{Endpoint: base.Endpoint}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := classify(test.evidence, test.ref) + if got.State != test.want { + t.Fatalf("state=%q want %q", got.State, test.want) + } + var recovery *RecoveryError + if test.wantKind == "" { + if err != nil { + t.Fatalf("error=%v", err) + } + return + } + if !errors.As(err, &recovery) || recovery.Kind != test.wantKind { + t.Fatalf("error=%v want kind %q", err, test.wantKind) + } + }) + } +} + +func TestClassifyReportsAllMissingIdentityFields(t *testing.T) { + _, err := classify(Evidence{}, git.RemoteRef{State: git.RefPresent}) + var recovery *RecoveryError + if !errors.As(err, &recovery) || strings.Join(recovery.MissingFields, ",") != "endpoint,tag" { + t.Fatalf("classification error = %#v", err) + } +}