From cabc56b134f979b1a41c3224351958f4c5da4156 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 22:27:31 +0000 Subject: [PATCH] Compile packages for selected targets --- check.go | 23 ++- cmd/releasekit/internal/cli/config.go | 16 +- cmd/releasekit/internal/cli/config_test.go | 9 +- cmd/releasekit/main.go | 12 +- cmd/releasekit/main_test.go | 17 +- docs/cli.md | 12 ++ docs/config.md | 25 ++- docs/consumers/check.md | 10 +- docs/consumers/publish.md | 5 + docs/internal/gotool.md | 9 +- docs/internal/overview.md | 4 +- docs/internal/validation.md | 19 +- docs/roadmap/implementation.md | 5 + internal/validation/compile_test.go | 204 +++++++++++++++++++++ internal/validation/diagnostic.go | 2 + internal/validation/race_test.go | 11 +- internal/validation/validation.go | 40 +++- publish.go | 7 +- validation_error.go | 2 +- validation_test.go | 14 +- 20 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 internal/validation/compile_test.go diff --git a/check.go b/check.go index 44ee0ee..91be31f 100644 --- a/check.go +++ b/check.go @@ -49,6 +49,11 @@ type GoValidationSpec struct { // FullVet runs go vet after the suite instead of the test-time vet pass. // Failures use the go-vet validation check identity. FullVet bool + // CompileTargets checks that every package builds for each complete target + // pair after testing and vet. Compilation uses CGO_ENABLED=0 and does not + // produce artifacts or execute target binaries. Failures use the + // package-compile validation check identity. + CompileTargets []Target } // Check validates one local, committed release candidate. It requires a clean @@ -56,8 +61,11 @@ type GoValidationSpec struct { // and an unused local tag. It always runs the offline Go test suite with the // available compatible local Go toolchain and Go host target, then calls // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with -// the race detector, and Validation.Go.FullVet runs go vet after the suite -// instead of test-time vet. Their zero values preserve the ordinary suite. If +// the race detector, Validation.Go.FullVet runs go vet after the suite instead +// of test-time vet, and Validation.Go.CompileTargets compiles all packages for +// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package +// compilation produces no artifacts and does not execute target binaries. +// Their zero values preserve the ordinary suite. If // Build selects targets, Check builds them after validation in temporary // storage outside the worktree, then rechecks // branch, HEAD, worktree, index, and local tag state. Success establishes local @@ -75,18 +83,23 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { } func candidateRequest(request ReleaseRequest) candidate.Request { - return candidate.Request{ + result := candidate.Request{ RepositoryDir: request.RepositoryDir, Product: request.Product, Version: request.Version, Validation: validation.Settings{ - Race: request.Validation.Go.Race, - FullVet: request.Validation.Go.FullVet, + Race: request.Validation.Go.Race, + FullVet: request.Validation.Go.FullVet, + CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), }, AdditionalChecks: candidateChecks(request.AdditionalChecks), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), Output: request.Output, } + for index, target := range request.Validation.Go.CompileTargets { + result.Validation.CompileTargets[index] = validation.Target{OS: target.OS, Arch: target.Arch} + } + return result } func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error { diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index ca29d96..ba18be3 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -130,8 +130,9 @@ type ValidationSettings struct { // GoValidationSettings selects checks performed by the Go toolchain. type GoValidationSettings struct { - Race bool `json:"race,omitempty"` - FullVet bool `json:"full_vet,omitempty"` + Race bool `json:"race,omitempty"` + FullVet bool `json:"full_vet,omitempty"` + CompileTargets []string `json:"compile_targets,omitempty"` } // DeliverySettings selects the hosted Gitea release destination. @@ -219,6 +220,17 @@ func LoadSettings(repoDir, configPath string) (Settings, error) { } } } + if settings.Validation != nil { + for i, target := range settings.Validation.Go.CompileTargets { + if strings.Count(target, "/") != 1 { + return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) + } + goos, goarch, _ := strings.Cut(target, "/") + if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" { + return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i) + } + } + } return settings, nil } diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index d41eed8..cda44d3 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") - if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true,"full_vet":true}}}`), 0o600); err != nil { + if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } @@ -155,6 +155,9 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) { if settings.Validation == nil || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { t.Fatalf("validation settings = %#v, want Go race and full vet enabled", settings.Validation) } + if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want { + t.Fatalf("compile targets = %q, want %q", got, want) + } } func TestExampleProfileLoads(t *testing.T) { @@ -207,6 +210,10 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) { {name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"}, {name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"}, {name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"}, + {name: "compile target format", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, + {name: "compile target missing architecture", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, + {name: "compile target missing operating system", content: `{"product":"Example","validation":{"go":{"compile_targets":["/amd64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, + {name: "compile target repeated separator", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/amd64/arm64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, {name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"}, {name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"}, {name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"}, diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 6ecfc47..b87261a 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -159,12 +159,18 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec if settings == nil { return releasekit.ValidationSpec{} } - return releasekit.ValidationSpec{ + result := releasekit.ValidationSpec{ Go: releasekit.GoValidationSpec{ - Race: settings.Go.Race, - FullVet: settings.Go.FullVet, + Race: settings.Go.Race, + FullVet: settings.Go.FullVet, + CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)), }, } + for index, target := range settings.Go.CompileTargets { + goos, goarch, _ := strings.Cut(target, "/") + result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch} + } + return result } func candidateFailureText(err error) string { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index c0018d0..d4f4a94 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -116,12 +116,21 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) { request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ Product: "Example Product", Validation: &cli.ValidationSettings{ - Go: cli.GoValidationSettings{Race: true, FullVet: true}, + Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}}, }, }, nil) if !request.Validation.Go.Race || !request.Validation.Go.FullVet { t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) } + wantTargets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}} + if len(request.Validation.Go.CompileTargets) != len(wantTargets) { + t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets) + } + for index := range wantTargets { + if request.Validation.Go.CompileTargets[index] != wantTargets[index] { + t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets) + } + } } func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) { @@ -434,7 +443,11 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) { state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() - settings := cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} + settings := cli.Settings{ + Product: "Example Product", + Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}}, + Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, + } writeDeliveryProfile(t, repository, settings) state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs. diff --git a/docs/cli.md b/docs/cli.md index 7d9f4fd..b20822c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,6 +30,13 @@ pass after successful tests when `validation.go.full_vet` is true. It then runs configured additional checks in profile order. It does not inspect remote availability or publish a tag. +When `validation.go.compile_targets` is selected, `check` compiles all packages +for each configured `GOOS/GOARCH` pair after tests and full vet, using static +mode with cgo disabled. This check produces no artifacts and does not execute +target binaries. Compilation failures use the `package-compile` identity and +show the selected target. Any artifact build selected by the separate `build` +setting runs after shared validation and configured checks. + `check` writes errors and diagnostics to standard error. It returns status 0 after local validation, 1 after a candidate validation failure, and 2 for usage, profile, or invalid-version errors. Shared validation failures identify the @@ -59,6 +66,11 @@ bounded reconciliation determines whether the exact tag was accepted. Shared validation failures use the same check and finding presentation as `check`, alongside publication state and phase. +When `validation.go.compile_targets` is selected, package compilation runs +after tests and full vet and before configured checks. Each failure is reported +as `package-compile` with its selected target; this does not create build +artifacts or execute target binaries. + The command returns status 0 for confirmed publication, 1 for a known publication failure or conflict, 2 for usage, profile, or invalid-version errors, and 3 when the push outcome remains unknown. Unknown status must be diff --git a/docs/config.md b/docs/config.md index 2a124e3..5ae8da0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -30,13 +30,24 @@ The optional `validation` object selects shared checks implemented by ReleaseKit `validation.go.race` enables the race detector for the single standard Go test suite. `validation.go.full_vet` runs a separate native `go vet` pass after that suite succeeds; ReleaseKit disables test-time vet for that suite to avoid -running vet twice. When `full_vet` is omitted or false, the test command keeps -its existing vet behavior. Both settings are independent. For example: +running vet twice. `validation.go.compile_targets` is an ordered array of +`GOOS/GOARCH` pairs. After tests and selected full vet pass, ReleaseKit compiles +all packages for each target with cgo disabled. Each pair must include both +components; the selected Go toolchain reports unsupported complete targets. +This source compatibility check does not name or produce binaries, run a target +executable, or create an artifact manifest. Omitted or empty `compile_targets` +selects no package-compilation pass. When `full_vet` is omitted or false, the +test command keeps its existing vet behavior. These settings are independent. +For example: ```json { "product": "Example", - "validation": {"go": {"race": true, "full_vet": true}} + "validation": {"go": { + "race": true, + "full_vet": true, + "compile_targets": ["linux/amd64", "linux/arm64"] + }} } ``` @@ -52,8 +63,12 @@ The `check` and `publish` commands run one access disabled, adding `-race` when `validation.go.race` is true. When `validation.go.full_vet` is true, they add `-vet=off` to that test command and run native `go vet -mod=readonly ./...` after successful tests. They then run -each configured `checks` command once, in order, as an additional product -check. Selected-target builds run after shared validation and product checks. +`go build -mod=readonly -o ./...` in static mode for each +`validation.go.compile_targets` entry, in profile order. Compile failures use +the `package-compile` identity and include the selected target. Configured +`checks` commands run afterward, once each in order, as additional product +checks. Artifact builds selected by the separate `build` setting run after +shared validation and product checks. Do not repeat the standard suite or vet in `checks`; doing so repeats work. The `build` command uses the selected build settings and writes binaries to `dist` unless `--output` selects another path. diff --git a/docs/consumers/check.md b/docs/consumers/check.md index 2eaa8ac..034ae8d 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -31,9 +31,13 @@ ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite 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. It calls `AdditionalChecks` once after shared validation when -supplied. Their zero values retain the ordinary native test behavior. ReleaseKit -does not download a different toolchain automatically. If race prerequisites +for the suite. Set `Validation.Go.CompileTargets` to compile packages for each +complete target pair after tests and vet. Package compilation uses +`CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its +failures use the `package-compile` identity and include the target. The zero +values retain the ordinary native test behavior and select no compilation. +ReleaseKit calls `AdditionalChecks` once after shared validation when supplied. +It does not download a different toolchain automatically. If race prerequisites are unavailable, the `go-test` check fails rather than being skipped; a selected full-vet failure is reported as `go-vet`. The suite uses the Go host target, disables workspace and module network access, and ignores ambient `GOFLAGS`, diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index ac0f310..c8639f5 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -36,6 +36,11 @@ If race prerequisites are unavailable, the selected suite fails as `go-test` instead of silently running without the race detector. Full-vet failures are reported as `go-vet` and stop publication before tag creation. +Set `Validation.Go.CompileTargets` to compile packages for each complete target +pair after tests and vet. This uses `CGO_ENABLED=0`, produces no artifact, and +does not execute target binaries. A failure is reported as `package-compile` +with the selected target and prevents tag creation. + If `Build` selects targets, ReleaseKit verifies them after the standard suite and additional checks, before creating the tag. The operation then rechecks the candidate and remote guards, creates an annotated tag for the captured commit, diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index 5f7030b..dd2ebff 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -41,6 +41,9 @@ 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. +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. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 0d0615a..1050a3e 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -46,7 +46,7 @@ these implemented packages: | `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | -| `internal/validation` | Shared Go validation with optional race and full-vet selections, structured check failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | +| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, structured check failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | @@ -78,7 +78,7 @@ not a separate release matrix: | Capability | Implementation owner | Existing evidence | | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | -| 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`, `TestCandidateRequestMapsRaceValidation`, `TestLoadSettingsReadsRaceValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce` | +| 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`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | diff --git a/docs/internal/validation.md b/docs/internal/validation.md index 3e81746..abf03f8 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -10,9 +10,13 @@ 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, using the -same discovered Go tool and native environment. Test and vet failures retain -their separate `go-test` and `go-vet` identities and underlying causes. Its +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 request carries the captured repository directory and commit, output writer, and internal settings. `Request.ReadCandidateTree` lets a selected file check request candidate contents; the Go-only path does not @@ -70,13 +74,14 @@ for product behavior. ## Go command environment reuse The current executor uses the [shared Go command environment](gotool.md) for -its offline suite. The [roadmap coordination design](../roadmap/implementation.md#execution-and-ownership) -owns the proposed test, vet, and package-compilation procedure. +its offline suite, vet pass, and selected-target package compilation. The +[implementation roadmap](../roadmap/implementation.md#execution-and-ownership) +records the shared ordering across validation and later file checks. Reuse `internal/gotool` for native and static environments and target handling. Keep toolchain and module-cache discovery in `gotool`; do not reproduce its -environment logic in a checker. Build output names, version injection, native -smoke checks, and artifact digests remain owned by `internal/build`. +environment logic in a checker. Artifact output names, version injection, +native smoke checks, and artifact digests remain owned by `internal/build`. ## File-check coordination diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9a2ce5d..5bfd531 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -271,6 +271,11 @@ Protect the selected command relationship without freezing incidental flag order ## Stage 3: Compile packages for selected targets +**Completion:** Complete. `Go.CompileTargets` and profile +`validation.go.compile_targets` select static package compilation after tests +and vet. Failures use `package-compile` and include the selected target; +artifact building remains separate. + **Outcome:** Source compatibility checks work independently of artifacts. 1. Add `Go.CompileTargets` and string-array profile targets. Convert to internal diff --git a/internal/validation/compile_test.go b/internal/validation/compile_test.go new file mode 100644 index 0000000..a3910cc --- /dev/null +++ b/internal/validation/compile_test.go @@ -0,0 +1,204 @@ +package validation + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestRunCompilesTargetsAfterTestAndVetInSelectionOrder(t *testing.T) { + logPath := installGoShim(t, "", false) + targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}} + err := Run(context.Background(), Request{ + RepositoryDir: t.TempDir(), + Settings: Settings{FullVet: true, CompileTargets: targets}, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + var commands []string + for _, command := range readGoShimLog(t, logPath) { + if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") { + commands = append(commands, command) + } + } + if len(commands) != len(targets)+2 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") { + t.Fatalf("validation commands = %q, want test, vet, then one build per target", commands) + } + for index, target := range targets { + command := commands[index+2] + if !strings.HasPrefix(command, "build ") { + t.Fatalf("command %d = %q, want package build", index+2, command) + } + for _, argument := range []string{"build", "-mod=readonly", "-o " + os.DevNull, "./..."} { + if !strings.Contains(" "+command+" ", " "+argument+" ") { + t.Errorf("build command %q does not contain %q", command, argument) + } + } + if got, want := strings.Fields(command)[0], "build"; got != want { + t.Errorf("command = %q, want %q", got, want) + } + if target.OS == "" || target.Arch == "" { + t.Fatalf("test target is incomplete: %#v", target) + } + } + + environments, err := os.ReadFile(os.Getenv("RELEASEKIT_GO_ENV_LOG")) + if err != nil { + t.Fatal(err) + } + got := strings.Split(strings.TrimSpace(string(environments)), "\n") + if len(got) != len(targets) { + t.Fatalf("static build environments = %q, want %d entries", got, len(targets)) + } + for index, target := range targets { + if want := fmt.Sprintf("%s/%s|0", target.OS, target.Arch); got[index] != want { + t.Errorf("static build environment %d = %q, want %q", index, got[index], want) + } + } +} + +func TestRunAttributesTargetCompileFailureAndStops(t *testing.T) { + logPath := installGoShim(t, "darwin/arm64", false) + targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}, {OS: "windows", Arch: "amd64"}} + err := Run(context.Background(), Request{ + RepositoryDir: t.TempDir(), + Settings: Settings{FullVet: true, CompileTargets: targets}, + }) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != PackageCompile { + t.Fatalf("Run() error = %v, want a package-compile failure", err) + } + if len(failure.Findings) != 1 || failure.Findings[0].Target != "darwin/arm64" { + t.Fatalf("package compilation findings = %#v, want target darwin/arm64", failure.Findings) + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("Run() error = %v, want the Go command failure preserved", err) + } + var commands []string + for _, command := range readGoShimLog(t, logPath) { + if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") { + commands = append(commands, command) + } + } + if len(commands) != 4 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") || !strings.HasPrefix(commands[2], "build ") || !strings.HasPrefix(commands[3], "build ") { + t.Fatalf("validation commands after target failure = %q, want test, vet, two builds", commands) + } +} + +func TestRunRejectsIncompleteCompileTargets(t *testing.T) { + installGoShim(t, "", false) + for _, target := range []Target{{OS: "linux"}, {Arch: "amd64"}, {OS: " ", Arch: "amd64"}, {OS: "linux/other", Arch: "amd64"}} { + err := Run(context.Background(), Request{ + RepositoryDir: t.TempDir(), + Settings: Settings{CompileTargets: []Target{target}}, + }) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != PackageCompile { + t.Errorf("Run(%#v) error = %v, want a package-compile validation failure", target, err) + continue + } + if len(failure.Findings) != 1 || failure.Findings[0].Target != target.OS+"/"+target.Arch { + t.Errorf("Run(%#v) findings = %#v, want the incomplete target", target, failure.Findings) + } + } +} + +func TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts(t *testing.T) { + fixtures := []struct { + name string + files map[string]string + }{ + { + name: "source library", + files: map[string]string{ + "library.go": "package fixture\n\nconst Value = 1\n", + }, + }, + { + name: "single main", + files: map[string]string{ + "library.go": "package fixture\n\nconst Value = 1\n", + "cmd/app/main.go": "package main\n\nfunc main() {}\n", + }, + }, + { + name: "multiple mains with repeated basenames", + files: map[string]string{ + "library.go": "package fixture\n\nconst Value = 1\n", + "cmd/one/tool/main.go": "package main\n\nfunc main() {}\n", + "cmd/two/tool/main.go": "package main\n\nfunc main() {}\n", + }, + }, + } + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + module := writeCompileFixture(t, fixture.files) + before := readCompileFixture(t, module, fixture.files) + var targets []Target + if fixture.name == "source library" { + targets = []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}} + } else { + targets = []Target{{OS: "linux", Arch: "amd64"}} + } + if err := Run(context.Background(), Request{ + RepositoryDir: module, + Settings: Settings{CompileTargets: targets}, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + after := readCompileFixture(t, module, fixture.files) + for path, contents := range before { + if string(after[path]) != string(contents) { + t.Errorf("candidate file %q changed during compilation", path) + } + } + }) + } +} + +func writeCompileFixture(t *testing.T, files map[string]string) string { + t.Helper() + module := t.TempDir() + if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/compilefixture\n\ngo 1.25.0\n"), 0o600); err != nil { + t.Fatal(err) + } + for path, contents := range files { + filename := filepath.Join(module, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + return module +} + +func readCompileFixture(t *testing.T, module string, files map[string]string) map[string][]byte { + t.Helper() + contents := make(map[string][]byte, len(files)+1) + for _, path := range append([]string{"go.mod"}, fixturePaths(files)...) { + data, err := os.ReadFile(filepath.Join(module, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + contents[path] = data + } + return contents +} + +func fixturePaths(files map[string]string) []string { + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + return paths +} diff --git a/internal/validation/diagnostic.go b/internal/validation/diagnostic.go index 9f33f19..30822fa 100644 --- a/internal/validation/diagnostic.go +++ b/internal/validation/diagnostic.go @@ -13,6 +13,8 @@ const ( GoTest Check = "go-test" // GoVet identifies the separate full vet pass. GoVet Check = "go-vet" + // PackageCompile identifies selected-target package compilation. + PackageCompile Check = "package-compile" ) // Finding describes a validation observation at an optional source location. diff --git a/internal/validation/race_test.go b/internal/validation/race_test.go index b273e94..b108e0a 100644 --- a/internal/validation/race_test.go +++ b/internal/validation/race_test.go @@ -213,6 +213,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") settings := map[string]string{ "GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, @@ -250,8 +251,15 @@ if [ "${1-}" = "vet" ] && [ "%s" = "vet" ]; then echo "Go vet failed" >&2 exit 2 fi +if [ "${1-}" = "build" ]; then + printf '%%s/%%s|%%s\n' "${GOOS-}" "${GOARCH-}" "${CGO_ENABLED-}" >> "$RELEASEKIT_GO_ENV_LOG" + if [ "%s" = "build" ] || [ "%s" = "${GOOS-}/${GOARCH-}" ]; then + echo "Go build failed" >&2 + exit 2 + fi +fi %s -`, failure, failure, failure, vetDelay) +`, failure, failure, failure, failure, failure, vetDelay) script := fmt.Sprintf(`#!/bin/sh set -eu printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" @@ -272,6 +280,7 @@ exit 0 } t.Setenv("PATH", path) t.Setenv("RELEASEKIT_GO_LOG", logPath) + t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath) return logPath } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 138bfaf..2eff571 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -3,15 +3,25 @@ package validation import ( "context" + "fmt" "io" + "os" + "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" ) // Settings selects shared validation behavior. type Settings struct { - Race bool - FullVet bool + Race bool + FullVet bool + CompileTargets []Target +} + +// Target selects one package-compilation target. +type Target struct { + OS string + Arch string } // Request identifies the captured source and output used by shared validation. @@ -30,6 +40,17 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e // Run executes shared validation for the captured candidate. func Run(ctx context.Context, request Request) error { + for _, target := range request.Settings.CompileTargets { + if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") { + return &Failure{ + Check: PackageCompile, + Findings: []Finding{{ + Target: target.OS + "/" + target.Arch, + Message: "target must be one complete GOOS/GOARCH pair", + }}, + } + } + } tool, err := gotool.New(ctx, request.RepositoryDir) if err != nil { return &Failure{Check: GoTest, Cause: err} @@ -52,5 +73,20 @@ func Run(ctx context.Context, request Request) error { 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, + "build", "-mod=readonly", "-o", os.DevNull, "./...") + if err != nil { + return &Failure{ + Check: PackageCompile, + Findings: []Finding{{ + Target: fmt.Sprintf("%s/%s", target.OS, target.Arch), + Message: "package compilation failed", + }}, + Cause: err, + } + } + } return nil } diff --git a/publish.go b/publish.go index 8e5a565..51d1a60 100644 --- a/publish.go +++ b/publish.go @@ -50,8 +50,11 @@ type PublicationResult struct { // Publish always runs one offline Go test suite for the captured candidate. // Validation.Go.Race enables the race detector for that suite, and -// Validation.Go.FullVet runs go vet afterward instead of test-time vet. It then -// calls AdditionalChecks when supplied. It optionally builds selected +// Validation.Go.FullVet runs go vet afterward instead of test-time vet, and +// Validation.Go.CompileTargets compiles all packages for each selected target +// with cgo disabled after tests and vet. Compilation produces no artifacts and +// does not execute target binaries. It then calls AdditionalChecks when +// supplied. It optionally builds selected // targets in temporary storage outside the worktree and pushes the exact // annotated tag object to the selected remote. It returns a result with errors: preflight // failures are not_attempted, failed authoritative guards are conflict, and diff --git a/validation_error.go b/validation_error.go index ae94f23..8dd2eb9 100644 --- a/validation_error.go +++ b/validation_error.go @@ -8,7 +8,7 @@ import ( ) // ValidationCheck identifies a shared validation check that failed. -// Current checks include "go-test" and "go-vet". +// Current checks include "go-test", "go-vet", and "package-compile". type ValidationCheck string // ValidationFinding describes a failed check observation at an optional diff --git a/validation_test.go b/validation_test.go index 9959187..abeabf1 100644 --- a/validation_test.go +++ b/validation_test.go @@ -9,11 +9,23 @@ import ( func TestCandidateRequestMapsGoValidation(t *testing.T) { request := candidateRequest(ReleaseRequest{ - Validation: ValidationSpec{Go: GoValidationSpec{Race: true, FullVet: true}}, + Validation: ValidationSpec{Go: GoValidationSpec{ + Race: true, FullVet: true, + CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, + }}, }) if !request.Validation.Race || !request.Validation.FullVet { t.Fatalf("candidate request validation = %#v, want race and full vet enabled", request.Validation) } + want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}} + if len(request.Validation.CompileTargets) != len(want) { + t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want) + } + for index := range want { + if request.Validation.CompileTargets[index] != want[index] { + t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want) + } + } } func TestValidationErrorPreservesGoVetIdentity(t *testing.T) {