Reconcile uncertain tag pushes

This commit is contained in:
2026-10-05 18:53:44 +00:00
parent 3757d40ac4
commit 7481dd0fd2
7 changed files with 388 additions and 22 deletions

View File

@@ -92,6 +92,34 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
return nil
}
// QueryTag reads one exact authoritative version tag and its peeled commit.
// Failed reads are unreadable and never interpreted as absence.
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
if !validVersionRef(version) {
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
}
ref := "refs/tags/" + version
peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
if err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err)
}
remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) {
object, name, ok := strings.Cut(line, "\t")
if !ok || !validObjectID(object) {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: malformed ls-remote output %q", ErrRemoteStateUnreadable, line)
}
switch name {
case ref:
remoteTag = mergeRef(remoteTag, object, false)
case peeledRef:
remoteTag = mergeRef(remoteTag, object, true)
}
}
return remoteTag, nil
}
// PushTag publishes only the captured tag object to its exact tag ref.
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
if !validVersionRef(version) || !validObjectID(tagObject) {

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"time"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -24,13 +25,16 @@ const (
type Phase string
const (
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Complete Phase = "complete"
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Reconciliation Phase = "reconciliation"
Complete Phase = "complete"
)
const recoveryTimeout = 30 * time.Second
type Request struct {
RepositoryDir string
Product string
@@ -50,8 +54,9 @@ type Result struct {
}
// Publish validates one captured candidate and pushes only its annotated tag
// object. A failed push remains Unknown until authoritative reconciliation is
// added by the following implementation stage.
// object. A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error.
func Publish(ctx context.Context, request Request) (Result, error) {
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
remoteName := request.Remote
@@ -129,13 +134,42 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = Push
result.State = Unknown
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil {
return result, err
return reconcilePush(endpoint, captured, tagObject, result, err)
}
result.Phase = Complete
result.State = Published
return result, nil
}
func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObject string, result Result, pushErr error) (Result, error) {
result.Phase = Reconciliation
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel()
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version)
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
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
}
}
return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%v): %w", captured.Version, queryErr, pushErr)
}
if remoteTag.State == git.RefAbsent {
result.State = NotPublished
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent: %w", captured.Version, pushErr)
}
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete
result.State = Published
return result, nil
}
result.State = Conflict
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s: %w", captured.Version, remoteTag.Object, tagObject, pushErr)
}
func isRemoteConflict(err error) bool {
return errors.Is(err, git.ErrRemoteMainMissing) ||
errors.Is(err, git.ErrRemoteMainConflict) ||