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
|
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
||||||
policy reading.
|
policy reading.
|
||||||
- [CLI contract](docs/cli.md): the current `version` command and its output.
|
- [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
|
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
|
||||||
read their embedded version through `buildinfo`.
|
read their embedded version through `buildinfo`.
|
||||||
- [Internal package inventory](docs/internal/overview.md): implemented internal
|
- [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"
|
"os"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -13,11 +14,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(args []string, stdout, stderr io.Writer) int {
|
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())
|
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(stderr, "usage: releasekit version")
|
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestUnknownCommand(t *testing.T) {
|
|||||||
if stdout.Len() != 0 {
|
if stdout.Len() != 0 {
|
||||||
t.Fatalf("unknown command wrote to stdout: %q", stdout.String())
|
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)
|
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.
|
It prints `releasekit <version>` followed by a newline and exits with status 0.
|
||||||
The version comes from [`buildinfo.Version`](consumers/runtime-version.md).
|
The version comes from [`buildinfo.Version`](consumers/runtime-version.md).
|
||||||
|
|
||||||
Any other argument sequence prints `usage: releasekit version` to standard
|
Any other argument sequence prints an error message followed by
|
||||||
error and exits with status 2.
|
`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.
|
release builder.
|
||||||
- `internal/apicheck` checks compiled exports with `go/types` and checks import
|
- `internal/apicheck` checks compiled exports with `go/types` and checks import
|
||||||
direction. Run it with `go run ./internal/apicheck` from the repository root.
|
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,
|
The executable controller uses the runtime facade and the command-owned CLI
|
||||||
process execution, build, Git, and Gitea implementation packages are introduced
|
adapter. The adapter uses the process runner and imports neither public facade.
|
||||||
as their responsibilities are implemented.
|
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
|
## 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
|
1. Implement JSON product, remote, and command settings under an internal CLI
|
||||||
adapter. Add strict field decoding, directory resolution, required-value
|
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
|
missing executables, invalid configuration, and cancellation report useful
|
||||||
errors. Tests use controlled local child commands and temporary directories.
|
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
|
## Stage 5: Capture and validate one local candidate
|
||||||
|
|
||||||
Status: Pending. Depends on Stage 4.
|
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
|
var violations []string
|
||||||
|
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
if !strings.HasPrefix(pkg.ImportPath, modulePath+"/internal/") {
|
if !isInternalPackage(pkg.ImportPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, imported := range pkg.Imports {
|
for _, imported := range pkg.Imports {
|
||||||
@@ -149,6 +149,11 @@ func checkImportDirections(packages []listedPackage) error {
|
|||||||
return errors.New(strings.Join(violations, "\n"))
|
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 {
|
func forbiddenRuntimeDependency(path string) bool {
|
||||||
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
for _, part := range strings.Split(strings.ToLower(path), "/") {
|
||||||
switch part {
|
switch part {
|
||||||
|
|||||||
@@ -110,10 +110,14 @@ func TestActualRepositoryBoundaries(t *testing.T) {
|
|||||||
func TestInternalPackagesCannotImportPublicFacades(t *testing.T) {
|
func TestInternalPackagesCannotImportPublicFacades(t *testing.T) {
|
||||||
packages := []listedPackage{
|
packages := []listedPackage{
|
||||||
{ImportPath: modulePath + "/internal/operations", Imports: []string{modulePath}},
|
{ImportPath: modulePath + "/internal/operations", Imports: []string{modulePath}},
|
||||||
|
{ImportPath: modulePath + "/cmd/releasekit/internal/cli", Imports: []string{modulePath + "/buildinfo"}},
|
||||||
{ImportPath: modulePath},
|
{ImportPath: modulePath},
|
||||||
|
{ImportPath: modulePath + "/buildinfo"},
|
||||||
}
|
}
|
||||||
err := checkImportDirections(packages)
|
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)
|
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