2026-10-05 16:49:54 +00:00
package main
import (
2026-10-05 17:47:50 +00:00
"context"
"errors"
2026-10-05 16:49:54 +00:00
"fmt"
"io"
"os"
2026-10-05 17:47:50 +00:00
"os/signal"
2026-10-05 20:39:33 +00:00
"path/filepath"
2026-10-05 19:38:50 +00:00
"strings"
2026-10-05 17:47:50 +00:00
"syscall"
2026-10-05 16:49:54 +00:00
2026-10-05 17:47:50 +00:00
"gitea.maximumdirect.net/eric/ReleaseKit"
2026-10-05 16:49:54 +00:00
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
2026-10-05 17:25:11 +00:00
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
2026-10-05 16:49:54 +00:00
)
func main ( ) {
2026-10-05 17:47:50 +00:00
ctx , stop := signal . NotifyContext ( context . Background ( ) , os . Interrupt , syscall . SIGTERM )
code := run ( ctx , os . Args [ 1 : ] , os . Stdout , os . Stderr )
stop ( )
os . Exit ( code )
2026-10-05 16:49:54 +00:00
}
2026-10-05 17:47:50 +00:00
func run ( ctx context . Context , args [ ] string , stdout , stderr io . Writer ) int {
2026-10-05 17:25:11 +00:00
invocation , err := cli . Parse ( args )
if err != nil {
fmt . Fprintf ( stderr , "%v\n%s\n" , err , cli . Usage )
return 2
}
switch invocation . Command {
case cli . VersionCommand :
2026-10-05 16:49:54 +00:00
fmt . Fprintf ( stdout , "releasekit %s\n" , buildinfo . Version ( ) )
return 0
2026-10-05 19:04:55 +00:00
case cli . CheckAction , cli . PublishAction :
2026-10-05 17:47:50 +00:00
settings , err := cli . LoadSettings ( invocation . Repository , invocation . ConfigPath )
if err != nil {
fmt . Fprintf ( stderr , "load profile: %v\n" , err )
return 2
}
2026-10-07 18:36:37 +00:00
request := candidateRequest ( invocation , settings , stderr )
2026-10-05 19:04:55 +00:00
if invocation . Command == cli . PublishAction {
fmt . Fprintf ( stderr , "validating and publishing %s\n" , invocation . Version )
result , err := releasekit . Publish ( ctx , request )
if err != nil {
2026-10-07 18:36:37 +00:00
fmt . Fprintf ( stderr , "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %s\n" , result . State , result . Phase , result . Version , result . CandidateCommit , result . TagObject , result . Endpoint , candidateFailureText ( err ) )
2026-10-05 19:04:55 +00:00
if errors . Is ( err , releasekit . ErrInvalidVersion ) {
return 2
}
if result . State == releasekit . PublicationUnknown {
return 3
}
return 1
}
fmt . Fprintf ( stdout , "published %s at %s (tag object %s)\n" , result . Version , result . CandidateCommit , result . TagObject )
return 0
}
2026-10-05 17:47:50 +00:00
candidate , err := releasekit . Check ( ctx , request )
if err != nil {
2026-10-07 18:36:37 +00:00
fmt . Fprintf ( stderr , "check failed: %s\n" , candidateFailureText ( err ) )
2026-10-05 17:47:50 +00:00
if errors . Is ( err , releasekit . ErrInvalidVersion ) {
return 2
}
return 1
}
fmt . Fprintf ( stdout , "local candidate %s at %s is valid\n" , candidate . Version , candidate . Commit )
return 0
2026-10-05 19:38:50 +00:00
case cli . BuildAction :
settings , err := cli . LoadSettings ( invocation . Repository , invocation . ConfigPath )
if err != nil {
fmt . Fprintf ( stderr , "load profile: %v\n" , err )
return 2
}
if settings . Build == nil {
fmt . Fprintln ( stderr , "build requires build settings in the profile" )
return 2
}
result , err := releasekit . Build ( ctx , releasekit . BuildRequest {
RepositoryDir : invocation . Repository ,
Version : invocation . Version ,
Spec : * configuredBuildSpec ( settings . Build ) ,
OutputDir : invocation . OutputDir ,
Output : stderr ,
} )
2026-10-06 00:56:44 +00:00
if result . OutputDir != "" && len ( result . Targets ) != 0 {
if receiptErr := cli . RemoveBuildReceipt ( result . OutputDir ) ; receiptErr != nil {
fmt . Fprintf ( stderr , "%v\n" , receiptErr )
return 1
}
}
2026-10-05 19:38:50 +00:00
if err != nil {
fmt . Fprintf ( stderr , "build failed at %s: %v\n" , result . CandidateCommit , err )
for _ , artifact := range result . Artifacts {
fmt . Fprintf ( stderr , "built %s %s\n" , artifact . Filename , artifact . SHA256 )
}
if errors . Is ( err , releasekit . ErrInvalidVersion ) {
return 2
}
return 1
}
if len ( result . Targets ) == 0 {
fmt . Fprintf ( stdout , "no build targets selected for %s at %s\n" , result . Version , result . CandidateCommit )
return 0
}
receipt := receiptFromBuild ( result )
receiptPath , err := cli . WriteBuildReceipt ( result . OutputDir , receipt )
if err != nil {
fmt . Fprintf ( stderr , "write build receipt: %v\n" , err )
return 1
}
stored , err := cli . LoadBuildReceipt ( receiptPath )
if err != nil {
fmt . Fprintf ( stderr , "verify build receipt: %v\n" , err )
return 1
}
if ! stored . MatchesBuild ( result . RepositoryDir , result . CandidateCommit , result . Version , * settings . Build ) {
fmt . Fprintln ( stderr , "build receipt does not match the selected source and build settings" )
return 1
}
fmt . Fprintf ( stdout , "built %d artifacts for %s at %s with %s\n" , len ( result . Artifacts ) , result . Version , result . CandidateCommit , result . Compiler )
for _ , artifact := range result . Artifacts {
fmt . Fprintf ( stdout , "%s %s\n" , artifact . Filename , artifact . SHA256 )
}
fmt . Fprintf ( stdout , "build receipt: %s\n" , receiptPath )
return 0
2026-10-05 20:39:33 +00:00
case cli . DeliveryAction :
return runDelivery ( ctx , invocation , stdout , stderr )
2026-10-06 19:26:54 +00:00
case cli . CIDeliveryAction :
return runCIDelivery ( ctx , invocation , stdout , stderr )
2026-10-05 16:49:54 +00:00
}
return 2
}
2026-10-05 19:38:50 +00:00
2026-10-07 18:36:37 +00:00
func candidateRequest ( invocation cli . Invocation , settings cli . Settings , output io . Writer ) releasekit . ReleaseRequest {
request := releasekit . ReleaseRequest {
RepositoryDir : invocation . Repository ,
Product : settings . Product ,
Version : invocation . Version ,
Remote : settings . Remote ,
Build : configuredBuildSpec ( settings . Build ) ,
Output : output ,
}
if len ( settings . Checks ) != 0 {
checks := settings . Checks
request . AdditionalChecks = func ( ctx context . Context , candidate releasekit . Candidate ) error {
return cli . RunChecks ( ctx , candidate . RepositoryDir , checks , map [ string ] string {
"RELEASEKIT_VERSION" : candidate . Version ,
"RELEASEKIT_CANDIDATE" : candidate . Commit ,
} , output , output )
}
}
return request
}
func candidateFailureText ( err error ) string {
return err . Error ( )
}
2026-10-05 20:39:33 +00:00
func runDelivery ( ctx context . Context , invocation cli . Invocation , stdout , stderr io . Writer ) int {
settings , err := cli . LoadSettings ( invocation . Repository , invocation . ConfigPath )
if err != nil {
fmt . Fprintf ( stderr , "load profile: %v\n" , err )
return 2
}
2026-10-06 18:35:45 +00:00
return deliverWithSettings ( ctx , invocation , settings , stdout , stderr )
}
2026-10-06 19:26:54 +00:00
func runCIDelivery ( ctx context . Context , invocation cli . Invocation , stdout , stderr io . Writer ) int {
err := releasekit . PrepareTaggedCheckout ( ctx , releasekit . TaggedCheckoutRequest {
RepositoryDir : invocation . Repository ,
Version : invocation . Version ,
Remote : invocation . Remote ,
ExpectedCommit : invocation . Commit ,
} )
if err != nil {
fmt . Fprintf ( stderr , "prepare tagged checkout: %v\n" , err )
if errors . Is ( err , releasekit . ErrInvalidVersion ) {
return 2
}
return 1
}
settings , err := cli . LoadSettings ( invocation . Repository , invocation . ConfigPath )
if err != nil {
fmt . Fprintf ( stderr , "load profile: %v\n" , err )
return 2
}
destination , status := deliveryDestination ( settings , stderr )
if status != 0 {
return status
}
if settings . Build != nil && len ( settings . Build . Targets ) != 0 {
if err := releasekit . PrepareBuildDependencies ( ctx , invocation . Repository ) ; err != nil {
fmt . Fprintf ( stderr , "prepare build dependencies: %v\n" , err )
return 1
}
}
return deliverWithDestination ( ctx , invocation , settings , destination , stdout , stderr )
}
2026-10-06 18:35:45 +00:00
// deliverWithSettings adapts prepared CLI state to the public delivery facade.
// CI entry points can reuse it after preparing the checkout and loading its profile.
func deliverWithSettings ( ctx context . Context , invocation cli . Invocation , settings cli . Settings , stdout , stderr io . Writer ) int {
2026-10-06 19:26:54 +00:00
destination , status := deliveryDestination ( settings , stderr )
if status != 0 {
return status
}
return deliverWithDestination ( ctx , invocation , settings , destination , stdout , stderr )
}
func deliveryDestination ( settings cli . Settings , stderr io . Writer ) ( releasekit . GiteaDestination , int ) {
2026-10-05 20:39:33 +00:00
delivery := settings . Delivery
if delivery == nil || strings . TrimSpace ( delivery . BaseURL ) == "" || strings . TrimSpace ( delivery . Owner ) == "" || strings . TrimSpace ( delivery . Repository ) == "" {
fmt . Fprintln ( stderr , "deliver requires base_url, owner, and repository in the profile delivery settings" )
2026-10-06 19:26:54 +00:00
return releasekit . GiteaDestination { } , 2
2026-10-05 20:39:33 +00:00
}
tokenEnv := delivery . TokenEnv
if strings . TrimSpace ( tokenEnv ) == "" {
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
}
token , ok := os . LookupEnv ( tokenEnv )
if ! ok || strings . TrimSpace ( token ) == "" {
fmt . Fprintf ( stderr , "deliver requires a non-empty token in environment variable %s\n" , tokenEnv )
2026-10-06 19:26:54 +00:00
return releasekit . GiteaDestination { } , 2
2026-10-05 20:39:33 +00:00
}
2026-10-06 19:26:54 +00:00
return releasekit . GiteaDestination { BaseURL : delivery . BaseURL , Owner : delivery . Owner , Repository : delivery . Repository , Token : token } , 0
}
2026-10-05 20:39:33 +00:00
2026-10-06 19:26:54 +00:00
func deliverWithDestination ( ctx context . Context , invocation cli . Invocation , settings cli . Settings , destination releasekit . GiteaDestination , stdout , stderr io . Writer ) int {
2026-10-05 20:39:33 +00:00
hasTargets := settings . Build != nil && len ( settings . Build . Targets ) != 0
if invocation . AssetsDir != "" && ! hasTargets {
fmt . Fprintln ( stderr , "--assets requires build targets in the profile" )
return 2
}
2026-10-06 18:35:45 +00:00
var buildSpec * releasekit . BuildSpec
var prebuilt * releasekit . BuildResult
if invocation . AssetsDir == "" && settings . Build != nil {
buildSpec = configuredBuildSpec ( settings . Build )
}
2026-10-05 20:39:33 +00:00
if hasTargets {
if invocation . AssetsDir != "" {
assetsDir := invocation . AssetsDir
if ! filepath . IsAbs ( assetsDir ) {
assetsDir = filepath . Join ( invocation . Repository , assetsDir )
}
receipt , err := cli . LoadBuildReceipt ( filepath . Join ( assetsDir , cli . BuildReceiptName ) )
if err != nil {
fmt . Fprintf ( stderr , "load verified build receipt: %v\n" , err )
return 1
}
if ! receipt . MatchesBuild ( invocation . Repository , receipt . CandidateCommit , invocation . Version , * settings . Build ) {
fmt . Fprintln ( stderr , "build receipt does not match the selected repository, version, or build settings" )
return 1
}
2026-10-06 18:35:45 +00:00
prebuilt = buildResultFromReceipt ( receipt )
2026-10-05 20:39:33 +00:00
}
}
result , err := releasekit . Deliver ( ctx , releasekit . DeliveryRequest {
RepositoryDir : invocation . Repository ,
Product : settings . Product ,
Version : invocation . Version ,
2026-10-06 19:26:54 +00:00
Destination : destination ,
Build : buildSpec ,
Prebuilt : prebuilt ,
Output : stderr ,
2026-10-05 20:39:33 +00:00
} )
if err != nil {
fmt . Fprintf ( stderr , "delivery state=%s version=%s commit=%s url=%s: %v\n" , result . State , result . Version , result . CandidateCommit , result . ReleaseURL , err )
writeDeliveryAssets ( stderr , result )
2026-10-06 17:54:22 +00:00
writeDeliveryRecovery ( stderr , result )
2026-10-05 20:39:33 +00:00
if errors . Is ( err , releasekit . ErrInvalidVersion ) {
return 2
}
if result . State == releasekit . DeliveryUnknown {
return 3
}
return 1
}
fmt . Fprintf ( stdout , "delivered %s at %s (state %s)\n" , result . Version , result . CandidateCommit , result . State )
if result . ReleaseURL != "" {
fmt . Fprintf ( stdout , "release: %s\n" , result . ReleaseURL )
}
writeDeliveryAssets ( stdout , result )
return 0
}
func writeDeliveryAssets ( output io . Writer , result releasekit . DeliveryResult ) {
for _ , group := range [ ] struct {
label string
assets [ ] releasekit . DeliveryAsset
} { { "matching" , result . Matching } , { "uploaded" , result . Uploaded } , { "pending" , result . Pending } , { "conflicting" , result . Conflicting } , { "uncertain" , result . Uncertain } } {
for _ , asset := range group . assets {
fmt . Fprintf ( output , "%s %s %s\n" , group . label , asset . Filename , asset . SHA256 )
}
}
}
2026-10-06 17:54:22 +00:00
func writeDeliveryRecovery ( output io . Writer , result releasekit . DeliveryResult ) {
switch result . State {
case releasekit . DeliveryNotAttempted :
fmt . Fprintln ( output , "recovery: fix the local or preflight failure before retrying; no remote write was attempted" )
case releasekit . DeliveryPartial :
fmt . Fprintln ( output , "recovery: fix the reported cause, then retry this version" )
case releasekit . DeliveryConflict :
fmt . Fprintln ( output , "recovery: investigate conflicting hosted content; use a new version if the source or published contents need correction" )
case releasekit . DeliveryUnknown :
fmt . Fprintln ( output , "recovery: resolve the remote state before retrying" )
default :
return
}
if len ( result . Matching ) + len ( result . Uploaded ) + len ( result . Conflicting ) + len ( result . Uncertain ) != 0 {
fmt . Fprintln ( output , "if rebuilding could change asset bytes already on the server, retain the original build artifacts and retry with deliver --assets DIR" )
}
}
2026-10-05 20:39:33 +00:00
func buildResultFromReceipt ( receipt cli . BuildReceipt ) * releasekit . BuildResult {
result := & releasekit . BuildResult {
2026-10-06 00:51:56 +00:00
RepositoryDir : receipt . RepositoryDir , CandidateCommit : receipt . CandidateCommit , SourceVerified : receipt . SourceVerified ,
2026-10-05 20:39:33 +00:00
Version : receipt . Version , MainPackage : receipt . MainPackage , BinaryName : receipt . BinaryName ,
OutputDir : receipt . OutputDir , Compiler : receipt . Compiler ,
}
for _ , target := range receipt . Targets {
result . Targets = append ( result . Targets , releasekit . Target { OS : target . OS , Arch : target . Arch } )
}
for _ , artifact := range receipt . Artifacts {
result . Artifacts = append ( result . Artifacts , releasekit . BuildArtifact {
Path : artifact . Path , Filename : artifact . Filename ,
Target : releasekit . Target { OS : artifact . Target . OS , Arch : artifact . Target . Arch } , SHA256 : artifact . SHA256 ,
} )
}
return result
}
2026-10-05 19:38:50 +00:00
func configuredBuildSpec ( settings * cli . BuildSettings ) * releasekit . BuildSpec {
if settings == nil {
return nil
}
spec := & releasekit . BuildSpec {
MainPackage : settings . MainPackage ,
BinaryName : settings . BinaryName ,
Targets : make ( [ ] releasekit . Target , 0 , len ( settings . Targets ) ) ,
}
for _ , value := range settings . Targets {
goos , goarch , _ := strings . Cut ( value , "/" )
spec . Targets = append ( spec . Targets , releasekit . Target { OS : goos , Arch : goarch } )
}
return spec
}
func receiptFromBuild ( result releasekit . BuildResult ) cli . BuildReceipt {
receipt := cli . BuildReceipt {
RepositoryDir : result . RepositoryDir ,
CandidateCommit : result . CandidateCommit ,
2026-10-06 00:51:56 +00:00
SourceVerified : result . SourceVerified ,
2026-10-05 19:38:50 +00:00
Version : result . Version ,
MainPackage : result . MainPackage ,
BinaryName : result . BinaryName ,
OutputDir : result . OutputDir ,
Compiler : result . Compiler ,
Targets : make ( [ ] cli . ReceiptTarget , 0 , len ( result . Targets ) ) ,
Artifacts : make ( [ ] cli . ReceiptArtifact , 0 , len ( result . Artifacts ) ) ,
}
for _ , target := range result . Targets {
receipt . Targets = append ( receipt . Targets , cli . ReceiptTarget { OS : target . OS , Arch : target . Arch } )
}
for _ , artifact := range result . Artifacts {
receipt . Artifacts = append ( receipt . Artifacts , cli . ReceiptArtifact {
Path : artifact . Path ,
Filename : artifact . Filename ,
Target : cli . ReceiptTarget { OS : artifact . Target . OS , Arch : artifact . Target . Arch } ,
SHA256 : artifact . SHA256 ,
} )
}
return receipt
}