Extract publication recovery observation

This commit is contained in:
2026-11-02 18:58:48 +00:00
parent 447b42a1cd
commit 4dff8086ab
6 changed files with 307 additions and 8 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}