215 lines
7.7 KiB
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)
|
|
}
|