Centralize Go validation and build environments
This commit is contained in:
9
build.go
9
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,
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
3
check.go
3
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
42
docs/internal/gotool.md
Normal 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.
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
203
internal/gotool/gotool.go
Normal 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()...)
|
||||
}
|
||||
138
internal/gotool/gotool_test.go
Normal file
138
internal/gotool/gotool_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user