2026-10-05 18:24:37 +00:00
// Package publish coordinates candidate validation and exact tag publication.
package publish
import (
"context"
"errors"
"fmt"
"io"
2026-10-05 18:53:44 +00:00
"time"
2026-10-05 18:24:37 +00:00
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
2026-10-07 22:10:34 +00:00
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
2026-10-05 18:24:37 +00:00
)
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 (
2026-10-05 18:53:44 +00:00
Preflight Phase = "preflight"
Validation Phase = "validation"
TagCreation Phase = "tag_creation"
Push Phase = "push"
Reconciliation Phase = "reconciliation"
Complete Phase = "complete"
2026-10-05 18:24:37 +00:00
)
2026-10-05 18:53:44 +00:00
const recoveryTimeout = 30 * time . Second
2026-10-07 22:31:58 +00:00
// ErrUnexpectedPushURL reports that the resolved push URL differs from the
// caller's expected publication destination.
var ErrUnexpectedPushURL = errors . New ( "unexpected publication push URL" )
2026-10-05 18:24:37 +00:00
type Request struct {
2026-10-06 17:15:46 +00:00
RepositoryDir string
2026-10-10 02:15:13 +00:00
ModuleDir string
2026-10-06 17:15:46 +00:00
Product string
Version string
Remote string
2026-10-07 22:31:58 +00:00
ExpectedPushURL string
2026-10-07 22:10:34 +00:00
Validation validation . Settings
2026-10-06 17:15:46 +00:00
AdditionalChecks func ( context . Context , candidate . Candidate ) error
AfterValidate func ( context . Context , candidate . Candidate ) error
Output io . Writer
2026-10-05 18:24:37 +00:00
}
type Result struct {
2026-10-10 02:15:13 +00:00
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
2026-10-05 18:24:37 +00:00
Version string
CandidateCommit string
Endpoint string
Phase Phase
TagObject string
State State
}
2026-10-07 22:31:58 +00:00
// 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
2026-10-07 22:10:34 +00:00
// AdditionalChecks when supplied.
2026-10-06 17:15:46 +00:00
// A failed push receives one bounded authoritative tag query. Exact
2026-10-05 18:53:44 +00:00
// object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error.
2026-10-05 18:24:37 +00:00
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 {
2026-10-06 17:15:46 +00:00
RepositoryDir : request . RepositoryDir ,
2026-10-10 02:15:13 +00:00
ModuleDir : request . ModuleDir ,
2026-10-06 17:15:46 +00:00
Product : request . Product ,
Version : request . Version ,
2026-10-07 22:10:34 +00:00
Validation : request . Validation ,
2026-10-06 17:15:46 +00:00
AdditionalChecks : request . AdditionalChecks ,
Output : request . Output ,
2026-10-05 18:24:37 +00:00
}
captured , err := candidate . Capture ( ctx , checks )
2026-10-10 02:15:13 +00:00
result . RepositoryDir = captured . RepositoryDir
result . ModuleDir = captured . ModuleDir
result . ModulePath = captured . ModulePath
result . Tag = captured . Tag
result . CandidateCommit = captured . Commit
2026-10-05 18:24:37 +00:00
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
2026-10-07 22:31:58 +00:00
if request . ExpectedPushURL != "" && request . ExpectedPushURL != endpoint . PushURL {
return result , fmt . Errorf ( "%w: expected %q, resolved %q" , ErrUnexpectedPushURL , request . ExpectedPushURL , endpoint . PushURL )
}
2026-10-05 18:24:37 +00:00
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
}
2026-10-05 19:38:50 +00:00
if request . AfterValidate != nil {
if err := request . AfterValidate ( ctx , captured ) ; err != nil {
return result , fmt . Errorf ( "after candidate validation: %w" , err )
}
}
2026-10-05 18:24:37 +00:00
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 {
2026-10-05 18:53:44 +00:00
return reconcilePush ( endpoint , captured , tagObject , result , err )
2026-10-05 18:24:37 +00:00
}
result . Phase = Complete
result . State = Published
return result , nil
}
2026-10-05 18:53:44 +00:00
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
}
}
2026-10-06 16:04:35 +00:00
return result , fmt . Errorf ( "push annotated tag %s failed; authoritative reconciliation failed (%w): %w" , captured . Version , queryErr , pushErr )
2026-10-05 18:53:44 +00:00
}
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 )
}
2026-10-05 18:24:37 +00:00
func isRemoteConflict ( err error ) bool {
return errors . Is ( err , git . ErrRemoteMainMissing ) ||
errors . Is ( err , git . ErrRemoteMainConflict ) ||
errors . Is ( err , git . ErrRemoteTagConflict )
}