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

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

View File

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

View File

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

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