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
2026-11-01 14:37:41 +00:00
"go.maximumdirect.net/releasekit/internal/candidate"
"go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/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-11-02 19:08:07 +00:00
RecordDir 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
2026-10-10 02:43:23 +00:00
AfterValidate func ( context . Context , candidate . Candidate , * gotool . Owner ) error
2026-10-06 17:15:46 +00:00
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-11-02 19:08:07 +00:00
Evidence Evidence
RecordPath string
RecordError error
2026-10-05 18:24:37 +00:00
}
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-11-02 19:08:07 +00:00
func Publish ( ctx context . Context , request Request ) ( result Result , resultErr error ) {
result = Result { Version : request . Version , Phase : Preflight , State : NotAttempted }
var recordDir , repositoryRoot string
initialized , recorded := false , false
defer func ( ) {
if ! initialized || recorded || result . RecordError != nil {
return
}
recorded = true
2026-11-02 22:20:53 +00:00
path , err := saveEvidenceInDir ( ctx , result . RepositoryDir , repositoryRoot , request . RecordDir , recordDir , result . Evidence )
2026-11-02 19:08:07 +00:00
if err != nil {
result . RecordError = err
return
}
result . RecordPath = path
} ( )
2026-10-05 18:24:37 +00:00
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
}
2026-11-02 22:17:29 +00:00
captured , captureErr := 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-11-02 19:08:07 +00:00
result . Evidence = Evidence { RepositoryDir : captured . RepositoryDir , ModuleDir : captured . ModuleDir , ModulePath : captured . ModulePath , Version : captured . Version , Tag : captured . Tag , CandidateCommit : captured . Commit , Remote : remoteName }
if captured . RepositoryDir != "" {
initialized = true
result . Evidence . RecordedAt = time . Now ( ) . UTC ( ) . Format ( time . RFC3339Nano )
2026-11-02 22:17:29 +00:00
var storageErr error
2026-11-02 22:20:53 +00:00
recordDir , repositoryRoot , storageErr = resolveRecordDir ( ctx , captured . RepositoryDir , request . RecordDir )
2026-11-02 22:17:29 +00:00
if storageErr != nil {
result . RecordError = & RecoveryError { Kind : "record_write" , cause : storageErr }
2026-11-02 19:08:07 +00:00
recordDir = ""
recorded = true
}
}
2026-11-02 22:17:29 +00:00
if captureErr != nil {
return result , fmt . Errorf ( "capture release candidate: %w" , captureErr )
2026-10-05 18:24:37 +00:00
}
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-11-02 19:08:07 +00:00
result . Evidence . Endpoint = endpoint . PushURL
2026-10-07 22:31:58 +00:00
if request . ExpectedPushURL != "" && request . ExpectedPushURL != endpoint . PushURL {
2026-11-02 03:19:16 +00:00
return result , fmt . Errorf ( "%w: expected %q, resolved %q" , ErrUnexpectedPushURL , git . DisplayURL ( request . ExpectedPushURL ) , git . DisplayURL ( endpoint . PushURL ) )
2026-10-07 22:31:58 +00:00
}
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 )
}
2026-11-02 19:08:07 +00:00
if err := candidate . Recheck ( ctx , captured , "" ) ; err != nil {
return result , fmt . Errorf ( "pre-validation local guard: candidate worktree and index must remain clean: %w" , err )
}
2026-10-10 02:18:02 +00:00
if _ , err := endpoint . RequireCandidate ( ctx , captured . RepositoryDir , captured . Tag , captured . Commit ) ; err != nil {
2026-10-05 18:24:37 +00:00
if isRemoteConflict ( err ) {
result . State = Conflict
}
return result , fmt . Errorf ( "pre-validation remote guard: %w" , err )
}
result . Phase = Validation
2026-10-10 02:43:23 +00:00
checks . ToolOwner = gotool . NewOwner ( captured . ModuleDir )
2026-10-05 18:24:37 +00:00
if err := candidate . Validate ( ctx , checks , captured ) ; err != nil {
return result , err
}
2026-10-05 19:38:50 +00:00
if request . AfterValidate != nil {
2026-10-10 02:43:23 +00:00
callbackErr := request . AfterValidate ( ctx , captured , checks . ToolOwner )
if callbackErr != nil {
return result , fmt . Errorf ( "after candidate validation: %w" , callbackErr )
}
2026-10-05 19:38:50 +00:00
}
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 )
}
2026-10-10 02:18:02 +00:00
if _ , err := endpoint . RequireCandidate ( ctx , captured . RepositoryDir , captured . Tag , captured . Commit ) ; err != nil {
2026-10-05 18:24:37 +00:00
if isRemoteConflict ( err ) {
result . State = Conflict
}
return result , fmt . Errorf ( "pre-publication remote guard: %w" , err )
}
result . Phase = TagCreation
result . State = NotPublished
2026-10-10 02:18:02 +00:00
tagObject , err := git . CreateAnnotatedTag ( ctx , captured . RepositoryDir , captured . Tag , captured . Commit , [ ] byte ( captured . Note ) )
2026-11-02 03:20:13 +00:00
result . TagObject = tagObject
2026-11-02 19:08:07 +00:00
result . Evidence . TagObject = tagObject
2026-10-05 18:24:37 +00:00
if err != nil {
return result , err
}
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 )
}
2026-10-10 02:18:02 +00:00
if _ , err := endpoint . RequireCandidate ( ctx , captured . RepositoryDir , captured . Tag , captured . Commit ) ; err != nil {
2026-10-05 18:24:37 +00:00
if isRemoteConflict ( err ) {
result . State = Conflict
}
return result , fmt . Errorf ( "pre-push remote guard: %w" , err )
}
result . Phase = Push
result . State = Unknown
2026-11-02 19:08:07 +00:00
if ! recorded {
recorded = true
2026-11-02 22:20:53 +00:00
path , recordErr := saveEvidenceInDir ( ctx , result . RepositoryDir , repositoryRoot , request . RecordDir , recordDir , result . Evidence )
2026-11-02 19:08:07 +00:00
if recordErr != nil {
result . RecordError = recordErr
} else {
result . RecordPath = path
}
}
2026-10-10 02:18:02 +00:00
if err := endpoint . PushTag ( ctx , captured . RepositoryDir , captured . Tag , 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 ( )
2026-10-10 02:18:02 +00:00
remoteTag , queryErr := endpoint . QueryTag ( recoveryCtx , captured . RepositoryDir , captured . Tag )
2026-11-02 18:58:48 +00:00
observation , classifyErr := classify ( Evidence { RepositoryDir : captured . RepositoryDir , Tag : captured . Tag , CandidateCommit : captured . Commit , TagObject : tagObject , Endpoint : endpoint . PushURL } , remoteTag )
if queryErr != nil || classifyErr != nil {
2026-10-05 18:53:44 +00:00
result . State = Unknown
if queryErr == nil {
if remoteTag . State == git . RefConflict {
queryErr = errors . New ( "authoritative tag query returned inconsistent exact ref records" )
} else {
2026-11-02 18:58:48 +00:00
queryErr = classifyErr
2026-10-05 18:53:44 +00:00
}
}
2026-11-02 03:19:16 +00:00
return result , fmt . Errorf ( "push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w" , captured . Tag , git . DisplayURL ( endpoint . PushURL ) , queryErr , pushErr )
2026-10-05 18:53:44 +00:00
}
2026-11-02 18:58:48 +00:00
if observation . State == NotPublished {
2026-10-05 18:53:44 +00:00
result . State = NotPublished
2026-11-02 03:19:16 +00:00
return result , fmt . Errorf ( "push annotated tag %s failed and the authoritative tag is absent at %s: %w" , captured . Tag , git . DisplayURL ( endpoint . PushURL ) , pushErr )
2026-10-05 18:53:44 +00:00
}
2026-11-02 18:58:48 +00:00
if observation . State == Published {
2026-10-05 18:53:44 +00:00
result . Phase = Complete
result . State = Published
return result , nil
}
result . State = Conflict
2026-11-02 03:19:16 +00:00
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 , git . DisplayURL ( endpoint . PushURL ) , pushErr )
2026-10-05 18:53:44 +00:00
}
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 )
}