Files
ReleaseKit/internal/publish/publish.go

215 lines
7.7 KiB
Go

// 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/gotool"
"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, *gotool.Owner) 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.Tag, 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
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
if err := candidate.Validate(ctx, checks, captured); err != nil {
return result, err
}
if request.AfterValidate != nil {
callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner)
refreshErr := checks.ToolOwner.Refresh(ctx)
if callbackErr != nil {
return result, fmt.Errorf("after candidate validation: %w", callbackErr)
}
if refreshErr != nil {
return result, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr)
}
}
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.Tag, 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.Tag, 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.Tag, 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.Tag, 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.Tag)
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 to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, 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 at %s: %w", captured.Tag, endpoint.PushURL, 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 at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr)
}
func isRemoteConflict(err error) bool {
return errors.Is(err, git.ErrRemoteMainMissing) ||
errors.Is(err, git.ErrRemoteMainConflict) ||
errors.Is(err, git.ErrRemoteTagConflict)
}