Extract publication recovery observation
This commit is contained in:
@@ -48,6 +48,8 @@ packages depend on internal types. Preserve the small `buildinfo` boundary.
|
|||||||
|
|
||||||
## Stage 1: Extract the shared observer and classification
|
## Stage 1: Extract the shared observer and classification
|
||||||
|
|
||||||
|
**Completed:** 2026-11-02
|
||||||
|
|
||||||
**Outcome:** Immediate failed-push recovery and a later observation can share
|
**Outcome:** Immediate failed-push recovery and a later observation can share
|
||||||
classification without sharing publication prerequisites.
|
classification without sharing publication prerequisites.
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,47 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
|
|||||||
return endpoint, nil
|
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
|
// Recheck confirms both the selected remote's effective push destination and
|
||||||
// the scoped remote's effective read and push URLs still identify this endpoint.
|
// the scoped remote's effective read and push URLs still identify this endpoint.
|
||||||
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
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.
|
// CheckTagRef validates a complete tag name using Git ref rules.
|
||||||
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
|
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)
|
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
|
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 {
|
func validObjectID(object string) bool {
|
||||||
if len(object) != 40 && len(object) != 64 {
|
if len(object) != 40 && len(object) != 64 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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) {
|
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
||||||
|
|||||||
@@ -179,22 +179,23 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
|||||||
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag)
|
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
|
result.State = Unknown
|
||||||
if queryErr == nil {
|
if queryErr == nil {
|
||||||
if remoteTag.State == git.RefConflict {
|
if remoteTag.State == git.RefConflict {
|
||||||
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
|
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
|
||||||
} else {
|
} 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)
|
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
|
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)
|
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.Phase = Complete
|
||||||
result.State = Published
|
result.State = Published
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
138
internal/publish/reconcile.go
Normal file
138
internal/publish/reconcile.go
Normal 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
|
||||||
|
}
|
||||||
59
internal/publish/reconcile_test.go
Normal file
59
internal/publish/reconcile_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user