// Package publish coordinates candidate validation and exact tag publication. package publish import ( "context" "errors" "fmt" "io" "time" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) type State string const ( NotAttempted State = "not_attempted" NotPublished State = "not_published" Published State = "published" Conflict State = "conflict" Unknown State = "unknown" ) type Phase string const ( Preflight Phase = "preflight" Validation Phase = "validation" TagCreation Phase = "tag_creation" Push Phase = "push" Reconciliation Phase = "reconciliation" Complete Phase = "complete" ) const recoveryTimeout = 30 * time.Second // ErrUnexpectedPushURL reports that the resolved push URL differs from the // caller's expected publication destination. var ErrUnexpectedPushURL = errors.New("unexpected publication push URL") type Request struct { RepositoryDir string ModuleDir string Product string Version string Remote string ExpectedPushURL string Validation validation.Settings AdditionalChecks func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error Output io.Writer } type Result struct { RepositoryDir string ModuleDir string ModulePath string Tag string Version string CandidateCommit string Endpoint string Phase Phase TagObject string State State } // Publish captures a candidate, resolves and checks the optional expected push // URL, then validates the candidate and pushes only its annotated tag object. // It runs one standard Go suite, with the selected settings, then // AdditionalChecks when supplied. // 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 if remoteName == "" { remoteName = "origin" } checks := candidate.Request{ RepositoryDir: request.RepositoryDir, ModuleDir: request.ModuleDir, Product: request.Product, Version: request.Version, Validation: request.Validation, AdditionalChecks: request.AdditionalChecks, Output: request.Output, } captured, err := candidate.Capture(ctx, checks) result.RepositoryDir = captured.RepositoryDir result.ModuleDir = captured.ModuleDir result.ModulePath = captured.ModulePath result.Tag = captured.Tag result.CandidateCommit = captured.Commit if err != nil { return result, fmt.Errorf("capture release candidate: %w", err) } endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName) if err != nil { return result, fmt.Errorf("resolve publication endpoint: %w", err) } result.Endpoint = endpoint.PushURL if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL { return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL) } if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-validation endpoint guard: %w", err) } if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } return result, fmt.Errorf("pre-validation remote guard: %w", err) } if err := candidate.Recheck(ctx, captured, ""); err != nil { return result, fmt.Errorf("pre-validation local guard: %w", err) } result.Phase = Validation if err := candidate.Validate(ctx, checks, captured); err != nil { return result, err } if request.AfterValidate != nil { if err := request.AfterValidate(ctx, captured); err != nil { return result, fmt.Errorf("after candidate validation: %w", err) } } if err := candidate.Recheck(ctx, captured, ""); err != nil { return result, fmt.Errorf("post-validation local guard: %w", err) } if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-publication endpoint guard: %w", err) } if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } return result, fmt.Errorf("pre-publication remote guard: %w", err) } result.Phase = TagCreation result.State = NotPublished tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Version, captured.Commit, []byte(captured.Note)) if err != nil { return result, err } result.TagObject = tagObject if err := candidate.Recheck(ctx, captured, tagObject); err != nil { return result, fmt.Errorf("pre-push local guard: %w", err) } if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { return result, fmt.Errorf("pre-push endpoint guard: %w", err) } if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if isRemoteConflict(err) { result.State = Conflict } return result, fmt.Errorf("pre-push remote guard: %w", err) } result.Phase = Push result.State = Unknown if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil { 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 (%w): %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) || errors.Is(err, git.ErrRemoteTagConflict) }