Add profile loading and process execution
This commit is contained in:
117
cmd/releasekit/internal/cli/config.go
Normal file
117
cmd/releasekit/internal/cli/config.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package cli owns command parsing, profile settings, and child-command
|
||||
// adaptation for the ReleaseKit executable.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
)
|
||||
|
||||
const Usage = "usage: releasekit version"
|
||||
|
||||
type Command string
|
||||
|
||||
const VersionCommand Command = "version"
|
||||
|
||||
// Invocation contains parsed command settings owned by the CLI adapter.
|
||||
type Invocation struct {
|
||||
Command Command
|
||||
}
|
||||
|
||||
// Parse accepts only commands that have been implemented by the executable.
|
||||
func Parse(args []string) (Invocation, error) {
|
||||
if len(args) == 1 && args[0] == string(VersionCommand) {
|
||||
return Invocation{Command: VersionCommand}, nil
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return Invocation{}, errors.New("a command is required")
|
||||
}
|
||||
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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, stdout, stderr io.Writer) error {
|
||||
for i, check := range checks {
|
||||
if err := process.Run(ctx, process.Invocation{
|
||||
Args: check.Args,
|
||||
Dir: repoDir,
|
||||
Env: check.Env,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("run check %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
172
cmd/releasekit/internal/cli/config_test.go
Normal file
172
cmd/releasekit/internal/cli/config_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
invocation, err := Parse([]string{"version"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invocation.Command != VersionCommand {
|
||||
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
|
||||
}
|
||||
if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") {
|
||||
t.Fatalf("Parse(nil) error = %v, want missing-command error", err)
|
||||
}
|
||||
if _, err := Parse([]string{"publish"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
|
||||
t.Fatalf("Parse(publish) error = %v, want unknown-command error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSettingsResolvesPathsAndPreservesArguments(t *testing.T) {
|
||||
repoDir := filepath.Join(t.TempDir(), "repository with spaces")
|
||||
profileDir := filepath.Join(repoDir, "profiles with spaces")
|
||||
if err := os.MkdirAll(profileDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Settings{
|
||||
Product: "Example Product",
|
||||
Remote: "upstream",
|
||||
Checks: []CheckCommand{{
|
||||
Args: []string{"sh", "scripts/check release.sh", "two words"},
|
||||
Env: map[string]string{"RELEASE_MODE": "candidate check"},
|
||||
}},
|
||||
}
|
||||
contents, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(profileDir, "release profile.json"), contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := LoadSettings(repoDir, filepath.Join("profiles with spaces", "release profile.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Product != want.Product || got.Remote != want.Remote || len(got.Checks) != 1 {
|
||||
t.Fatalf("LoadSettings() = %#v, want product, remote, and one check", got)
|
||||
}
|
||||
if got.Checks[0].Args[1] != "scripts/check release.sh" || got.Checks[0].Args[2] != "two words" {
|
||||
t.Fatalf("check arguments were changed: %#v", got.Checks[0].Args)
|
||||
}
|
||||
if got.Checks[0].Env["RELEASE_MODE"] != "candidate check" {
|
||||
t.Fatalf("check environment = %#v", got.Checks[0].Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
contents := `{"product":"Example"}`
|
||||
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||
if err := os.WriteFile(profilePath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "default path"},
|
||||
{name: "absolute path", path: profilePath},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
settings, err := LoadSettings(repoDir, test.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.Remote != "origin" {
|
||||
t.Fatalf("default remote = %q, want origin", settings.Remote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleProfileLoads(t *testing.T) {
|
||||
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.Product != "Example" || settings.Remote != "origin" || len(settings.Checks) != 1 {
|
||||
t.Fatalf("example settings = %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{name: "missing product", content: `{}`, want: "product is required"},
|
||||
{name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"},
|
||||
{name: "unsupported build field", content: `{"product":"Example","build":{}}`, want: "unknown field"},
|
||||
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
|
||||
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},
|
||||
{name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repoDir := t.TempDir()
|
||||
path := filepath.Join(repoDir, "profile.json")
|
||||
if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadSettings(repoDir, path); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("LoadSettings() error = %v, want text %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChecksUsesConfiguredArgumentsEnvironmentAndDirectory(t *testing.T) {
|
||||
workdir := filepath.Join(t.TempDir(), "repo with spaces")
|
||||
if err := os.Mkdir(workdir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := RunChecks(context.Background(), workdir, []CheckCommand{{
|
||||
Args: []string{helperExecutable(t), "-test.run=TestConfiguredCommandHelper", "--", "argument with spaces"},
|
||||
Env: map[string]string{"RELEASEKIT_CONFIGURED_HELPER": "1", "RELEASEKIT_CONFIGURED_VALUE": "selected value"},
|
||||
}}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := fmt.Sprintf("%s|argument with spaces|selected value", workdir)
|
||||
if stdout.String() != want {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredCommandHelper(t *testing.T) {
|
||||
if os.Getenv("RELEASEKIT_CONFIGURED_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
workdir, err := os.Getwd()
|
||||
if err != nil {
|
||||
os.Exit(9)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s|%s|%s", workdir, os.Args[len(os.Args)-1], os.Getenv("RELEASEKIT_CONFIGURED_VALUE"))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func helperExecutable(t *testing.T) string {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return executable
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -13,11 +14,16 @@ func main() {
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 1 && args[0] == "version" {
|
||||
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
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderr, "usage: releasekit version")
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestUnknownCommand(t *testing.T) {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unknown command wrote to stdout: %q", stdout.String())
|
||||
}
|
||||
if want := "usage: releasekit version\n"; stderr.String() != want {
|
||||
if want := "unknown command \"publish\"\nusage: releasekit version\n"; stderr.String() != want {
|
||||
t.Fatalf("usage output = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user