320 lines
12 KiB
Go
320 lines
12 KiB
Go
// 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")
|
|
}
|
|
}
|
|
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 <repoDir>/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
|
|
}
|