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

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

View File

@@ -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() {}

View File

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

View File

@@ -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 `<binary> <version>` 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 `<binary> <version>`
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

View File

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

42
docs/internal/gotool.md Normal file
View File

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

View File

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

View File

@@ -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`,

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
}