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 `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"` } // 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 RecordPath 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}, observationFailure(ctx, 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 { return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err) } ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag) if err != nil { return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err) } result, err := classify(evidence, ref) if err != nil && ctx.Err() != nil { return result, &RecoveryError{Kind: "canceled", cause: ctx.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. func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) { return observe(ctx, evidence) } // recoveryFailure classifies failed operations while preserving caller cancellation. func recoveryFailure(ctx context.Context, kind string, err error) *RecoveryError { if contextErr := ctx.Err(); contextErr != nil { return &RecoveryError{Kind: "canceled", cause: contextErr} } return &RecoveryError{Kind: kind, cause: err} }