Add profile loading and process execution

This commit is contained in:
2026-10-05 17:25:11 +00:00
parent 22a3dfe9bc
commit be225cf634
14 changed files with 575 additions and 12 deletions

View File

@@ -27,6 +27,7 @@ go run ./cmd/releasekit version
- [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading.
- [CLI contract](docs/cli.md): the current `version` command and its output.
- [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
read their embedded version through `buildinfo`.
- [Internal package inventory](docs/internal/overview.md): implemented internal

View 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
}

View 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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -9,5 +9,5 @@ go run ./cmd/releasekit version
It prints `releasekit <version>` followed by a newline and exits with status 0.
The version comes from [`buildinfo.Version`](consumers/runtime-version.md).
Any other argument sequence prints `usage: releasekit version` to standard
error and exits with status 2.
Any other argument sequence prints an error message followed by
`usage: releasekit version` to standard error and exits with status 2.

27
docs/config.md Normal file
View File

@@ -0,0 +1,27 @@
# Configuration
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default.
The repository directory defaults to the current directory. A relative profile
path is resolved from that repository directory; an absolute path is used as-is.
The current profile fields are:
| Field | Required | Behavior |
| --- | --- | --- |
| `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. |
| `checks` | No | Ordered child commands, each with an argument vector and optional environment map. |
Each check requires a nonempty `args` array whose first element names the
executable. Arguments are passed directly to the child process; ReleaseKit does
not parse a shell command string. Select a shell explicitly when a check needs
shell syntax. Each `env` map overlays the current environment for that child.
The overlay does not modify the ReleaseKit process environment.
Unknown fields are rejected so a setting cannot be silently ignored. Build and
delivery fields will be added when those operations are implemented. Profile
loading and child-command adaptation are implemented; no CLI operation consumes
the profile yet.
See the [profile example](../examples/releasekit.json). The
[CLI contract](cli.md) lists the currently available command.

View File

@@ -12,7 +12,12 @@ Implemented internal packages:
release builder.
- `internal/apicheck` checks compiled exports with `go/types` and checks import
direction. Run it with `go run ./internal/apicheck` from the repository root.
- `internal/process` runs cancellable child commands with per-child environment
overlays.
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, and adapts configured checks to the process runner.
The executable currently imports only the runtime facade. Release orchestration,
process execution, build, Git, and Gitea implementation packages are introduced
as their responsibilities are implemented.
The executable controller uses the runtime facade and the command-owned CLI
adapter. The adapter uses the process runner and imports neither public facade.
Release orchestration, build, Git, and Gitea implementation packages are
introduced as their responsibilities are implemented.

View File

@@ -510,7 +510,7 @@ checker command and internal package inventory to the canonical developer docs.
## Stage 4: Implement profile loading and command adaptation
Status: Pending. Depends on Stage 3.
Status: Complete. Depends on Stage 3.
1. Implement JSON product, remote, and command settings under an internal CLI
adapter. Add strict field decoding, directory resolution, required-value
@@ -527,6 +527,15 @@ selected environment values reach the child without changing process globals;
missing executables, invalid configuration, and cancellation report useful
errors. Tests use controlled local child commands and temporary directories.
Completion evidence: added strict profile decoding, repository-relative and
absolute profile resolution, required product and command diagnostics, and
direct check argument adaptation. Added a cancellable `os/exec` runner with
working directory, inherited environment plus child-only overlays, stdout,
stderr, and preserved exit errors. Tests cover spaced paths and arguments,
environment isolation, missing executables, exit failures, cancellation, and
invalid profiles. Added a profile example and canonical CLI, configuration, and
internal package documentation.
## Stage 5: Capture and validate one local candidate
Status: Pending. Depends on Stage 4.

12
examples/releasekit.json Normal file
View File

@@ -0,0 +1,12 @@
{
"product": "Example",
"remote": "origin",
"checks": [
{
"args": ["go", "test", "-count=1", "./..."],
"env": {
"RELEASE_MODE": "candidate"
}
}
]
}

View File

@@ -108,7 +108,7 @@ func checkImportDirections(packages []listedPackage) error {
var violations []string
for _, pkg := range packages {
if !strings.HasPrefix(pkg.ImportPath, modulePath+"/internal/") {
if !isInternalPackage(pkg.ImportPath) {
continue
}
for _, imported := range pkg.Imports {
@@ -149,6 +149,11 @@ func checkImportDirections(packages []listedPackage) error {
return errors.New(strings.Join(violations, "\n"))
}
func isInternalPackage(path string) bool {
return strings.HasPrefix(path, modulePath+"/internal/") ||
strings.HasPrefix(path, modulePath+"/") && strings.Contains(strings.TrimPrefix(path, modulePath), "/internal/")
}
func forbiddenRuntimeDependency(path string) bool {
for _, part := range strings.Split(strings.ToLower(path), "/") {
switch part {

View File

@@ -110,10 +110,14 @@ func TestActualRepositoryBoundaries(t *testing.T) {
func TestInternalPackagesCannotImportPublicFacades(t *testing.T) {
packages := []listedPackage{
{ImportPath: modulePath + "/internal/operations", Imports: []string{modulePath}},
{ImportPath: modulePath + "/cmd/releasekit/internal/cli", Imports: []string{modulePath + "/buildinfo"}},
{ImportPath: modulePath},
{ImportPath: modulePath + "/buildinfo"},
}
err := checkImportDirections(packages)
if err == nil || !strings.Contains(err.Error(), "internal package "+modulePath+"/internal/operations imports public or command package "+modulePath) {
if err == nil ||
!strings.Contains(err.Error(), "internal package "+modulePath+"/internal/operations imports public or command package "+modulePath) ||
!strings.Contains(err.Error(), "internal package "+modulePath+"/cmd/releasekit/internal/cli imports public or command package "+modulePath+"/buildinfo") {
t.Fatalf("checkImportDirections() error = %v, want internal-to-facade violation", err)
}
}

View File

@@ -0,0 +1,60 @@
// Package process runs child commands for ReleaseKit's internal adapters.
package process
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sort"
)
// Invocation describes one child process. Environment values overlay the
// current process environment for this child only.
type Invocation struct {
Args []string
Dir string
Env map[string]string
Stdout io.Writer
Stderr io.Writer
}
// Run starts the command in the requested directory and waits for it to exit.
// Errors preserve the underlying exec error, including *exec.ExitError.
func Run(ctx context.Context, invocation Invocation) error {
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
return errors.New("process invocation requires a command")
}
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
if len(invocation.Env) != 0 {
command.Env = append(os.Environ(), environmentEntries(invocation.Env)...)
}
command.Stdout = invocation.Stdout
command.Stderr = invocation.Stderr
if err := command.Run(); err != nil {
if contextErr := ctx.Err(); contextErr != nil {
return fmt.Errorf("run %q: %w", invocation.Args[0], contextErr)
}
return fmt.Errorf("run %q: %w", invocation.Args[0], err)
}
return nil
}
func environmentEntries(environment map[string]string) []string {
keys := make([]string, 0, len(environment))
for key := range environment {
keys = append(keys, key)
}
sort.Strings(keys)
entries := make([]string, 0, len(keys))
for _, key := range keys {
entries = append(entries, key+"="+environment[key])
}
return entries
}

View File

@@ -0,0 +1,145 @@
package process
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRunPassesArgumentsDirectoryAndEnvironment(t *testing.T) {
workdir := filepath.Join(t.TempDir(), "directory with spaces")
if err := os.Mkdir(workdir, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stdout, stderr bytes.Buffer
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "success"},
Dir: workdir,
Env: map[string]string{
"RELEASEKIT_PROCESS_HELPER": "1",
"RELEASEKIT_PROCESS_OVERLAY": "child",
},
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s|argument with spaces|child|inherited", workdir)
if stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
if stderr.String() != "child stderr\n" {
t.Fatalf("stderr = %q, want %q", stderr.String(), "child stderr\n")
}
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
}
}
func TestRunPreservesExitError(t *testing.T) {
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
t.Fatalf("Run() error = %v, want exit code 7", err)
}
}
func TestRunReportsMissingExecutable(t *testing.T) {
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
if err == nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
t.Fatalf("Run() error = %v, want missing executable diagnostic", err)
}
}
func TestRunReturnsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{}, 1)
invocation := Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
Stdout: writerFunc(func([]byte) (int, error) {
select {
case started <- struct{}{}:
default:
}
return len("started\n"), nil
}),
}
result := make(chan error, 1)
go func() { result <- Run(ctx, invocation) }()
select {
case <-started:
cancel()
case <-time.After(5 * time.Second):
t.Fatal("child did not start")
}
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run() did not return after cancellation")
}
}
func TestProcessHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" {
return
}
args := os.Args
if len(args) < 2 {
os.Exit(8)
}
mode := args[len(args)-1]
switch mode {
case "success":
workdir, err := os.Getwd()
if err != nil {
os.Exit(9)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s", workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
fmt.Fprintln(os.Stderr, "child stderr")
os.Exit(0)
case "exit":
os.Exit(7)
case "wait":
fmt.Fprintln(os.Stdout, "started")
for {
time.Sleep(time.Second)
}
default:
os.Exit(10)
}
}
type writerFunc func([]byte) (int, error)
func (write writerFunc) Write(p []byte) (int, error) {
return write(p)
}
func helperExecutable(t *testing.T) string {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return executable
}