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. // 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, // It uses the available compatible local Go toolchain, applies ReleaseKit's
// and verifies the version command on the native host before succeeding. An // readonly offline build settings, preserves effective cache and tool settings,
// empty target set records source identity without running the Go toolchain. // and verifies the version command on the Go host before succeeding. An empty
// Errors return partial build facts in the result. // 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) { func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
result, err := internalbuild.Build(ctx, internalbuild.Request{ result, err := internalbuild.Build(ctx, internalbuild.Request{
RepositoryDir: request.RepositoryDir, 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) { func TestBuildWithNoTargetsDoesNotInvokeCompiler(t *testing.T) {
repository := newBuildConsumer(t, `package main repository := newBuildConsumer(t, `package main
func main() {} func main() {}

View File

@@ -34,7 +34,8 @@ type ReleaseRequest struct {
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // 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 // If Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks branch, HEAD, worktree, index, // storage outside the worktree, then rechecks branch, HEAD, worktree, index,
// and local tag state. Success establishes local candidate evidence only; it // 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 directory are left in place. A failed later build or native version check returns the binaries already
generated in `BuildResult.Artifacts`. generated in `BuildResult.Artifacts`.
Builds use the prepared local Go toolchain, readonly module mode, disabled Builds use the available compatible Go toolchain selected locally; ReleaseKit
module network access, `CGO_ENABLED=0`, `-trimpath`, and `-buildvcs=false`. Each does not download a different toolchain automatically. It disables workspace
selected target is compiled explicitly. ReleaseKit then runs the `version` discovery and module network access, uses readonly module mode, sets
command on a native binary and requires `<binary> <version>` followed by a `CGO_ENABLED=0`, and passes `-trimpath` and `-buildvcs=false`. Effective cache,
newline. If no selected target matches the Go host, it builds a temporary module-cache, temporary-directory, and tool settings are retained, including
native binary for this check and removes it afterward. Cross-compilation does values from Go's persistent environment file. Ambient experiment and CPU
not promise that a binary runs on another platform. 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, 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 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. that version may already exist.
The callback receives the captured repository, commit, and version. ReleaseKit The callback receives the captured repository, commit, and version. ReleaseKit
calls it once. When omitted, ReleaseKit runs the offline Go test command. The calls it once. When omitted, ReleaseKit runs
check revalidates the branch, commit, and cleanliness after validation. `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 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. 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/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/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/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/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/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `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` | | 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` | | 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` | | 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` | | 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` | | 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 # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 partial-build behavior. Fixtures use temporary Go configuration and never edit
the developer's persistent settings. 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 ## Stage 3 — Make product checks additive
**Covers:** F4 and ReleaseKit's own F1 exposure. **Primary owners:** `check.go`, **Covers:** F4 and ReleaseKit's own F1 exposure. **Primary owners:** `check.go`,

View File

@@ -11,10 +11,10 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "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) 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 { if err != nil {
return result, fmt.Errorf("identify Go compiler: %w", err) return result, fmt.Errorf("identify Go compiler: %w", err)
} }
result.Compiler = strings.TrimSpace(string(compiler)) result.Compiler = strings.TrimSpace(string(compiler))
host, err := hostTarget(ctx, repositoryDir) goHost := goTool.HostTarget()
if err != nil { host := Target{OS: goHost.OS, Arch: goHost.Arch}
return result, fmt.Errorf("identify Go host: %w", err)
}
for index, target := range result.Targets { for index, target := range result.Targets {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -134,7 +136,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
if request.Output != nil { if request.Output != nil {
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename) 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) return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
} }
artifact := Artifact{Path: path, Filename: filename, Target: target} 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 { if request.Output != nil {
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch) 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) 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 return strings.TrimSpace(string(output)), nil
} }
func hostTarget(ctx context.Context, repositoryDir string) (Target, error) { func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target 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 {
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version 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) "build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
return err 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 { func hashArtifact(artifact *Artifact) error {
file, err := os.Open(artifact.Path) file, err := os.Open(artifact.Path)
if err != nil { if err != nil {
@@ -322,7 +261,6 @@ func hashArtifact(artifact *Artifact) error {
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error { func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
command := exec.CommandContext(ctx, binaryPath, "version") command := exec.CommandContext(ctx, binaryPath, "version")
command.Env = goEnvironment(nil)
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {

View File

@@ -10,8 +10,8 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "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 { func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
environment := map[string]string{ tool, err := gotool.New(ctx, repositoryDir)
"GOWORK": "off", if err != nil {
"GOPROXY": "off", return fmt.Errorf("prepare Go toolchain: %w", err)
"GONOPROXY": "none",
"GOSUMDB": "off",
"GOTOOLCHAIN": "local",
} }
var writer io.Writer = output _, err = tool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, output,
if output != nil { "test", "-mod=readonly", "-count=1", "./...")
writer = &synchronizedWriter{writer: output} return err
}
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)
} }
func validStableVersion(version string) bool { 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) { func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) {
for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} { for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} {
t.Run(version, func(t *testing.T) { 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"
"os/exec" "os/exec"
"sort" "sort"
"strings"
) )
// Invocation describes one child process. Environment values overlay the // Invocation describes one child process. UnsetEnv removes inherited values
// current process environment for this child only. // before Env overlays values for this child only.
type Invocation struct { type Invocation struct {
Args []string Args []string
Dir string Dir string
Env map[string]string Env map[string]string
Stdout io.Writer UnsetEnv []string
Stderr io.Writer Stdout io.Writer
Stderr io.Writer
} }
// Run starts the command in the requested directory and waits for it to exit. // 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 := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir command.Dir = invocation.Dir
if len(invocation.Env) != 0 { if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = append(os.Environ(), environmentEntries(invocation.Env)...) command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
} }
command.Stdout = invocation.Stdout command.Stdout = invocation.Stdout
command.Stderr = invocation.Stderr command.Stderr = invocation.Stderr
@@ -45,7 +47,21 @@ func Run(ctx context.Context, invocation Invocation) error {
return nil 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)) keys := make([]string, 0, len(environment))
for key := range environment { for key := range environment {
keys = append(keys, key) 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) { func TestRunReportsMissingExecutable(t *testing.T) {
err := Run(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}}) 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") { 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 return executable
} }
func mustGetwd(t *testing.T) string {
t.Helper()
workdir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return workdir
}