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 := candidateRequest(invocation, settings, 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: %s\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(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: %s\n", candidateFailureText(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) case cli.CIDeliveryAction: return runCIDelivery(ctx, invocation, stdout, stderr) } return 2 } 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, Validation: validationSpec(settings.Validation), 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 validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec { if settings == nil { return releasekit.ValidationSpec{} } return releasekit.ValidationSpec{ Go: releasekit.GoValidationSpec{Race: settings.Go.Race}, } } func candidateFailureText(err error) string { return err.Error() } 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 } return deliverWithSettings(ctx, invocation, settings, stdout, stderr) } 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) } // 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 { 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) { 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 releasekit.GiteaDestination{}, 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 releasekit.GiteaDestination{}, 2 } return releasekit.GiteaDestination{BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token}, 0 } func deliverWithDestination(ctx context.Context, invocation cli.Invocation, settings cli.Settings, destination releasekit.GiteaDestination, stdout, stderr io.Writer) int { 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 buildSpec *releasekit.BuildSpec var prebuilt *releasekit.BuildResult if invocation.AssetsDir == "" && settings.Build != nil { buildSpec = configuredBuildSpec(settings.Build) } 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 } prebuilt = buildResultFromReceipt(receipt) } } result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ RepositoryDir: invocation.Repository, Product: settings.Product, Version: invocation.Version, Destination: destination, Build: buildSpec, Prebuilt: prebuilt, Output: stderr, }) 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 }