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" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" ) 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 } if invocation.Command != cli.VersionCommand { invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository) if err != nil { fmt.Fprintf(stderr, "%v\n", err) return 1 } } 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 { if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { fmt.Fprintf(stderr, "profile module selection: %v\n", err) return 2 } request.ExpectedPushURL = settings.ExpectedPushURL fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version) result, err := releasekit.Publish(ctx, request) if err != nil { fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, 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 as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject) return 0 } candidate, err := releasekit.Check(ctx, request) if err != nil { fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err)) if errors.Is(err, releasekit.ErrInvalidVersion) { return 2 } return 1 } fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, 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, ModuleDir: settings.ModuleDir, 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 module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, 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 module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, 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 module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, 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, ModuleDir: settings.ModuleDir, 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.ModuleDir, checks, map[string]string{ "RELEASEKIT_VERSION": candidate.Version, "RELEASEKIT_CANDIDATE": candidate.Commit, "RELEASEKIT_REPOSITORY_DIR": candidate.RepositoryDir, "RELEASEKIT_MODULE_DIR": candidate.ModuleDir, "RELEASEKIT_MODULE_PATH": candidate.ModulePath, "RELEASEKIT_TAG": candidate.Tag, }, output, output) } } return request } func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec { if settings == nil { return releasekit.ValidationSpec{} } result := releasekit.ValidationSpec{ SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles), GoFormat: settings.GoFormat, ModuleDistribution: releasekit.ModuleDistributionValidationSpec{ WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles, LocalReplacements: settings.ModuleDistribution.LocalReplacements, }, Go: releasekit.GoValidationSpec{ Race: settings.Go.Race, FullVet: settings.Go.FullVet, CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)), }, } if settings.Markdown != nil { result.Markdown = &releasekit.MarkdownValidationSpec{ EnforceCheckoutBoundary: settings.Markdown.EnforceCheckoutBoundary, } } if settings.FileSize != nil { var limitBytes *int64 if settings.FileSize.LimitBytes != nil { limit := *settings.FileSize.LimitBytes limitBytes = &limit } result.FileSize = &releasekit.FileSizeValidationSpec{ LimitBytes: limitBytes, Exclusions: append([]string(nil), settings.FileSize.Exclusions...), } } for index, target := range settings.Go.CompileTargets { goos, goarch, _ := strings.Cut(target, "/") result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch} } return result } func candidateFailureText(err error) string { return err.Error() } func checkExpectedModule(repositoryDir, expectedModule, profileModule string) error { if expectedModule == "" { return nil } expected, err := releaseidentity.ResolveSelection(repositoryDir, expectedModule) if err != nil { return fmt.Errorf("normalize expected module %q: %w", expectedModule, err) } selected, err := releaseidentity.ResolveSelection(repositoryDir, profileModule) if err != nil { return fmt.Errorf("normalize profile module %q: %w", profileModule, err) } if expected.ModuleSubdir != selected.ModuleSubdir { return fmt.Errorf("expected module %q resolves to %q, but the profile selects %q", expectedModule, expected.ModuleSubdir, selected.ModuleSubdir) } return nil } func selectedModuleLabel(moduleDir string) string { if moduleDir == "" { return "." } return moduleDir } func copyStringsPreservingEmpty(values []string) []string { if values == nil { return nil } return append([]string{}, values...) } 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, Tag: invocation.ExpectedTag, Remote: invocation.Remote, ExpectedCommit: invocation.Commit, VerifyOnly: invocation.Prepared, }) 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 } if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil { fmt.Fprintf(stderr, "profile module selection: %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, releasekit.DependencyPreparationRequest{ RepositoryDir: invocation.Repository, ModuleDir: settings.ModuleDir, }); 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 != "" { selection, err := releaseidentity.ResolveSelection(invocation.Repository, settings.ModuleDir) if err != nil { fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err) return 1 } assetsDir := artifact.OutputDirectory(selection.ModuleDir, invocation.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, ModuleDir: settings.ModuleDir, Product: settings.Product, Version: invocation.Version, ExpectedTag: invocation.ExpectedTag, Destination: destination, Build: buildSpec, Prebuilt: prebuilt, Output: stderr, }) if err != nil { fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, 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 module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, 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.Fprintf(output, "recovery: fix the local or preflight failure for module %s version %s tag %s before retrying; no remote write was attempted\n", result.ModulePath, result.Version, result.Tag) case releasekit.DeliveryPartial: fmt.Fprintf(output, "recovery: fix the reported cause, then retry module %s version %s tag %s\n", result.ModulePath, result.Version, result.Tag) case releasekit.DeliveryConflict: fmt.Fprintf(output, "recovery: investigate conflicting hosted content for module %s version %s tag %s; use a new version if the source or published contents need correction\n", result.ModulePath, result.Version, result.Tag) case releasekit.DeliveryUnknown: fmt.Fprintf(output, "recovery: resolve the remote state for module %s version %s tag %s before retrying\n", result.ModulePath, result.Version, result.Tag) 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, ModuleDir: receipt.ModuleDir, ModulePath: receipt.ModulePath, Tag: receipt.Tag, 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{ SchemaVersion: cli.BuildReceiptSchemaVersion, RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, 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 }