Files
ReleaseKit/cmd/releasekit/main.go

339 lines
12 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
code := run(ctx, os.Args[1:], os.Stdout, os.Stderr)
stop()
os.Exit(code)
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
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:
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
return 0
case cli.CheckAction, cli.PublishAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr,
}
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,
}, stderr, stderr)
}
}
if invocation.Command == cli.PublishAction {
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version)
result, err := releasekit.Publish(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, err)
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
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed: %v\n", err)
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
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,
})
if result.OutputDir != "" && len(result.Targets) != 0 {
if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil {
fmt.Fprintf(stderr, "%v\n", receiptErr)
return 1
}
}
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
case cli.DeliveryAction:
return runDelivery(ctx, invocation, stdout, stderr)
}
return 2
}
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
}
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")
return 2
}
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)
return 2
}
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
}
var build *releasekit.BuildResult
var temporaryDir string
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
}
build = buildResultFromReceipt(receipt)
} else {
repositoryDir, resolveErr := filepath.Abs(invocation.Repository)
if resolveErr != nil {
fmt.Fprintf(stderr, "resolve repository directory: %v\n", resolveErr)
return 1
}
temporaryDir, err = cli.TemporaryBuildDir(repositoryDir)
if err != nil {
fmt.Fprintf(stderr, "create temporary build directory: %v\n", err)
return 1
}
defer os.RemoveAll(temporaryDir)
result, buildErr := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: filepath.Join(temporaryDir, "assets"),
Output: stderr,
})
if buildErr != nil {
fmt.Fprintf(stderr, "build tagged-source assets: %v\n", buildErr)
return 1
}
build = &result
}
}
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Destination: releasekit.GiteaDestination{
BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token,
},
Prebuilt: build,
})
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)
writeDeliveryRecovery(stderr, result)
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)
}
}
}
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")
}
}
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &releasekit.BuildResult{
RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified,
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
}
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,
SourceVerified: result.SourceVerified,
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
}