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.
|
// 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,
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
3
check.go
3
check.go
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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/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` |
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
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,14 +9,16 @@ 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
|
||||||
|
UnsetEnv []string
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user