Add profile loading and process execution
This commit is contained in:
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
27
docs/config.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
12
examples/releasekit.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"product": "Example",
|
||||
"remote": "origin",
|
||||
"checks": [
|
||||
{
|
||||
"args": ["go", "test", "-count=1", "./..."],
|
||||
"env": {
|
||||
"RELEASE_MODE": "candidate"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
60
internal/process/process.go
Normal file
60
internal/process/process.go
Normal 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
|
||||
}
|
||||
145
internal/process/process_test.go
Normal file
145
internal/process/process_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user