Centralize Go validation and build environments

This commit is contained in:
2026-10-06 16:38:15 +00:00
parent 12e866a94a
commit 08093124af
15 changed files with 637 additions and 129 deletions

View File

@@ -11,10 +11,10 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
@@ -115,15 +115,17 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
}
compiler, err := runGo(ctx, repositoryDir, nil, "version")
goTool, err := gotool.New(ctx, repositoryDir)
if err != nil {
return result, fmt.Errorf("prepare Go toolchain: %w", err)
}
compiler, err := goTool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
if err != nil {
return result, fmt.Errorf("identify Go compiler: %w", err)
}
result.Compiler = strings.TrimSpace(string(compiler))
host, err := hostTarget(ctx, repositoryDir)
if err != nil {
return result, fmt.Errorf("identify Go host: %w", err)
}
goHost := goTool.HostTarget()
host := Target{OS: goHost.OS, Arch: goHost.Arch}
for index, target := range result.Targets {
if err := ctx.Err(); err != nil {
@@ -134,7 +136,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
if request.Output != nil {
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
}
if err := buildTarget(ctx, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
}
artifact := Artifact{Path: path, Filename: filename, Target: target}
@@ -164,7 +166,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
if request.Output != nil {
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch)
}
if err := buildTarget(ctx, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
return result, fmt.Errorf("build native version check: %w", err)
}
}
@@ -236,76 +238,13 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
return strings.TrimSpace(string(output)), nil
}
func hostTarget(ctx context.Context, repositoryDir string) (Target, error) {
output, err := runGo(ctx, repositoryDir, nil, "env", "GOHOSTOS", "GOHOSTARCH")
if err != nil {
return Target{}, err
}
parts := strings.Fields(string(output))
if len(parts) != 2 {
return Target{}, fmt.Errorf("unexpected Go host response %q", output)
}
return Target{OS: parts[0], Arch: parts[1]}, nil
}
func buildTarget(ctx context.Context, repositoryDir, mainPackage, outputPath, version string, target Target) error {
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
_, err := runGo(ctx, repositoryDir, map[string]string{"GOOS": target.OS, "GOARCH": target.Arch},
_, err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
return err
}
func runGo(ctx context.Context, directory string, target map[string]string, args ...string) ([]byte, error) {
commandArgs := append([]string(nil), args...)
command := exec.CommandContext(ctx, "go", commandArgs...)
command.Dir = directory
command.Env = goEnvironment(target)
output, err := command.CombinedOutput()
if err == nil {
return output, nil
}
if ctx.Err() != nil {
err = fmt.Errorf("%w: %v", ctx.Err(), err)
}
if len(output) != 0 {
return output, fmt.Errorf("go %s: %w: %s", strings.Join(commandArgs, " "), err, strings.TrimSpace(string(output)))
}
return output, fmt.Errorf("go %s: %w", strings.Join(commandArgs, " "), err)
}
func goEnvironment(target map[string]string) []string {
controlled := map[string]bool{
"GOWORK": true, "GOPROXY": true, "GONOPROXY": true, "GOSUMDB": true,
"GOTOOLCHAIN": true, "GOFLAGS": true, "CGO_ENABLED": true, "GOOS": true, "GOARCH": true,
}
for key := range target {
controlled[key] = true
}
environment := make([]string, 0, len(os.Environ())+len(controlled))
for _, entry := range os.Environ() {
key, _, ok := strings.Cut(entry, "=")
if !ok || !controlled[key] {
environment = append(environment, entry)
}
}
values := map[string]string{
"GOWORK": "off", "GOPROXY": "off", "GONOPROXY": "none", "GOSUMDB": "off",
"GOTOOLCHAIN": "local", "GOFLAGS": "-mod=readonly", "CGO_ENABLED": "0",
}
for key, value := range target {
values[key] = value
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
environment = append(environment, key+"="+values[key])
}
return environment
}
func hashArtifact(artifact *Artifact) error {
file, err := os.Open(artifact.Path)
if err != nil {
@@ -322,7 +261,6 @@ func hashArtifact(artifact *Artifact) error {
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
command := exec.CommandContext(ctx, binaryPath, "version")
command.Env = goEnvironment(nil)
output, err := command.CombinedOutput()
if err != nil {
if ctx.Err() != nil {

View File

@@ -10,8 +10,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
@@ -216,35 +216,13 @@ func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, erro
}
func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
environment := map[string]string{
"GOWORK": "off",
"GOPROXY": "off",
"GONOPROXY": "none",
"GOSUMDB": "off",
"GOTOOLCHAIN": "local",
tool, err := gotool.New(ctx, repositoryDir)
if err != nil {
return fmt.Errorf("prepare Go toolchain: %w", err)
}
var writer io.Writer = output
if output != nil {
writer = &synchronizedWriter{writer: output}
}
return process.Run(ctx, process.Invocation{
Args: []string{"go", "test", "-mod=readonly", "-count=1", "./..."},
Dir: repositoryDir,
Env: environment,
Stdout: writer,
Stderr: writer,
})
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (w *synchronizedWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.writer.Write(p)
_, err = tool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, output,
"test", "-mod=readonly", "-count=1", "./...")
return err
}
func validStableVersion(version string) bool {

View File

@@ -90,6 +90,60 @@ func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
}
}
func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n")
gitRun(t, repository, "add", "failure_test.go")
gitRun(t, repository, "commit", "-m", "add failing validation fixture")
for _, source := range []string{"process environment", "Go environment file"} {
t.Run(source, func(t *testing.T) {
unsetCandidateEnv(t, "GOFLAGS", "GOOS", "GOARCH")
goEnv := filepath.Join(t.TempDir(), "go-env")
if source == "Go environment file" {
if err := os.WriteFile(goEnv, []byte("GOFLAGS=-run=^$\nGOOS=windows\nGOARCH=arm64\n"), 0o600); err != nil {
t.Fatal(err)
}
} else {
if err := os.WriteFile(goEnv, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("GOFLAGS", "-run=^$")
t.Setenv("GOOS", "windows")
t.Setenv("GOARCH", "arm64")
}
t.Setenv("GOENV", goEnv)
var output bytes.Buffer
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Output: &output,
})
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") {
t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String())
}
})
}
}
func unsetCandidateEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
value, exists := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if exists {
_ = os.Setenv(key, value)
} else {
_ = os.Unsetenv(key)
}
})
}
}
func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) {
for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} {
t.Run(version, func(t *testing.T) {

203
internal/gotool/gotool.go Normal file
View File

@@ -0,0 +1,203 @@
// Package gotool owns Go configuration discovery and ReleaseKit's Go command
// environments.
package gotool
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
// Mode selects the target environment for a Go command.
type Mode uint8
const (
// Native runs Go for the toolchain host and keeps its effective cgo settings.
Native Mode = iota
// Static runs Go for the selected target with cgo disabled.
Static
)
// Target selects a Go operating system and architecture.
type Target struct {
OS string
Arch string
}
// Tool holds the host target and effective operational settings resolved from
// the process environment and Go's persistent environment file.
type Tool struct {
host Target
settings map[string]string
}
var operationalSettings = []string{
"CC", "CXX", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_CXXFLAGS", "CGO_ENABLED", "CGO_LDFLAGS",
"GOCACHE", "GOCACHEPROG", "GOMODCACHE", "GOPATH", "GOROOT", "GOTMPDIR", "PKG_CONFIG",
}
var architectureFeatures = []string{
"GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM",
}
// New discovers the Go toolchain host and its effective cache, tool, and cgo
// settings. Discovery uses the selected local Go command and neutralizes
// ambient flags, experiments, and architecture tuning so they cannot prevent
// configuration from being read.
func New(ctx context.Context, directory string) (*Tool, error) {
var host struct {
OS string `json:"GOHOSTOS"`
Arch string `json:"GOHOSTARCH"`
}
output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH")
if err != nil {
return nil, fmt.Errorf("identify Go host: %w", err)
}
if err := json.Unmarshal(output, &host); err != nil {
return nil, fmt.Errorf("decode Go host settings: %w", err)
}
if host.OS == "" || host.Arch == "" {
return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output)))
}
tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}}
environment := discoveryEnvironment()
environment["GOOS"] = tool.host.OS
environment["GOARCH"] = tool.host.Arch
args := append([]string{"go", "env", "-json"}, operationalSettings...)
output, err = discover(ctx, directory, environment, args...)
if err != nil {
return nil, fmt.Errorf("resolve effective Go settings: %w", err)
}
var settings map[string]string
if err := json.Unmarshal(output, &settings); err != nil {
return nil, fmt.Errorf("decode effective Go settings: %w", err)
}
for _, key := range operationalSettings {
if _, ok := settings[key]; !ok {
return nil, fmt.Errorf("Go did not report effective setting %s", key)
}
}
tool.settings = settings
return tool, nil
}
// HostTarget reports the Go command's host operating system and architecture.
func (tool *Tool) HostTarget() Target {
return tool.host
}
// Run executes a Go command with ReleaseKit's shared offline baseline. Native
// commands use the host target and effective cgo configuration. Static commands
// use target and disable cgo. Output, when supplied, receives stdout and stderr
// as the command runs; the returned bytes retain both streams for diagnostics.
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
if len(args) == 0 {
return nil, errors.New("Go command requires arguments")
}
environment := tool.environment(mode, target)
captured := &combinedWriter{output: output}
commandArgs := append([]string{"go"}, args...)
err := process.Run(ctx, process.Invocation{
Args: commandArgs,
Dir: directory,
Env: environment,
Stdout: captured,
Stderr: captured,
})
contents := captured.Bytes()
if err != nil {
if len(contents) != 0 {
return contents, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
}
return contents, fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
}
return contents, nil
}
func (tool *Tool) environment(mode Mode, target Target) map[string]string {
if mode == Native || target.OS == "" || target.Arch == "" {
target = tool.host
}
values := make(map[string]string, len(tool.settings)+len(architectureFeatures)+12)
for key, value := range tool.settings {
values[key] = value
}
values["GOENV"] = "off"
values["GOFLAGS"] = "-mod=readonly"
values["GOWORK"] = "off"
values["GOTOOLCHAIN"] = "local"
values["GOPROXY"] = "off"
values["GONOPROXY"] = "none"
values["GOSUMDB"] = "off"
values["GOOS"] = target.OS
values["GOARCH"] = target.Arch
values["GOEXPERIMENT"] = ""
for _, key := range architectureFeatures {
values[key] = ""
}
if mode == Static {
values["CGO_ENABLED"] = "0"
}
return values
}
func discoveryEnvironment() map[string]string {
environment := map[string]string{
"GOFLAGS": "-mod=readonly",
"GOTOOLCHAIN": "local",
"GOWORK": "off",
"GOEXPERIMENT": "none",
}
for _, key := range architectureFeatures {
environment[key] = ""
}
return environment
}
func discover(ctx context.Context, directory string, environment map[string]string, args ...string) ([]byte, error) {
captured := &combinedWriter{}
err := process.Run(ctx, process.Invocation{
Args: args,
Dir: directory,
Env: environment,
Stdout: captured,
Stderr: captured,
})
contents := captured.Bytes()
if err != nil {
if len(contents) != 0 {
return contents, fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
}
return contents, fmt.Errorf("%s: %w", strings.Join(args, " "), err)
}
return contents, nil
}
type combinedWriter struct {
mu sync.Mutex
output io.Writer
buffer bytes.Buffer
}
func (writer *combinedWriter) Write(contents []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
if writer.output == nil {
return writer.buffer.Write(contents)
}
return io.MultiWriter(&writer.buffer, writer.output).Write(contents)
}
func (writer *combinedWriter) Bytes() []byte {
writer.mu.Lock()
defer writer.mu.Unlock()
return append([]byte(nil), writer.buffer.Bytes()...)
}

View File

@@ -0,0 +1,138 @@
package gotool
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestNativeAndStaticCommandsUseOwnedBaseline(t *testing.T) {
clearGoSettings(t)
t.Setenv("GOENV", "off")
t.Setenv("GOFLAGS", "-run=^$")
t.Setenv("GOOS", "windows")
t.Setenv("GOARCH", "arm64")
t.Setenv("GOAMD64", "v2")
t.Setenv("GOEXPERIMENT", "loopvar")
tool, err := New(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH)
}
native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED")
if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH {
t.Fatalf("native environment = %#v", native)
}
if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" {
t.Fatalf("native Go settings = %#v", native)
}
if native["CGO_ENABLED"] == "" {
t.Fatalf("native cgo setting was not resolved: %#v", native)
}
static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT")
if static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" {
t.Fatalf("static environment = %#v", static)
}
if static["GOFLAGS"] != "-mod=readonly" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" {
t.Fatalf("static baseline settings = %#v", static)
}
}
func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
clearGoSettings(t)
cache := filepath.Join(t.TempDir(), "configured-cache")
moduleCache := filepath.Join(t.TempDir(), "configured-module-cache")
temporary := filepath.Join(t.TempDir(), "configured-temporary")
if err := os.Mkdir(temporary, 0o700); err != nil {
t.Fatal(err)
}
goEnv := filepath.Join(t.TempDir(), "go-env")
contents := strings.Join([]string{
"GOFLAGS=-run=^$",
"GOOS=windows",
"GOARCH=arm64",
"GOAMD64=v2",
"GOEXPERIMENT=loopvar",
"GOCACHE=" + cache,
"GOMODCACHE=" + moduleCache,
"GOTMPDIR=" + temporary,
"CGO_ENABLED=1",
"CC=releasekit-test-cc",
"CXX=releasekit-test-cxx",
"CGO_CFLAGS=-O0 -g",
"PKG_CONFIG=releasekit-test-pkg-config",
}, "\n") + "\n"
if err := os.WriteFile(goEnv, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("GOENV", goEnv)
tool, err := New(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH)
}
native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "GOCACHE", "GOMODCACHE", "GOTMPDIR", "PKG_CONFIG")
if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH {
t.Fatalf("native environment = %#v", native)
}
if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" {
t.Fatalf("persisted overrides were not cleared: %#v", native)
}
if native["CGO_ENABLED"] != "1" || native["CC"] != "releasekit-test-cc" || native["CXX"] != "releasekit-test-cxx" || native["CGO_CFLAGS"] != "-O0 -g" || native["PKG_CONFIG"] != "releasekit-test-pkg-config" {
t.Fatalf("persistent native C settings = %#v", native)
}
if native["GOCACHE"] != cache || native["GOMODCACHE"] != moduleCache || native["GOTMPDIR"] != temporary {
t.Fatalf("persistent cache settings = %#v", native)
}
static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT", "GOCACHE")
if static["GOENV"] != "" || static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" || static["GOCACHE"] != cache {
t.Fatalf("persistent static environment = %#v", static)
}
}
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
t.Helper()
args := append([]string{"env", "-json"}, names...)
output, err := tool.Run(context.Background(), t.TempDir(), mode, target, nil, args...)
if err != nil {
t.Fatal(err)
}
var settings map[string]string
if err := json.Unmarshal(output, &settings); err != nil {
t.Fatalf("decode Go environment %q: %v", output, err)
}
return settings
}
func clearGoSettings(t *testing.T) {
t.Helper()
for _, key := range []string{
"GOENV", "GOFLAGS", "GOWORK", "GOTOOLCHAIN", "GOPROXY", "GONOPROXY", "GOSUMDB",
"GOOS", "GOARCH", "GOEXPERIMENT", "GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM",
"CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_CXXFLAGS", "CGO_LDFLAGS", "GOCACHE", "GOCACHEPROG", "GOMODCACHE", "GOPATH", "GOROOT", "GOTMPDIR", "PKG_CONFIG",
} {
value, exists := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if exists {
_ = os.Setenv(key, value)
} else {
_ = os.Unsetenv(key)
}
})
}
}

View File

@@ -9,16 +9,18 @@ import (
"os"
"os/exec"
"sort"
"strings"
)
// Invocation describes one child process. Environment values overlay the
// current process environment for this child only.
// Invocation describes one child process. UnsetEnv removes inherited values
// before Env overlays values for this child only.
type Invocation struct {
Args []string
Dir string
Env map[string]string
Stdout io.Writer
Stderr io.Writer
Args []string
Dir string
Env map[string]string
UnsetEnv []string
Stdout io.Writer
Stderr io.Writer
}
// Run starts the command in the requested directory and waits for it to exit.
@@ -30,8 +32,8 @@ func Run(ctx context.Context, invocation Invocation) error {
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)...)
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}
command.Stdout = invocation.Stdout
command.Stderr = invocation.Stderr
@@ -45,7 +47,21 @@ func Run(ctx context.Context, invocation Invocation) error {
return nil
}
func environmentEntries(environment map[string]string) []string {
func environmentEntries(overlay map[string]string, unset []string) []string {
environment := make(map[string]string, len(os.Environ())+len(overlay))
for _, entry := range os.Environ() {
key, value, ok := strings.Cut(entry, "=")
if ok {
environment[key] = value
}
}
for _, key := range unset {
delete(environment, key)
}
for key, value := range overlay {
environment[key] = value
}
keys := make([]string, 0, len(environment))
for key := range environment {
keys = append(keys, key)

View File

@@ -58,6 +58,24 @@ func TestRunPreservesExitError(t *testing.T) {
}
}
func TestRunCanRemoveInheritedEnvironment(t *testing.T) {
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stdout bytes.Buffer
err := Run(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument", "success"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
UnsetEnv: []string{"RELEASEKIT_PROCESS_INHERITED"},
Stdout: &stdout,
})
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s|argument||", mustGetwd(t))
if stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
}
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") {
@@ -143,3 +161,12 @@ func helperExecutable(t *testing.T) string {
}
return executable
}
func mustGetwd(t *testing.T) string {
t.Helper()
workdir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return workdir
}