// Package cli owns command parsing, profile settings, and child-command // adaptation for the ReleaseKit executable. package cli import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG" type Command string const ( VersionCommand Command = "version" CheckAction Command = "check" PublishAction Command = "publish" BuildAction Command = "build" DeliveryAction Command = "deliver" CIDeliveryAction Command = "ci-deliver" ) // Invocation contains parsed command settings owned by the CLI adapter. type Invocation struct { Command Command Repository string ConfigPath string OutputDir string AssetsDir string Remote string Commit string Version string ExpectedModule string ExpectedTag string Prepared bool } // Parse accepts only commands that have been implemented by the executable. func Parse(args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, errors.New("a command is required") } switch args[0] { case string(VersionCommand): if len(args) != 1 { return Invocation{}, errors.New("version takes no arguments") } return Invocation{Command: VersionCommand}, nil case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction), string(CIDeliveryAction): action := Command(args[0]) flags := flag.NewFlagSet(string(action), flag.ContinueOnError) flags.SetOutput(io.Discard) repository := flags.String("repo", ".", "repository directory") configPath := flags.String("config", "", "profile path") var outputDir string if action == BuildAction { flags.StringVar(&outputDir, "output", "", "build output directory") } var assetsDir string if action == DeliveryAction { flags.StringVar(&assetsDir, "assets", "", "verified build assets directory") } var remote *string expectedModule := "" if action == PublishAction || action == CIDeliveryAction { flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") } expectedCommit := "" prepared := false if action == CIDeliveryAction { remote = flags.String("remote", "origin", "Git remote") flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit") flags.BoolVar(&prepared, "prepared", false, "verify an already prepared tagged checkout without fetching or checkout") } if err := flags.Parse(args[1:]); err != nil { return Invocation{}, err } if flags.NArg() != 1 { return Invocation{}, fmt.Errorf("%s requires one version argument", action) } if action == CIDeliveryAction && expectedCommit == "" { return Invocation{}, errors.New("ci-deliver requires --commit OID") } if action == CIDeliveryAction && !validGitObjectID(expectedCommit) { return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID") } version := flags.Arg(0) expectedTag := "" if action == CIDeliveryAction { expectedTag = version if slash := strings.LastIndex(version, "/"); slash >= 0 { version = version[slash+1:] } if !versioncontract.ValidStableVersion(version) { return Invocation{}, errors.New("ci-deliver TAG must end in a stable vMAJOR.MINOR.PATCH version") } } if !versioncontract.ValidStableVersion(version) { return Invocation{}, errors.New("VERSION must be a stable vMAJOR.MINOR.PATCH version") } parsedRemote := "" if remote != nil { parsedRemote = *remote } return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil default: return Invocation{}, fmt.Errorf("unknown command %q", args[0]) } } func validGitObjectID(value string) bool { if len(value) != 40 && len(value) != 64 { return false } for _, char := range value { if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { return false } } return true } // CheckCommand contains one direct child-process invocation from a profile. type CheckCommand struct { Args []string `json:"args"` Env map[string]string `json:"env,omitempty"` } // Settings contains the product, remote, publication expectation, shared // validation selections, and check commands read from a releasekit.json profile. type Settings struct { Product string `json:"product"` ModuleDir string `json:"module_dir,omitempty"` Remote string `json:"remote,omitempty"` ExpectedPushURL string `json:"expected_push_url,omitempty"` Checks []CheckCommand `json:"checks,omitempty"` Validation *ValidationSettings `json:"validation,omitempty"` Build *BuildSettings `json:"build,omitempty"` Delivery *DeliverySettings `json:"delivery,omitempty"` } // ValidationSettings selects shared candidate checks in a release profile. type ValidationSettings struct { // SharedFiles selects committed files relative to the repository root. // Nil uses optional defaults; a non-nil empty slice selects no shared files. SharedFiles []string `json:"shared_files"` Go GoValidationSettings `json:"go,omitempty"` // GoFormat checks the formatting of committed regular Go source files. // Omitted or false disables the check. GoFormat bool `json:"go_format,omitempty"` Markdown *MarkdownSettings `json:"markdown,omitempty"` ModuleDistribution ModuleDistributionSettings `json:"module_distribution,omitempty"` // FileSize checks the size of each committed blob. Null or omission disables // the check; an empty object enables the default limit. FileSize *FileSizeSettings `json:"file_size,omitempty"` } // MarkdownSettings selects committed Markdown destination and fragment checks. type MarkdownSettings struct { // EnforceCheckoutBoundary fails local destinations that resolve outside // the candidate checkout. Submodule contents remain out of scope. EnforceCheckoutBoundary bool `json:"enforce_checkout_boundary,omitempty"` } // FileSizeSettings selects the maximum committed-file size and literal exclusions. type FileSizeSettings struct { LimitBytes *int64 `json:"limit_bytes,omitempty"` Exclusions []string `json:"exclusions,omitempty"` } // ModuleDistributionSettings selects committed module-source distribution checks. type ModuleDistributionSettings struct { // WorkspaceFiles rejects committed go.work and go.work.sum blobs. // Omitted or false disables the check. WorkspaceFiles bool `json:"workspace_files,omitempty"` // LocalReplacements rejects filesystem replacements in committed go.mod files. // Omitted or false disables the check. LocalReplacements bool `json:"local_replacements,omitempty"` } // GoValidationSettings selects checks performed by the Go toolchain. type GoValidationSettings struct { Race bool `json:"race,omitempty"` FullVet bool `json:"full_vet,omitempty"` CompileTargets []string `json:"compile_targets,omitempty"` } // DeliverySettings selects the hosted Gitea release destination. type DeliverySettings struct { BaseURL string `json:"base_url"` Owner string `json:"owner"` Repository string `json:"repository"` TokenEnv string `json:"token_env,omitempty"` } // BuildSettings selects the Go application package, artifact name, and targets. type BuildSettings struct { MainPackage string `json:"main_package,omitempty"` BinaryName string `json:"binary_name,omitempty"` Targets []string `json:"targets,omitempty"` } // LoadSettings reads a profile path relative to repoDir unless it is absolute. // An empty configPath selects /releasekit.json. func LoadSettings(repoDir, configPath string) (Settings, error) { resolvedRepo, err := filepath.Abs(repoDir) if err != nil { return Settings{}, fmt.Errorf("resolve repository directory %q: %w", repoDir, err) } if configPath == "" { configPath = "releasekit.json" } if !filepath.IsAbs(configPath) { configPath = filepath.Join(resolvedRepo, configPath) } file, err := os.Open(configPath) if err != nil { return Settings{}, fmt.Errorf("open profile %q: %w", configPath, err) } defer file.Close() var settings Settings decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() if err := decoder.Decode(&settings); err != nil { return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { if err == nil { return Settings{}, fmt.Errorf("decode profile %q: multiple JSON values", configPath) } return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) } if strings.TrimSpace(settings.Product) == "" { return Settings{}, fmt.Errorf("profile %q: product is required", configPath) } if settings.Remote == "" { settings.Remote = "origin" } for i, check := range settings.Checks { if len(check.Args) == 0 || check.Args[0] == "" { return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i) } } if settings.Build != nil { if len(settings.Build.Targets) != 0 { if strings.TrimSpace(settings.Build.MainPackage) == "" { return Settings{}, fmt.Errorf("profile %q: build.main_package is required when targets are selected", configPath) } if strings.TrimSpace(settings.Build.BinaryName) == "" { return Settings{}, fmt.Errorf("profile %q: build.binary_name is required when targets are selected", configPath) } if strings.ContainsAny(settings.Build.BinaryName, "/\\\r\n") { return Settings{}, fmt.Errorf("profile %q: build.binary_name must be a filename component", configPath) } } for i, target := range settings.Build.Targets { if strings.Count(target, "/") != 1 { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i) } goos, goarch, _ := strings.Cut(target, "/") if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i) } if strings.ContainsAny(goos+goarch, "\\\r\n") { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] contains an unsafe filename component", configPath, i) } } } if settings.Validation != nil { if fileSize := settings.Validation.FileSize; fileSize != nil && fileSize.LimitBytes != nil && *fileSize.LimitBytes < 0 { return Settings{}, fmt.Errorf("profile %q: validation.file_size.limit_bytes must not be negative", configPath) } for i, target := range settings.Validation.Go.CompileTargets { if strings.Count(target, "/") != 1 { return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) } goos, goarch, _ := strings.Cut(target, "/") if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" { return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) } } } return settings, nil } // RunChecks runs configured argument vectors in dir. Each command receives // its own environment overlay and inherits the remaining process environment. func RunChecks(ctx context.Context, dir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error { for i, check := range checks { environment := make(map[string]string, len(check.Env)+len(ownedEnv)) for key, value := range check.Env { environment[key] = value } for key, value := range ownedEnv { environment[key] = value } if err := process.Run(ctx, process.Invocation{ Args: check.Args, Dir: dir, Env: environment, Stdout: stdout, Stderr: stderr, }); err != nil { return fmt.Errorf("run check %d: %w", i+1, err) } } return nil }