From 08093124afd82e3b1d2eec311ed564dcfe235868 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 16:38:15 +0000 Subject: [PATCH] Centralize Go validation and build environments --- build.go | 9 +- build_test.go | 89 ++++++++++++++ check.go | 3 +- docs/consumers/build.md | 19 +-- docs/consumers/check.md | 10 +- docs/internal/gotool.md | 42 +++++++ docs/internal/overview.md | 4 +- docs/roadmap/implementation.md | 10 +- internal/build/build.go | 86 ++----------- internal/candidate/check.go | 36 ++---- internal/candidate/check_test.go | 54 ++++++++ internal/gotool/gotool.go | 203 +++++++++++++++++++++++++++++++ internal/gotool/gotool_test.go | 138 +++++++++++++++++++++ internal/process/process.go | 36 ++++-- internal/process/process_test.go | 27 ++++ 15 files changed, 637 insertions(+), 129 deletions(-) create mode 100644 docs/internal/gotool.md create mode 100644 internal/gotool/gotool.go create mode 100644 internal/gotool/gotool_test.go diff --git a/build.go b/build.go index b3c0127..58192df 100644 --- a/build.go +++ b/build.go @@ -54,10 +54,11 @@ type BuildResult struct { } // Build compiles the selected targets with Version injected through buildinfo. -// It uses the prepared local Go environment, regenerates selected output files while preserving unrelated files, -// and verifies the version command on the native host before succeeding. An -// empty target set records source identity without running the Go toolchain. -// Errors return partial build facts in the result. +// It uses the available compatible local Go toolchain, applies ReleaseKit's +// readonly offline build settings, preserves effective cache and tool settings, +// and verifies the version command on the Go host before succeeding. An empty +// target set records source identity without running the Go toolchain. Errors +// return partial build facts in the result. func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { result, err := internalbuild.Build(ctx, internalbuild.Request{ RepositoryDir: request.RepositoryDir, diff --git a/build_test.go b/build_test.go index 78a46c4..0d228ea 100644 --- a/build_test.go +++ b/build_test.go @@ -131,6 +131,95 @@ func main() { } } +func TestBuildIgnoresAmbientAndPersistentArchitectureTuning(t *testing.T) { + repository := newBuildConsumer(t, `package main +import ( + "fmt" + "os" + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) +func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } +`) + + for _, source := range []string{"process environment", "Go environment file"} { + t.Run(source, func(t *testing.T) { + unsetBuildEnv(t, "GOAMD64") + unsetBuildEnv(t, "GOEXPERIMENT") + unsetBuildEnv(t, "GOFLAGS") + cache := filepath.Join(t.TempDir(), "configured-cache") + goEnv := filepath.Join(t.TempDir(), "go-env") + if source == "Go environment file" { + contents := fmt.Sprintf("GOAMD64=v2\nGOEXPERIMENT=loopvar\nGOFLAGS=-run=^$\nGOCACHE=%s\n", cache) + if err := os.WriteFile(goEnv, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } else { + if err := os.WriteFile(goEnv, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("GOAMD64", "v2") + t.Setenv("GOEXPERIMENT", "loopvar") + t.Setenv("GOFLAGS", "-run=^$") + } + t.Setenv("GOENV", goEnv) + + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v2.3.4", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}, + }, + OutputDir: filepath.Join(t.TempDir(), "artifacts"), + }) + if err != nil { + t.Fatalf("Build() error = %v; partial result = %#v", err, result) + } + metadata := goBuildMetadata(t, result.Artifacts[0].Path) + if !strings.Contains(metadata, "GOAMD64=v1") || strings.Contains(metadata, "GOAMD64=v2") || strings.Contains(metadata, "GOEXPERIMENT=loopvar") { + t.Fatalf("build metadata includes ambient tuning:\n%s", metadata) + } + if source == "Go environment file" { + entries, err := os.ReadDir(cache) + if err != nil || len(entries) == 0 { + t.Fatalf("configured persistent cache was not used: entries=%d error=%v", len(entries), err) + } + } + }) + } +} + +func goBuildMetadata(t *testing.T, binary string) string { + t.Helper() + t.Setenv("GOENV", "off") + t.Setenv("GOFLAGS", "") + t.Setenv("GOEXPERIMENT", "") + t.Setenv("GOAMD64", "") + t.Setenv("GOOS", "") + t.Setenv("GOARCH", "") + output, err := exec.Command("go", "version", "-m", binary).CombinedOutput() + if err != nil { + t.Fatalf("go version -m: %v\n%s", err, output) + } + return string(output) +} + +func unsetBuildEnv(t *testing.T, key string) { + t.Helper() + 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 TestBuildWithNoTargetsDoesNotInvokeCompiler(t *testing.T) { repository := newBuildConsumer(t, `package main func main() {} diff --git a/check.go b/check.go index a7d45aa..7ff7d85 100644 --- a/check.go +++ b/check.go @@ -34,7 +34,8 @@ type ReleaseRequest struct { // Check validates one local, committed release candidate. It requires a clean // worktree and index on main, a committed note matching Product and Version, -// and an unused local tag. If Validate is nil, the offline Go test suite runs. +// and an unused local tag. If Validate is nil, the offline Go test suite runs +// with the available compatible local Go toolchain and the Go host target. // If Build selects targets, Check builds them after validation in temporary // storage outside the worktree, then rechecks branch, HEAD, worktree, index, // and local tag state. Success establishes local candidate evidence only; it diff --git a/docs/consumers/build.md b/docs/consumers/build.md index b07984d..509d4f0 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -50,13 +50,18 @@ resolved from the repository. Selected artifact filenames are regenerated. Other directory are left in place. A failed later build or native version check returns the binaries already generated in `BuildResult.Artifacts`. -Builds use the prepared local Go toolchain, readonly module mode, disabled -module network access, `CGO_ENABLED=0`, `-trimpath`, and `-buildvcs=false`. Each -selected target is compiled explicitly. ReleaseKit then runs the `version` -command on a native binary and requires ` ` followed by a -newline. If no selected target matches the Go host, it builds a temporary -native binary for this check and removes it afterward. Cross-compilation does -not promise that a binary runs on another platform. +Builds use the available compatible Go toolchain selected locally; ReleaseKit +does not download a different toolchain automatically. It disables workspace +discovery and module network access, uses readonly module mode, sets +`CGO_ENABLED=0`, and passes `-trimpath` and `-buildvcs=false`. Effective cache, +module-cache, temporary-directory, and tool settings are retained, including +values from Go's persistent environment file. Ambient experiment and CPU +feature settings are cleared so the selected toolchain uses its baseline for +each target. Each selected target is compiled explicitly. ReleaseKit then runs +the `version` command on a native binary and requires ` ` +followed by a newline. If no selected target matches the Go host, it builds a +temporary native binary for this check and removes it afterward. +Cross-compilation does not promise that a binary runs on another platform. An empty target list records the source commit and returns without invoking Go, creating the output directory, or running a version check. It does not require a diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 4d25eb1..e4ec132 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -25,8 +25,14 @@ product name and exact version as complete title elements. No local tag with that version may already exist. The callback receives the captured repository, commit, and version. ReleaseKit -calls it once. When omitted, ReleaseKit runs the offline Go test command. The -check revalidates the branch, commit, and cleanliness after validation. +calls it once. When omitted, ReleaseKit runs +`go test -mod=readonly -count=1 ./...` with the available compatible local Go +toolchain; it does not download a different toolchain automatically. The suite +uses the Go host target, disables workspace and module network access, and +ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective +cache and native cgo/C-tool settings are preserved, including values from Go's +persistent environment file. The check revalidates the branch, commit, and +cleanliness after validation. A successful check is evidence about the local candidate only. It does not check whether a remote branch or tag is available and does not create a tag. diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md new file mode 100644 index 0000000..a3b4624 --- /dev/null +++ b/docs/internal/gotool.md @@ -0,0 +1,42 @@ +# Go command environments + +`internal/gotool` resolves effective Go settings once for a validation or build +operation and applies the shared environment to its Go commands. It uses +`internal/process` for cancellation, the working directory, output, and wrapped +errors. + +## Configuration discovery + +The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then +queries effective operational settings with that host selected. Both queries +use `GOTOOLCHAIN=local` and controlled `GOFLAGS`. Discovery sets +`GOEXPERIMENT=none` and clears architecture feature variables so an invalid or +nonbaseline tuning value cannot prevent settings from being read. The +experiment override is discovery-only. + +The persistent Go environment remains active during discovery. The owner copies +the effective cache and module-cache locations, Go root, temporary directory, +and native cgo/C-tool configuration into its settings. This captures values +supplied by either the process environment or the persistent Go environment +file. + +## Command environments + +Every Go command receives `GOENV=off`, so removed persistent values cannot +return. ReleaseKit owns `GOFLAGS=-mod=readonly`, `GOWORK=off`, +`GOTOOLCHAIN=local`, `GOPROXY=off`, `GONOPROXY=none`, and `GOSUMDB=off`. +Native commands use `GOHOSTOS` and `GOHOSTARCH` and retain the effective +`CGO_ENABLED`, `CC`, `CXX`, `PKG_CONFIG`, and `CGO_CFLAGS`, `CGO_CPPFLAGS`, +`CGO_CXXFLAGS`, and `CGO_LDFLAGS`. Static commands use the selected target and +`CGO_ENABLED=0`. + +Both modes clear `GOEXPERIMENT` and the Go architecture feature variables +`GOAMD64`, `GOARM`, `GOARM64`, `GO386`, `GOMIPS`, `GOMIPS64`, `GOPPC64`, +`GORISCV64`, and `GOWASM`. The selected Go toolchain supplies its ordinary +baseline values. Effective `GOCACHE`, `GOCACHEPROG`, `GOMODCACHE`, `GOPATH`, +`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain +available to child commands. + +Candidate validation uses native mode for the offline `go test` suite. Builds +use static mode for selected-target compilation and the native version-check +build. The native version executable itself runs as a normal child process. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 5479e65..5dbafec 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -39,8 +39,9 @@ these implemented packages: | `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | | `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations, including captured-endpoint verification during recovery. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | +| `internal/gotool` | Effective Go configuration discovery and shared native-validation and static-build environments; see [Go command environments](gotool.md). | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | -| `internal/process` | Cancellable child-process execution with per-command environment overlays. | +| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -62,6 +63,7 @@ not a separate release matrix: | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | Candidate capture, committed note, one validation, and rechecks | `releasekit.Check`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsValidationOnce`, `TestCheckRechecksHeadAndWorktreeAfterCallback`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate` | +| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7834438..6649f04 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: Stage 1 complete; Stage 2 not started. +Status: Stages 1 and 2 complete; Stage 3 not started. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -160,6 +160,14 @@ survive. Preserve existing version injection, empty-target, cancellation, and partial-build behavior. Fixtures use temporary Go configuration and never edit the developer's persistent settings. +**Completed:** Shared Go configuration now drives default validation, +toolchain/host queries, target builds, and the native smoke build. Failing +validation fixtures prove process and persistent `GOFLAGS` cannot suppress a +real test and that cross-target settings still run tests on the host. Build +metadata confirms ambient and persistent tuning are cleared, and a temporary +persistent cache is used. Focused tests, the full offline suite, API boundary +checks, and documentation whitespace checks pass. + ## Stage 3 — Make product checks additive **Covers:** F4 and ReleaseKit's own F1 exposure. **Primary owners:** `check.go`, diff --git a/internal/build/build.go b/internal/build/build.go index d7d95fc..fc0d1da 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 { diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 70b4843..c90c890 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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 { diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index ca62679..752126c 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -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) { diff --git a/internal/gotool/gotool.go b/internal/gotool/gotool.go new file mode 100644 index 0000000..134f9cd --- /dev/null +++ b/internal/gotool/gotool.go @@ -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()...) +} diff --git a/internal/gotool/gotool_test.go b/internal/gotool/gotool_test.go new file mode 100644 index 0000000..21404e9 --- /dev/null +++ b/internal/gotool/gotool_test.go @@ -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) + } + }) + } +} diff --git a/internal/process/process.go b/internal/process/process.go index 2f12bd3..8e53072 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -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) diff --git a/internal/process/process_test.go b/internal/process/process_test.go index a9cdb4a..4b23077 100644 --- a/internal/process/process_test.go +++ b/internal/process/process_test.go @@ -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 +}