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

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