diff --git a/build_test.go b/build_test.go index d0c11e2..203242d 100644 --- a/build_test.go +++ b/build_test.go @@ -432,6 +432,21 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) { func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) { repository := newPublishFixture(t) + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + goShimDir := t.TempDir() + goLog := filepath.Join(goShimDir, "go-commands") + goShim := filepath.Join(goShimDir, "go") + shimSource := "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$RELEASEKIT_REAL_GO_LOG\"\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n" + if err := os.WriteFile(goShim, []byte(shimSource), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("RELEASEKIT_REAL_GO", realGo) + t.Setenv("RELEASEKIT_REAL_GO_LOG", goLog) + t.Setenv("GOENV", "off") + t.Setenv("PATH", goShimDir+string(os.PathListSeparator)+os.Getenv("PATH")) writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n") gitRun(t, repository.work, "add", "guide.md") gitRun(t, repository.work, "commit", "-m", "add Markdown guide") @@ -465,6 +480,26 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") { t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations) } + goCommands, err := os.ReadFile(goLog) + if err != nil { + t.Fatal(err) + } + var discoveries, suites, builds, versions int + for _, command := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") { + switch { + case strings.HasPrefix(command, "env "): + discoveries++ + case strings.HasPrefix(command, "test "): + suites++ + case strings.HasPrefix(command, "build "): + builds++ + case command == "version": + versions++ + } + } + if discoveries != 2 || suites != 1 || builds != 1 || versions != 1 { + t.Fatalf("Go commands = %q; discovery=%d suite=%d build=%d version=%d, want 2, 1, 1, 1", goCommands, discoveries, suites, builds, versions) + } if got := gitText(t, repository.work, "status", "--porcelain"); got != "" { t.Fatalf("candidate build dirtied worktree: %s", got) } diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 5e5a6f7..284c0a5 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -27,8 +27,9 @@ version must be stable `vMAJOR.MINOR.PATCH`. The committed product name and exact version as complete title elements. No local tag with that version may already exist. -ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the -available compatible local Go toolchain. Set `Validation.Go.Race` to run that +ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite from the +selected module directory with the available compatible local Go toolchain. +Set `Validation.Go.Race` to run that suite with `-race`; set `Validation.Go.FullVet` to run `go vet -mod=readonly ./...` after successful tests, with test-time vet disabled for the suite. Set `Validation.Go.CompileTargets` to compile packages for each diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index ce47a33..645a4ee 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -1,7 +1,10 @@ # 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/gotool` resolves effective Go settings when an operation first needs +a Go command and applies the shared environment to its commands. Candidate +validation and its build callback share an operation-local owner while the +inherited environment and persistent Go settings file remain unchanged. +Standalone and delivery builds create their own owners. The package uses `internal/process` for cancellation, the working directory, output, and wrapped errors. @@ -35,9 +38,10 @@ uses the same ownership transfer when formatting a failure. ## 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 +The owner asks the installed `go` command for `GOHOSTOS`, `GOHOSTARCH`, and the +`GOENV` settings-file path, 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. @@ -48,6 +52,11 @@ and native cgo/C-tool configuration into its settings. This captures values supplied by either the process environment or the persistent Go environment file. +The operation-local owner snapshots the inherited environment and selected +settings file. It reuses the resolved tool across validation and its following +build callback while those inputs match, and rediscovers after a consumer +callback changes either input. It does not cache settings across operations. + Configuration discovery parses JSON from stdout. Stderr is captured separately and included in command-failure diagnostics, so a successful query can emit warnings while supplying valid configuration. @@ -69,9 +78,10 @@ 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 and -selected full `go vet` pass. Package-compilation checks use static mode for each -selected target and direct Go's output to the operating system null device, so -they leave no binary artifact. Artifact builds also use static mode for -selected-target compilation, then build the native version-check executable; -that executable runs as a normal child process. +Candidate validation runs the offline `go test` suite and selected full +`go vet` pass from the selected module directory using native mode. +Package-compilation checks use static mode for each selected target and direct +Go's output to the operating system null device, so they leave no binary +artifact. Artifact builds also compile selected targets in static mode, then +build the native version-check executable, which runs as a normal child +process. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 7b96772..138b0d8 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -42,7 +42,7 @@ reports these implemented packages: | `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. | | `internal/git` | Git command boundary, committed-tree reads, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, 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, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). | +| `internal/gotool` | Operation-local Go configuration discovery and reuse, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | @@ -61,10 +61,11 @@ also recorded in [ADR 0001](../adr/0001-public-facade.md) and the Candidate checking captures and validates one clean committed source revision in `internal/candidate`. Its validation bridge calls `internal/validation` for -the default offline Go suite, then runs product-specific checks once. The -candidate package owns post-validation builds and local-state rechecks; -publication owns endpoint guards, tag creation, push, and recovery around that -same candidate validation path. `internal/validation.CandidateTree` reads +the selected module's default offline Go suite, then runs product-specific +checks once. Validation and its post-validation build callback share one local +Go settings owner; the candidate package owns those builds and local-state +rechecks; publication owns endpoint guards, tag creation, push, and recovery +around that same candidate validation path. `internal/validation.CandidateTree` reads committed blobs lazily and resolves local destinations from committed entries; it does not consult neighboring files or traverse submodule checkouts. See the [shared validation guide](validation.md) for checker, Go command, and @@ -81,7 +82,7 @@ not a separate release matrix: | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | | Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` | | Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` | -| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | +| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` | | Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | diff --git a/docs/internal/validation.md b/docs/internal/validation.md index fe05c50..4bb3ff0 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -8,15 +8,16 @@ post-validation build callback and local-state recheck. `internal/publish` adds its endpoint guards, tag creation, push, and recovery around that same candidate path. -The executor runs one offline Go suite, optionally with the race detector when -selected. When full vet is selected, it disables test-time vet for that suite -and runs native `go vet -mod=readonly ./...` after the suite succeeds. It then -runs `go build -mod=readonly -o ./...` in static mode for each -selected package-compilation target, preserving target order and stopping on -the first failure. This compiles source without producing an artifact or -executing a target binary. Test, vet, and package-compilation failures retain -their `go-test`, `go-vet`, and `package-compile` identities, selected target -where applicable, and underlying causes. Its +The executor runs one offline Go suite from the captured module directory, +optionally with the race detector when selected. When full vet is selected, it +disables test-time vet for that suite and runs native +`go vet -mod=readonly ./...` from the same directory after the suite succeeds. +It then runs `go build -mod=readonly -o ./...` there in static mode +for each selected package-compilation target, preserving target order and +stopping on the first failure. This compiles source without producing an +artifact or executing a target binary. Test, vet, and package-compilation +failures retain their `go-test`, `go-vet`, and `package-compile` identities, +selected target where applicable, and underlying causes. Its request carries the captured repository directory and commit, output writer, and internal settings. When selected, the Go-format check loads the candidate tree once, reads regular Go blobs from the captured commit, and compares them diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index becdb96..707bc7e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -371,6 +371,18 @@ suite and reused discovery in an unchanged validation/build operation; changing environment inputs through an additional callback triggers fresh settings. Run Go-tool, validation, and candidate coordination tests. +**Completed:** Go validation commands now run from the captured module +directory. Candidate checks and publication share a lazy operation-local Go +settings owner with their later build callbacks; standalone and managed delivery +builds acquire separate owners. The owner reuses discovery while the inherited +environment and selected `GOENV` file stay unchanged, and refreshes after +consumer callbacks when either changes. Nested-module fixtures now include +packages for selected-module validation. Verified one suite and shared discovery +through candidate build coordination, environment and persistent-file refresh, +nested-suite failure and sibling isolation, selected vet/compile command +directories and compile baseline, `go test ./...`, and +`go run ./internal/apicheck`. + ## Stage 7: Build the selected module and bind output evidence **Outcome:** Standalone and composite builds use the correct declaration source, diff --git a/internal/build/build.go b/internal/build/build.go index 6808c77..dbf7e8a 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -34,6 +34,7 @@ type Request struct { Spec Spec OutputDir string Output io.Writer + ToolOwner *gotool.Owner } type Artifact struct { @@ -118,7 +119,11 @@ func Build(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) } - goTool, err := gotool.New(ctx, repositoryDir) + owner := request.ToolOwner + if owner == nil { + owner = gotool.NewOwner(repositoryDir) + } + goTool, err := owner.Acquire(ctx) if err != nil { return result, fmt.Errorf("prepare Go toolchain: %w", err) } diff --git a/internal/build/candidate.go b/internal/build/candidate.go index 16c6461..4468851 100644 --- a/internal/build/candidate.go +++ b/internal/build/candidate.go @@ -7,16 +7,17 @@ import ( "os" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" ) -func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error { +func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error { if spec == nil || len(spec.Targets) == 0 { return nil } selected := *spec selected.Targets = append([]Target(nil), spec.Targets...) - return func(ctx context.Context, captured candidate.Candidate) error { + return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error { temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-") if err != nil { return fmt.Errorf("create temporary candidate build output: %w", err) @@ -28,6 +29,7 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi Spec: selected, OutputDir: temporaryDir, Output: output, + ToolOwner: owner, }) if err != nil { return fmt.Errorf("build candidate: %w", err) diff --git a/internal/candidate/check.go b/internal/candidate/check.go index b7d09de..175636d 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -12,6 +12,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" @@ -37,8 +38,9 @@ type Request struct { Version string Validation validation.Settings AdditionalChecks func(context.Context, Candidate) error - AfterValidate func(context.Context, Candidate) error + AfterValidate func(context.Context, Candidate, *gotool.Owner) error Output io.Writer + ToolOwner *gotool.Owner } func Check(ctx context.Context, request Request) (Candidate, error) { @@ -46,12 +48,18 @@ func Check(ctx context.Context, request Request) (Candidate, error) { if err != nil { return captured, err } + request.ToolOwner = gotool.NewOwner(captured.ModuleDir) if err := Validate(ctx, request, captured); err != nil { return captured, err } if request.AfterValidate != nil { - if err := request.AfterValidate(ctx, captured); err != nil { - return captured, fmt.Errorf("after candidate validation: %w", err) + callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner) + refreshErr := request.ToolOwner.Refresh(ctx) + if callbackErr != nil { + return captured, fmt.Errorf("after candidate validation: %w", callbackErr) + } + if refreshErr != nil { + return captured, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr) } } if err := Recheck(ctx, captured, ""); err != nil { @@ -120,18 +128,28 @@ func Capture(ctx context.Context, request Request) (Candidate, error) { // Validate runs the selected offline Go suite, followed by AdditionalChecks // when supplied, for the captured candidate. func Validate(ctx context.Context, request Request, captured Candidate) error { + owner := request.ToolOwner + if owner == nil { + owner = gotool.NewOwner(captured.ModuleDir) + } if err := validation.Run(ctx, validation.Request{ RepositoryDir: captured.RepositoryDir, ModuleDir: captured.ModuleDir, Commit: captured.Commit, Output: request.Output, Settings: request.Validation, + ToolOwner: owner, }); err != nil { return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) } if request.AdditionalChecks != nil { - if err := request.AdditionalChecks(ctx, captured); err != nil { - return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) + callbackErr := request.AdditionalChecks(ctx, captured) + refreshErr := owner.Refresh(ctx) + if callbackErr != nil { + return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr) + } + if refreshErr != nil { + return fmt.Errorf("refresh Go settings after additional checks: %w", refreshErr) } } return nil diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index 2067b97..bdeec32 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -3,13 +3,17 @@ package candidate import ( "bytes" "context" + "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) @@ -159,7 +163,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) productCalls++ return nil }, - AfterValidate: func(context.Context, Candidate) error { + AfterValidate: func(context.Context, Candidate, *gotool.Owner) error { buildCalls++ return nil }, @@ -191,7 +195,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *te productCalls++ return nil }, - AfterValidate: func(context.Context, Candidate) error { + AfterValidate: func(context.Context, Candidate, *gotool.Owner) error { buildCalls++ return nil }, @@ -265,6 +269,125 @@ func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) { } } +func TestCheckRefreshesGoSettingsAfterAdditionalChecks(t *testing.T) { + repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") + t.Setenv("GOENV", "off") + t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "initial-module-cache")) + selectedCache := filepath.Join(t.TempDir(), "callback-module-cache") + var observed string + _, err := Check(context.Background(), Request{ + RepositoryDir: repository, + Product: "Example Product", + Version: testVersion, + AdditionalChecks: func(context.Context, Candidate) error { + t.Setenv("GOMODCACHE", selectedCache) + return nil + }, + AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error { + tool, err := owner.Acquire(ctx) + if err != nil { + return err + } + output, err := tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "env", "GOMODCACHE") + if err != nil { + return err + } + observed = strings.TrimSpace(string(output)) + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if observed != selectedCache { + t.Fatalf("Go settings after additional checks = %q, want %q", observed, selectedCache) + } +} + +func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the controlled Go command fixture uses a POSIX shell") + } + repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") + logPath := installCandidateGoShim(t) + t.Setenv("GOENV", "off") + _, err := Check(context.Background(), Request{ + RepositoryDir: repository, + Product: "Example Product", + Version: testVersion, + AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error { + tool, err := owner.Acquire(ctx) + if err != nil { + return err + } + _, err = tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "version") + return err + }, + }) + if err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + commands := strings.Split(strings.TrimSpace(string(contents)), "\n") + var discoveries, suites, callbacks int + for _, command := range commands { + switch { + case strings.HasPrefix(command, "env "): + discoveries++ + case strings.HasPrefix(command, "test "): + suites++ + case command == "version": + callbacks++ + } + } + if discoveries != 2 || suites != 1 || callbacks != 1 { + t.Fatalf("Go invocations = %q; discovery=%d suite=%d callback=%d, want 2, 1, 1", commands, discoveries, suites, callbacks) + } +} + +func installCandidateGoShim(t *testing.T) string { + t.Helper() + directory := t.TempDir() + logPath := filepath.Join(directory, "go-commands") + settings := map[string]string{ + "GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, "GOENV": "off", + "CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "", "CGO_CXXFLAGS": "", + "CGO_ENABLED": "1", "CGO_LDFLAGS": "", "GOCACHE": "", "GOCACHEPROG": "", + "GOMODCACHE": "", "GOPATH": "", "GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "", + } + encoded, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + script := fmt.Sprintf(`#!/bin/sh +set -eu +printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" +if [ "${1-}" = "env" ]; then + printf '%%s\n' '%s' + exit 0 +fi +if [ "${1-}" = "version" ]; then + echo 'go version fixture' + exit 0 +fi +exit 0 +`, encoded) + shimPath := filepath.Join(directory, "go") + if err := os.WriteFile(shimPath, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + path := directory + if current := os.Getenv("PATH"); current != "" { + path += string(os.PathListSeparator) + current + } + t.Setenv("PATH", path) + t.Setenv("RELEASEKIT_GO_LOG", logPath) + return logPath +} + 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") diff --git a/internal/gotool/gotool.go b/internal/gotool/gotool.go index a9073e9..e9db27a 100644 --- a/internal/gotool/gotool.go +++ b/internal/gotool/gotool.go @@ -34,8 +34,9 @@ type Target struct { // 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 + host Target + settings map[string]string + settingsFile string } var operationalSettings = []string{ @@ -53,10 +54,11 @@ var architectureFeatures = []string{ // configuration from being read. func New(ctx context.Context, directory string) (*Tool, error) { var host struct { - OS string `json:"GOHOSTOS"` - Arch string `json:"GOHOSTARCH"` + OS string `json:"GOHOSTOS"` + Arch string `json:"GOHOSTARCH"` + SettingsFile string `json:"GOENV"` } - output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH") + output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH", "GOENV") if err != nil { return nil, fmt.Errorf("identify Go host: %w", err) } @@ -66,7 +68,7 @@ func New(ctx context.Context, directory string) (*Tool, error) { 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}} + tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}, settingsFile: host.SettingsFile} environment := discoveryEnvironment() environment["GOOS"] = tool.host.OS diff --git a/internal/gotool/gotool_test.go b/internal/gotool/gotool_test.go index 8f91f53..616eb93 100644 --- a/internal/gotool/gotool_test.go +++ b/internal/gotool/gotool_test.go @@ -301,6 +301,72 @@ func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) { } } +func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) { + clearGoSettings(t) + t.Setenv("GOENV", "off") + owner := NewOwner(t.TempDir()) + + first, err := owner.Acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + second, err := owner.Acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("unchanged Go inputs caused settings discovery to be repeated") + } + + cache := filepath.Join(t.TempDir(), "changed-cache") + t.Setenv("GOCACHE", cache) + if err := owner.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + third, err := owner.Acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + if third == first || third.settings["GOCACHE"] != cache { + t.Fatalf("changed inherited environment kept stale settings: tool=%p GOCACHE=%q", third, third.settings["GOCACHE"]) + } +} + +func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) { + clearGoSettings(t) + firstCache := filepath.Join(t.TempDir(), "first-cache") + secondCache := filepath.Join(t.TempDir(), "second-cache") + settingsFile := filepath.Join(t.TempDir(), "go-env") + writeSettings := func(cache string) { + t.Helper() + if err := os.WriteFile(settingsFile, []byte("GOCACHE="+cache+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + writeSettings(firstCache) + t.Setenv("GOENV", settingsFile) + owner := NewOwner(t.TempDir()) + + first, err := owner.Acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + if first.settings["GOCACHE"] != firstCache { + t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache) + } + writeSettings(secondCache) + if err := owner.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + second, err := owner.Acquire(context.Background()) + if err != nil { + t.Fatal(err) + } + if second == first || second.settings["GOCACHE"] != secondCache { + t.Fatalf("changed persistent settings were not rediscovered: tool=%p GOCACHE=%q", second, second.settings["GOCACHE"]) + } +} + func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string { t.Helper() args := append([]string{"env", "-json"}, names...) diff --git a/internal/gotool/owner.go b/internal/gotool/owner.go new file mode 100644 index 0000000..380d688 --- /dev/null +++ b/internal/gotool/owner.go @@ -0,0 +1,136 @@ +package gotool + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Owner lazily discovers and reuses one Go tool while its inherited +// environment and persistent settings file remain unchanged. +type Owner struct { + directory string + tool *Tool + env map[string]string + file string + contents []byte + fileFound bool +} + +// NewOwner creates an operation-local Go tool owner without running Go. +func NewOwner(directory string) *Owner { + return &Owner{directory: directory} +} + +// Acquire returns the operation's Go tool, rediscovering settings when their +// process or persistent inputs have changed. +func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) { + if owner.tool != nil { + changed, err := owner.inputsChanged() + if err == nil && !changed { + return owner.tool, nil + } + } + if err := owner.discover(ctx); err != nil { + return nil, err + } + return owner.tool, nil +} + +// Refresh checks the owner's inputs after a consumer callback and rediscovers +// settings when the callback changed the environment or persistent file. +func (owner *Owner) Refresh(ctx context.Context) error { + if owner.tool == nil { + return nil + } + changed, err := owner.inputsChanged() + if err == nil && !changed { + return nil + } + return owner.discover(ctx) +} + +func (owner *Owner) discover(ctx context.Context) error { + environment := inheritedEnvironment() + tool, err := New(ctx, owner.directory) + if err != nil { + return err + } + settingsFile, err := resolveSettingsFile(owner.directory, tool.settingsFile) + if err != nil { + return err + } + contents, found, err := readSettingsFile(settingsFile) + if err != nil { + return fmt.Errorf("read Go settings file %q: %w", settingsFile, err) + } + owner.tool = tool + owner.env = environment + owner.file = settingsFile + owner.contents = contents + owner.fileFound = found + return nil +} + +func (owner *Owner) inputsChanged() (bool, error) { + if !sameEnvironment(owner.env, inheritedEnvironment()) { + return true, nil + } + contents, found, err := readSettingsFile(owner.file) + if err != nil { + return true, err + } + return found != owner.fileFound || !bytes.Equal(contents, owner.contents), nil +} + +func inheritedEnvironment() map[string]string { + values := make(map[string]string) + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + values[key] = value + } + } + return values +} + +func sameEnvironment(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + other, ok := right[key] + if !ok || other != value { + return false + } + } + return true +} + +func resolveSettingsFile(directory, filename string) (string, error) { + if filename == "" || filename == "off" { + return "", nil + } + if !filepath.IsAbs(filename) { + filename = filepath.Join(directory, filename) + } + return filepath.Abs(filename) +} + +func readSettingsFile(filename string) ([]byte, bool, error) { + if filename == "" { + return nil, false, nil + } + contents, err := os.ReadFile(filename) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return contents, true, nil +} diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 3a92da9..c44f254 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) @@ -49,7 +50,7 @@ type Request struct { ExpectedPushURL string Validation validation.Settings AdditionalChecks func(context.Context, candidate.Candidate) error - AfterValidate func(context.Context, candidate.Candidate) error + AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error Output io.Writer } @@ -120,12 +121,18 @@ func Publish(ctx context.Context, request Request) (Result, error) { } result.Phase = Validation + checks.ToolOwner = gotool.NewOwner(captured.ModuleDir) if err := candidate.Validate(ctx, checks, captured); err != nil { return result, err } if request.AfterValidate != nil { - if err := request.AfterValidate(ctx, captured); err != nil { - return result, fmt.Errorf("after candidate validation: %w", err) + callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner) + refreshErr := checks.ToolOwner.Refresh(ctx) + if callbackErr != nil { + return result, fmt.Errorf("after candidate validation: %w", callbackErr) + } + if refreshErr != nil { + return result, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr) } } if err := candidate.Recheck(ctx, captured, ""); err != nil { diff --git a/internal/validation/race_test.go b/internal/validation/race_test.go index b108e0a..dff739d 100644 --- a/internal/validation/race_test.go +++ b/internal/validation/race_test.go @@ -19,25 +19,38 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) { name string race bool fullVet bool + compile []Target }{ {name: "default"}, {name: "race", race: true}, {name: "full vet", fullVet: true}, {name: "race and full vet", race: true, fullVet: true}, + {name: "target compilation", compile: []Target{{OS: "linux", Arch: "amd64"}}}, } { t.Run(test.name, func(t *testing.T) { logPath := installGoShim(t, "", false) + repositoryDir := t.TempDir() + moduleDir := filepath.Join(repositoryDir, "nested module") + if err := os.MkdirAll(moduleDir, 0o700); err != nil { + t.Fatal(err) + } err := Run(context.Background(), Request{ - RepositoryDir: t.TempDir(), - Settings: Settings{Race: test.race, FullVet: test.fullVet}, + RepositoryDir: repositoryDir, + ModuleDir: moduleDir, + Settings: Settings{Race: test.race, FullVet: test.fullVet, CompileTargets: test.compile}, }) if err != nil { t.Fatalf("Run() error = %v", err) } commands := readGoShimLog(t, logPath) - var tests, vets []string - var testPosition, vetPosition int + for _, directory := range readGoShimDirectories(t, os.Getenv("RELEASEKIT_GO_DIR_LOG")) { + if directory != moduleDir { + t.Errorf("Go command directory = %q, want selected module %q", directory, moduleDir) + } + } + var tests, vets, builds []string + var testPosition, vetPosition, buildPosition int for i, command := range commands { if strings.HasPrefix(command, "test ") { tests = append(tests, command) @@ -47,6 +60,10 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) { vets = append(vets, command) vetPosition = i } + if strings.HasPrefix(command, "build ") { + builds = append(builds, command) + buildPosition = i + } } if len(tests) != 1 { t.Fatalf("Go test invocations = %q, want exactly one", tests) @@ -81,6 +98,27 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) { t.Errorf("Go vet command %q did not follow the test suite %q", vets[0], tests[0]) } } + wantBuilds := 0 + if len(test.compile) != 0 { + wantBuilds = len(test.compile) + } + if len(builds) != wantBuilds { + t.Fatalf("Go build invocations = %q, want %d", builds, wantBuilds) + } + if wantBuilds != 0 { + buildCommand := " " + builds[0] + " " + for _, argument := range []string{"build", "-mod=readonly", "-o", os.DevNull, "./..."} { + if !strings.Contains(buildCommand, " "+argument+" ") { + t.Errorf("Go compile command %q does not contain %q", builds[0], argument) + } + } + if buildPosition <= testPosition { + t.Errorf("Go compile command %q did not follow the test suite %q", builds[0], tests[0]) + } + if environments := readGoShimLog(t, os.Getenv("RELEASEKIT_GO_ENV_LOG")); len(environments) != 1 || environments[0] != "linux/amd64|0" { + t.Errorf("Go compile baseline = %q, want linux/amd64|0", environments) + } + } }) } } @@ -111,6 +149,49 @@ func TestRunReportsRacePrerequisiteFailureWithoutFallback(t *testing.T) { } } +func TestRunValidatesOnlyTheSelectedModule(t *testing.T) { + t.Setenv("GOENV", "off") + repositoryDir := t.TempDir() + nestedDir := filepath.Join(repositoryDir, "nested") + siblingDir := filepath.Join(repositoryDir, "sibling") + for _, directory := range []string{nestedDir, siblingDir} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + writeModule := func(directory, name, testSource string) { + t.Helper() + files := map[string]string{ + "go.mod": "module " + name + "\n\ngo 1.25.0\n", + "module_test.go": testSource, + } + for filename, contents := range files { + if err := os.WriteFile(filepath.Join(directory, filename), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + } + passingTest := "package fixture\nimport \"testing\"\nfunc TestSelected(t *testing.T) {}\n" + failingTest := "package fixture\nimport \"testing\"\nfunc TestSibling(t *testing.T) { t.Fatal(\"sibling suite ran\") }\n" + writeModule(repositoryDir, "example.com/root", passingTest) + writeModule(nestedDir, "example.com/nested", passingTest) + writeModule(siblingDir, "example.com/sibling", failingTest) + + if err := Run(context.Background(), Request{RepositoryDir: repositoryDir}); err != nil { + t.Fatalf("root module validation: %v", err) + } + if err := Run(context.Background(), Request{RepositoryDir: repositoryDir, ModuleDir: nestedDir}); err != nil { + t.Fatalf("nested module validation ran a sibling suite: %v", err) + } + + writeModule(nestedDir, "example.com/nested", failingTest) + err := Run(context.Background(), Request{RepositoryDir: repositoryDir, ModuleDir: nestedDir}) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoTest { + t.Fatalf("failing nested module result = %v, want Go test failure", err) + } +} + func TestRunStopsAfterGoTestFailure(t *testing.T) { logPath := installGoShim(t, "test", false) err := Run(context.Background(), Request{ @@ -214,6 +295,7 @@ func installGoShim(t *testing.T, failure string, delayVet bool) string { directory := t.TempDir() logPath := filepath.Join(directory, "go-commands") envLogPath := filepath.Join(directory, "go-environment") + directoryLogPath := filepath.Join(directory, "go-directories") settings := map[string]string{ "GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, @@ -263,6 +345,7 @@ fi script := fmt.Sprintf(`#!/bin/sh set -eu printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" +pwd -P >> "$RELEASEKIT_GO_DIR_LOG" if [ "${1-}" = "env" ]; then printf '%%s\n' '%s' exit 0 @@ -281,6 +364,7 @@ exit 0 t.Setenv("PATH", path) t.Setenv("RELEASEKIT_GO_LOG", logPath) t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath) + t.Setenv("RELEASEKIT_GO_DIR_LOG", directoryLogPath) return logPath } @@ -293,6 +377,15 @@ func readGoShimLog(t *testing.T, path string) []string { return strings.Split(strings.TrimSpace(string(contents)), "\n") } +func readGoShimDirectories(t *testing.T, path string) []string { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSpace(string(contents)), "\n") +} + func raceSupported(goos, goarch string) bool { switch goos { case "darwin": diff --git a/internal/validation/validation.go b/internal/validation/validation.go index caaaeaf..35c032b 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -57,6 +57,7 @@ type Request struct { Commit string Output io.Writer Settings Settings + ToolOwner *gotool.Owner } // ReadCandidateTree loads the committed-file inventory when a selected check @@ -87,7 +88,15 @@ func Run(ctx context.Context, request Request) (result error) { } } } - tool, err := gotool.New(ctx, request.RepositoryDir) + goDirectory := request.ModuleDir + if goDirectory == "" { + goDirectory = request.RepositoryDir + } + owner := request.ToolOwner + if owner == nil { + owner = gotool.NewOwner(goDirectory) + } + tool, err := owner.Acquire(ctx) if err != nil { return &Failure{Check: GoTest, Cause: err} } @@ -99,19 +108,19 @@ func Run(ctx context.Context, request Request) (result error) { args = append(args, "-vet=off") } args = append(args, "./...") - err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...) + err = tool.Run(ctx, goDirectory, gotool.Native, gotool.Target{}, request.Output, args...) if err != nil { return &Failure{Check: GoTest, Cause: err} } if request.Settings.FullVet { - err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...") + err = tool.Run(ctx, goDirectory, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...") if err != nil { return &Failure{Check: GoVet, Cause: err} } } for _, target := range request.Settings.CompileTargets { selected := gotool.Target{OS: target.OS, Arch: target.Arch} - err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output, + err = tool.Run(ctx, goDirectory, gotool.Static, selected, request.Output, "build", "-mod=readonly", "-o", os.DevNull, "./...") if err != nil { return &Failure{ diff --git a/publish_test.go b/publish_test.go index 5cc3f2a..ea8a8fe 100644 --- a/publish_test.go +++ b/publish_test.go @@ -805,6 +805,7 @@ func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrappe func addNestedReleaseModule(t *testing.T, repository, moduleDir, modulePath, version string) { t.Helper() writeFile(t, repository, moduleDir+"/go.mod", "module "+modulePath+"\n\ngo 1.25.0\n") + writeFile(t, repository, moduleDir+"/fixture_test.go", "package fixture\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n") writeFile(t, repository, moduleDir+"/docs/releases/"+version+".md", "# Sample App "+version+"\n\nNested release.\n") }