236 lines
8.1 KiB
Go
236 lines
8.1 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"
|
|
)
|
|
|
|
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] 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] --commit OID VERSION"
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
expectedCommit := ""
|
|
if action == CIDeliveryAction {
|
|
remote = flags.String("remote", "origin", "Git remote")
|
|
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit")
|
|
}
|
|
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")
|
|
}
|
|
parsedRemote := ""
|
|
if remote != nil {
|
|
parsedRemote = *remote
|
|
}
|
|
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0)}, 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, and check commands read from a
|
|
// releasekit.json profile.
|
|
type Settings struct {
|
|
Product string `json:"product"`
|
|
Remote string `json:"remote,omitempty"`
|
|
Checks []CheckCommand `json:"checks,omitempty"`
|
|
Build *BuildSettings `json:"build,omitempty"`
|
|
Delivery *DeliverySettings `json:"delivery,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)
|
|
}
|
|
}
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
// RunChecks runs configured argument vectors in repoDir. Each command receives
|
|
// its own environment overlay and inherits the remaining process environment.
|
|
func RunChecks(ctx context.Context, repoDir 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: repoDir,
|
|
Env: environment,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
}); err != nil {
|
|
return fmt.Errorf("run check %d: %w", i+1, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|