Extract publication recovery observation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
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