From 30fe4fe20634b925bba1a291a000984f70136617 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 22:10:34 +0000 Subject: [PATCH] Enable race testing in validation --- check.go | 24 ++- cmd/releasekit/internal/cli/config.go | 25 ++- cmd/releasekit/internal/cli/config_test.go | 16 ++ cmd/releasekit/main.go | 10 ++ cmd/releasekit/main_test.go | 80 +++++++++ docs/cli.md | 17 +- docs/config.md | 21 ++- docs/consumers/check.md | 22 ++- docs/consumers/publish.md | 18 +- docs/internal/overview.md | 4 +- docs/internal/validation.md | 9 +- docs/roadmap/implementation.md | 5 + docs/roadmap/model.md | 19 +-- internal/candidate/check.go | 4 +- internal/publish/publish.go | 6 +- internal/validation/race_test.go | 188 +++++++++++++++++++++ internal/validation/validation.go | 15 +- publish.go | 6 +- publish_test.go | 70 ++++++++ validation_test.go | 12 ++ 20 files changed, 510 insertions(+), 61 deletions(-) create mode 100644 internal/validation/race_test.go create mode 100644 validation_test.go diff --git a/check.go b/check.go index 0518b73..1ab2010 100644 --- a/check.go +++ b/check.go @@ -6,6 +6,7 @@ import ( internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" ) // ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH. @@ -27,18 +28,34 @@ type ReleaseRequest struct { Product string Version string Remote string - // AdditionalChecks optionally runs product checks after the standard suite. + // Validation selects optional checks run against the captured candidate. + Validation ValidationSpec + // AdditionalChecks optionally runs product checks after shared validation. AdditionalChecks func(context.Context, Candidate) error Build *BuildSpec // optional selected-target build after validation. Output io.Writer } +// ValidationSpec selects optional shared validation checks. Its zero value +// preserves the current validation behavior. +type ValidationSpec struct { + Go GoValidationSpec +} + +// GoValidationSpec selects checks performed by the Go toolchain. +type GoValidationSpec struct { + // Race runs the one standard Go test suite with the race detector enabled. + Race bool +} + // Check validates one local, committed release candidate. It requires a clean // worktree and index on main, a committed note matching Product and Version, // and an unused local tag. It always runs the offline Go test suite with the // available compatible local Go toolchain and Go host target, then calls -// AdditionalChecks when supplied. If Build selects targets, Check builds them -// after validation in temporary storage outside the worktree, then rechecks +// AdditionalChecks when supplied. Validation.Go.Race runs that one suite with +// the race detector; the zero value preserves the ordinary native 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 // candidate evidence only; it does not establish remote availability. func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { @@ -58,6 +75,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request { RepositoryDir: request.RepositoryDir, Product: request.Product, Version: request.Version, + Validation: validation.Settings{Race: request.Validation.Go.Race}, AdditionalChecks: candidateChecks(request.AdditionalChecks), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), Output: request.Output, diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 40cd251..dc90d89 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -112,14 +112,25 @@ type CheckCommand struct { Env map[string]string `json:"env,omitempty"` } -// Settings contains the product, remote, and check commands read from a -// releasekit.json profile. +// Settings contains the product, remote, shared validation selections, and +// check commands read from a releasekit.json profile. type Settings struct { - Product string `json:"product"` - Remote string `json:"remote,omitempty"` - Checks []CheckCommand `json:"checks,omitempty"` - Build *BuildSettings `json:"build,omitempty"` - Delivery *DeliverySettings `json:"delivery,omitempty"` + Product string `json:"product"` + Remote string `json:"remote,omitempty"` + Checks []CheckCommand `json:"checks,omitempty"` + Validation *ValidationSettings `json:"validation,omitempty"` + Build *BuildSettings `json:"build,omitempty"` + Delivery *DeliverySettings `json:"delivery,omitempty"` +} + +// ValidationSettings selects shared candidate checks in a release profile. +type ValidationSettings struct { + Go GoValidationSettings `json:"go,omitempty"` +} + +// GoValidationSettings selects checks performed by the Go toolchain. +type GoValidationSettings struct { + Race bool `json:"race,omitempty"` } // DeliverySettings selects the hosted Gitea release destination. diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index e126649..d5ad349 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -141,6 +141,22 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { } } +func TestLoadSettingsReadsRaceValidation(t *testing.T) { + repoDir := t.TempDir() + profilePath := filepath.Join(repoDir, "releasekit.json") + if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true}}}`), 0o600); err != nil { + t.Fatal(err) + } + + settings, err := LoadSettings(repoDir, "") + if err != nil { + t.Fatal(err) + } + if settings.Validation == nil || !settings.Validation.Go.Race { + t.Fatalf("validation settings = %#v, want Go race enabled", settings.Validation) + } +} + func TestExampleProfileLoads(t *testing.T) { repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index c295df1..eeac378 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -139,6 +139,7 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i Product: settings.Product, Version: invocation.Version, Remote: settings.Remote, + Validation: validationSpec(settings.Validation), Build: configuredBuildSpec(settings.Build), Output: output, } @@ -154,6 +155,15 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i return request } +func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec { + if settings == nil { + return releasekit.ValidationSpec{} + } + return releasekit.ValidationSpec{ + Go: releasekit.GoValidationSpec{Race: settings.Go.Race}, + } +} + func candidateFailureText(err error) string { return err.Error() } diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 97f7f75..e18c241 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -49,6 +49,7 @@ func TestUnknownCommand(t *testing.T) { } func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { + raceSelected := racePrerequisitesAvailable() repository := newCLIRepository(t) profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") orderPath := filepath.Join(t.TempDir(), "check-order") @@ -73,6 +74,9 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { Remote: "origin", Checks: []cli.CheckCommand{check("first"), check("second")}, } + if raceSelected { + settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}} + } contents, err := json.Marshal(settings) if err != nil { t.Fatal(err) @@ -82,6 +86,11 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { } var stdout, stderr bytes.Buffer + raceMarker := "" + if raceSelected { + raceMarker = filepath.Join(t.TempDir(), "race-enabled") + t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker) + } args := []string{"check", "--repo", repository, "--config", profilePath, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) @@ -96,6 +105,23 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } + if raceSelected { + if _, err := os.Stat(raceMarker); err != nil { + t.Fatalf("race-enabled profile did not run: %v", err) + } + } +} + +func TestCandidateRequestMapsRaceValidation(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}, + }, + }, nil) + if !request.Validation.Go.Race { + t.Fatalf("candidate request validation = %#v, want Go race enabled", request.Validation) + } } func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) { @@ -165,6 +191,7 @@ func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) { } func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { + raceSelected := racePrerequisitesAvailable() repository := newCLIRepository(t) writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\n## Compatibility\n\nNo action required.\n") writeRepositoryFile(t, repository, "scripts/check-release-note.sh", `#!/bin/sh @@ -189,6 +216,9 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog}, }}, } + if raceSelected { + settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}} + } contents, err := json.Marshal(settings) if err != nil { t.Fatal(err) @@ -204,6 +234,11 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") var stdout, stderr bytes.Buffer + raceMarker := "" + if raceSelected { + raceMarker = filepath.Join(t.TempDir(), "race-enabled") + t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker) + } args := []string{"publish", "--repo", repository, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) @@ -226,6 +261,11 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" if got, want := strings.Count(string(checkResult), "v1.2.3 "+commit), 1; got != want { t.Fatalf("product compatibility check ran %d times, want %d", got, want) } + if raceSelected { + if _, err := os.Stat(raceMarker); err != nil { + t.Fatalf("race-enabled profile did not run: %v", err) + } + } } func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) { @@ -343,12 +383,52 @@ func newCLIRepository(t *testing.T) string { } write("go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n") write("candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n") + write("race_candidate_test.go", `//go:build race + +package candidate + +import ( + "os" + "testing" +) + +func TestRaceBuildSelection(t *testing.T) { + marker := os.Getenv("RELEASEKIT_RACE_MARKER") + if marker == "" { + return + } + if err := os.WriteFile(marker, []byte("race enabled"), 0o600); err != nil { + t.Fatal(err) + } +} +`) write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n") cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "commit", "-m", "candidate") return repository } +func racePrerequisitesAvailable() bool { + supported := runtime.GOOS == "darwin" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") || + (runtime.GOOS == "freebsd" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "ppc64le") || + runtime.GOOS == "windows" && runtime.GOARCH == "amd64" + if !supported { + return false + } + cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output() + if err != nil || strings.TrimSpace(string(cgo)) != "1" { + return false + } + cc, err := exec.Command("go", "env", "CC").Output() + if err != nil || len(strings.Fields(string(cc))) == 0 { + return false + } + if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil { + return false + } + return true +} + func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{} diff --git a/docs/cli.md b/docs/cli.md index 650cf75..6b5ac8a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,10 +23,10 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION `VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the version. The repository defaults to the current directory, and the profile defaults to `/releasekit.json`. A successful command prints the -captured local commit and version. It checks the committed release note, local -Git state, runs the standard offline Go test suite, and then runs any configured -additional checks in profile order. It does not inspect remote availability or -publish a tag. +captured local commit and version. It checks the committed release note and +local Git state, runs the standard offline Go test suite (with `-race` when +`validation.go.race` is true), and then runs any configured additional checks +in profile order. It does not inspect remote availability or publish a tag. `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, @@ -40,10 +40,11 @@ Publish a source tag: go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION ``` -`publish` reads the same product, remote, and additional-check settings as -`check`. It checks the authoritative remote and validates the candidate: the -standard offline Go test suite runs first, followed by configured checks in -profile order. It then creates an annotated local tag and pushes only its exact +`publish` reads the same product, remote, validation, and additional-check +settings as `check`. It checks the authoritative remote and validates the +candidate: the single standard offline Go test suite runs first, with `-race` +when `validation.go.race` is true, followed by configured checks in profile +order. It then creates an annotated local tag and pushes only its exact tag object. It prints the confirmed version, candidate commit, and tag object on success. Validation progress and failed publication state, including the captured publication diff --git a/docs/config.md b/docs/config.md index db94d5c..8a4d7e8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -11,6 +11,7 @@ The current profile fields are: | `product` | Yes | Product name used by release operations. | | `remote` | No | Selected Git remote; defaults to `origin`. | | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | +| `validation` | No | Optional shared checks selected by ReleaseKit. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | | `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. | @@ -25,6 +26,17 @@ Unknown fields are rejected so a setting cannot be silently ignored. The target requires the package and binary name; each target is written as one `GOOS/GOARCH` pair. An omitted or empty target list means no binary work. +The optional `validation` object selects shared checks implemented by ReleaseKit. +`validation.go.race` enables the race detector for the single standard Go test +suite. When omitted or false, the ordinary native suite runs. For example: + +```json +{ + "product": "Example", + "validation": {"go": {"race": true}} +} +``` + The `delivery` object contains `base_url`, `owner`, `repository`, and optional `token_env`. Its values are required when running `deliver`, but credentials are not required for `check`, `publish`, or `build`. `token_env` names the @@ -32,10 +44,11 @@ environment variable containing the Gitea token; it defaults to `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and passes its value explicitly to the library operation. -The `check` and `publish` commands always run -`go test -mod=readonly -count=1 ./...` with Go module and toolchain network -access disabled. They then run each configured `checks` command once, in order, -as an additional product check. Selected-target builds run after both. Do not +The `check` and `publish` commands run one +`go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network +access disabled, adding `-race` when `validation.go.race` is true. They then run +each configured `checks` command once, in order, as an additional product +check. Selected-target builds run after the suite and product checks. Do not repeat the standard suite in `checks`; doing so runs it twice. 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 6b581df..70284bd 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -8,6 +8,9 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{ RepositoryDir: "/work/project", Product: "Example", Version: "v1.2.3", + Validation: releasekit.ValidationSpec{ + Go: releasekit.GoValidationSpec{Race: true}, + }, AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { return runProjectChecks(ctx, candidate.RepositoryDir) }, @@ -24,14 +27,17 @@ 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 always runs `go test -mod=readonly -count=1 ./...` with the available -compatible local Go toolchain, then calls `AdditionalChecks` once when supplied. -It does not download a different toolchain automatically. The standard 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. +ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the +available compatible local Go toolchain, then calls `AdditionalChecks` once +when supplied. Set `Validation.Go.Race` to run that suite with `-race`; the zero +value keeps the ordinary native suite. ReleaseKit does not download a different +toolchain automatically. If the race prerequisite is unavailable, the `go-test` +check fails rather than being skipped. The suite uses the Go host target, +disables workspace and module network access, and ignores ambient `GOFLAGS`, +experiment, and CPU feature overrides. Effective cache and native cgo/C-tool +settings are preserved, including values from Go's persistent environment +file. The check revalidates the branch, commit, and cleanliness after +validation. A successful check is evidence about the local candidate only. It does not check whether a remote branch or tag is available and does not create a tag. diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 93b1560..25b03f3 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -8,6 +8,9 @@ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ RepositoryDir: "/work/project", Product: "Example", Version: "v1.2.3", + Validation: releasekit.ValidationSpec{ + Go: releasekit.GoValidationSpec{Race: true}, + }, AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { return runProjectChecks(ctx, candidate.RepositoryDir) }, @@ -20,12 +23,15 @@ fmt.Println(result.Version, result.CandidateCommit, result.TagObject) Publication captures a clean candidate on `main`, validates its committed release note, and checks the selected remote before running candidate checks. -It always runs `go test -mod=readonly -count=1 ./...` with the available -compatible local Go toolchain. It then calls `AdditionalChecks` once when -supplied. The standard suite cannot be replaced; move product-specific behavior -into `AdditionalChecks` and remove repeated standard-suite commands from custom -checks. A failed suite or additional check prevents tag creation, and wrapped -errors preserve their causes. +It 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`; its zero value keeps the ordinary native suite. It then calls +`AdditionalChecks` once when supplied. The standard suite cannot be replaced; +move product-specific behavior into `AdditionalChecks` and remove repeated +standard-suite commands from custom checks. A failed suite or additional check +prevents tag creation, and wrapped errors preserve their causes. +If race prerequisites are unavailable, the selected suite fails as `go-test` +instead of silently running without the race detector. If `Build` selects targets, ReleaseKit verifies them after the standard suite and additional checks, before creating the tag. The operation then rechecks the diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 98d0fae..5b506a0 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 default Go validation, 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 selection, 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` | `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`, `TestCandidateRequestMapsRaceValidation`, `TestLoadSettingsReadsRaceValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce` | | 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 e57c9d0..2c46ff7 100644 --- a/docs/internal/validation.md +++ b/docs/internal/validation.md @@ -8,10 +8,11 @@ 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 current executor runs the default offline Go suite. 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 default Go-only path does not enumerate the tree. +The executor runs one offline Go suite, optionally with the race detector when +selected. 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 +enumerate the tree. `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes lazily, and resolves destinations without consulting the worktree or neighboring files. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cb39d9d..4e7056e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -226,6 +226,11 @@ imports only lightweight `buildinfo`. Stages 12–14 implement this agreed appro ## Stage 1: Introduce the specification with race selection +**Completion:** Complete. `ReleaseRequest.Validation.Go.Race` and profile +`validation.go.race` select `-race` for the single native Go suite. Both +candidate operations share the selection, and race failures retain the `go-test` +identity without falling back to an ordinary suite. + **Outcome:** The first public/profile selection changes the one existing suite. 1. Add `ReleaseRequest.Validation`, `ValidationSpec.Go`, and diff --git a/docs/roadmap/model.md b/docs/roadmap/model.md index 645af51..e6fd11f 100644 --- a/docs/roadmap/model.md +++ b/docs/roadmap/model.md @@ -1,7 +1,7 @@ # Validation API and profile model -Status: Shared validation foundation implemented; optional v0.2.0 capabilities -and their public/profile settings remain future work. +Status: Shared validation foundation and Go race selection implemented; +remaining v0.2.0 capabilities and their settings are future work. This roadmap describes the shared model that enables the optional validation capabilities in the [v0.2.0 roadmap](v0.2.0.md). It owns their common API, @@ -18,9 +18,9 @@ Selected executable builds follow validation. The CLI maps profile `checks` commands to the additional-check callback. This provides a common procedure and a product-specific extension point. The -request and profile currently have no structured selection for shared optional -validation. Race testing, full vet, package compilation, formatting, Markdown, -module-distribution, and file-size checks need a coherent home as they are added. +request and profile now have a structured selection for race testing; full vet, +package compilation, formatting, Markdown, module-distribution, and file-size +checks will use the same model as their capabilities are added. The model should let consumers declare their choices once while ReleaseKit owns execution and coordination. This follows [ADR 0002](../adr/0002-routine-releases.md): @@ -124,11 +124,10 @@ failure-stopping behavior of the candidate workflow. ## Implementation -The [implementation plan](implementation.md) records the completed foundation: -shared execution, internal diagnostics, captured candidate-tree inspection and -destination resolution, facade diagnostics, and consistent CLI presentation. -The public `ReleaseRequest.Validation` field and profile `validation` object are -not yet available; each setting will be introduced with its first working +The [implementation plan](implementation.md) records the completed foundation +and current rollout. The facade exposes `ReleaseRequest.Validation.Go.Race`, and +profiles accept `validation.go.race`; both select `-race` for the one base Go +suite. Each remaining setting will be introduced with its first working capability. Individual checkers, module-selected CI tooling, and the publication endpoint expectation remain future work in the [v0.2.0 roadmap](v0.2.0.md). diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 6d30f21..263a514 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -29,6 +29,7 @@ type Request struct { RepositoryDir string Product string Version string + Validation validation.Settings AdditionalChecks func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate) error Output io.Writer @@ -95,13 +96,14 @@ func Capture(ctx context.Context, request Request) (Candidate, error) { return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil } -// Validate runs the standard offline Go suite, followed by AdditionalChecks +// 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 { if err := validation.Run(ctx, validation.Request{ RepositoryDir: captured.RepositoryDir, Commit: captured.Commit, Output: request.Output, + Settings: request.Validation, }); err != nil { return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index be365be..bb795a5 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/validation" ) type State string @@ -40,6 +41,7 @@ type Request struct { Product string Version string Remote string + Validation validation.Settings AdditionalChecks func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error Output io.Writer @@ -55,7 +57,8 @@ type Result struct { } // Publish validates one captured candidate and pushes only its annotated tag -// object. It runs the standard Go suite, then AdditionalChecks when supplied. +// object. It runs one standard Go suite, with the selected settings, then +// AdditionalChecks when supplied. // A failed push receives one bounded authoritative tag query. Exact // object equality confirms publication; absence and conflict are classified, // while an unreadable result remains Unknown and preserves the push error. @@ -69,6 +72,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { RepositoryDir: request.RepositoryDir, Product: request.Product, Version: request.Version, + Validation: request.Validation, AdditionalChecks: request.AdditionalChecks, Output: request.Output, } diff --git a/internal/validation/race_test.go b/internal/validation/race_test.go new file mode 100644 index 0000000..dd4d1fe --- /dev/null +++ b/internal/validation/race_test.go @@ -0,0 +1,188 @@ +package validation + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRunUsesOneSuiteWithSelectedRaceSetting(t *testing.T) { + for _, test := range []struct { + name string + race bool + }{ + {name: "default"}, + {name: "race", race: true}, + } { + t.Run(test.name, func(t *testing.T) { + logPath := installGoShim(t, false) + err := Run(context.Background(), Request{ + RepositoryDir: t.TempDir(), + Settings: Settings{Race: test.race}, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + commands := readGoShimLog(t, logPath) + var tests []string + for _, command := range commands { + if strings.HasPrefix(command, "test ") { + tests = append(tests, command) + } + } + if len(tests) != 1 { + t.Fatalf("Go test invocations = %q, want exactly one", tests) + } + command := " " + tests[0] + " " + for _, argument := range []string{"test", "-mod=readonly", "-count=1", "./..."} { + if !strings.Contains(command, " "+argument+" ") { + t.Errorf("Go test command %q does not contain %q", tests[0], argument) + } + } + if got := strings.Contains(command, " -race "); got != test.race { + t.Errorf("Go test command %q has race=%t, want %t", tests[0], got, test.race) + } + }) + } +} + +func TestRunReportsRacePrerequisiteFailureWithoutFallback(t *testing.T) { + logPath := installGoShim(t, true) + err := Run(context.Background(), Request{ + RepositoryDir: t.TempDir(), + Settings: Settings{Race: true}, + }) + var failure *Failure + if !errors.As(err, &failure) || failure.Check != GoTest { + t.Fatalf("Run() error = %v, want a go-test validation failure", err) + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("Run() error = %v, want the command failure preserved", err) + } + commands := readGoShimLog(t, logPath) + var tests []string + for _, command := range commands { + if strings.HasPrefix(command, "test ") { + tests = append(tests, command) + } + } + if len(tests) != 1 || !strings.Contains(" "+tests[0]+" ", " -race ") { + t.Fatalf("Go test invocations = %q, want one race-enabled invocation and no fallback", tests) + } +} + +func TestRunExecutesRaceEnabledSuiteOnHost(t *testing.T) { + if !raceSupported(runtime.GOOS, runtime.GOARCH) { + t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) + } + cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output() + if err != nil || strings.TrimSpace(string(cgo)) != "1" { + t.Skip("Go race detector requires CGO_ENABLED=1") + } + cc, err := exec.Command("go", "env", "CC").Output() + if err != nil || len(strings.Fields(string(cc))) == 0 { + t.Skip("Go race detector requires a C compiler") + } + if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil { + t.Skip("Go race detector C compiler is unavailable") + } + module := t.TempDir() + if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/racefixture\n\ngo 1.25.0\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(module, "race_test.go"), []byte(`package racefixture + +import "testing" + +func TestRaceFixture(t *testing.T) {} +`), 0o600); err != nil { + t.Fatal(err) + } + if err := Run(context.Background(), Request{RepositoryDir: module, Settings: Settings{Race: true}}); err != nil { + t.Fatalf("race-enabled Go suite failed: %v", err) + } +} + +func installGoShim(t *testing.T, failRace bool) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the controlled Go command fixture uses a POSIX shell") + } + directory := t.TempDir() + logPath := filepath.Join(directory, "go-commands") + settings := map[string]string{ + "GOHOSTOS": runtime.GOOS, + "GOHOSTARCH": runtime.GOARCH, + "CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "", + "CGO_CXXFLAGS": "", "CGO_ENABLED": "1", "CGO_LDFLAGS": "", + "GOCACHE": "", "GOCACHEPROG": "", "GOMODCACHE": "", "GOPATH": "", + "GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "", + } + encodedSettings, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + failBlock := "" + if failRace { + failBlock = ` +for argument in "$@"; do + if [ "$argument" = "-race" ]; then + echo "race detector prerequisite unavailable" >&2 + exit 2 + fi +done +` + } + script := fmt.Sprintf(`#!/bin/sh +set -eu +printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" +if [ "${1-}" = "env" ]; then + printf '%%s\n' '%s' + exit 0 +fi +%s +exit 0 +`, encodedSettings, failBlock) + 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 readGoShimLog(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": + return goarch == "amd64" || goarch == "arm64" + case "freebsd", "linux": + return goarch == "amd64" || goarch == "arm64" || goarch == "ppc64le" + case "windows": + return goarch == "amd64" + default: + return false + } +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 9424d26..8cbe804 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -8,9 +8,10 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" ) -// Settings selects shared validation behavior. Its zero value runs the current -// default Go suite. -type Settings struct{} +// Settings selects shared validation behavior. +type Settings struct { + Race bool +} // Request identifies the captured source and output used by shared validation. type Request struct { @@ -32,8 +33,12 @@ func Run(ctx context.Context, request Request) error { if err != nil { return &Failure{Check: GoTest, Cause: err} } - _, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, - "test", "-mod=readonly", "-count=1", "./...") + args := []string{"test", "-mod=readonly", "-count=1"} + if request.Settings.Race { + args = append(args, "-race") + } + args = append(args, "./...") + _, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...) if err != nil { return &Failure{Check: GoTest, Cause: err} } diff --git a/publish.go b/publish.go index a7c9bfa..597146c 100644 --- a/publish.go +++ b/publish.go @@ -48,8 +48,9 @@ type PublicationResult struct { State PublicationState // publication outcome for this invocation } -// Publish always runs the offline Go test suite for the captured candidate, -// then calls AdditionalChecks when supplied. It optionally builds selected +// Publish always runs one offline Go test suite for the captured candidate; +// Validation.Go.Race enables the race detector for that suite. 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 @@ -65,6 +66,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Product: candidateInput.Product, Version: candidateInput.Version, Remote: request.Remote, + Validation: candidateInput.Validation, AdditionalChecks: candidateInput.AdditionalChecks, AfterValidate: candidateInput.AfterValidate, Output: candidateInput.Output, diff --git a/publish_test.go b/publish_test.go index a2dbb98..09b3b9b 100644 --- a/publish_test.go +++ b/publish_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -93,6 +94,35 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { } } +func TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks(t *testing.T) { + requireRacePrerequisites(t) + repo := newPublishFixture(t) + raceMarker := filepath.Join(t.TempDir(), "race-enabled") + t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker) + checks := 0 + _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + Validation: releasekit.ValidationSpec{ + Go: releasekit.GoValidationSpec{Race: true}, + }, + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + checks++ + return nil + }, + }) + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if checks != 1 { + t.Fatalf("additional checks ran %d times, want once", checks) + } + if _, err := os.Stat(raceMarker); err != nil { + t.Fatalf("race-enabled fixture did not run: %v", err) + } +} + func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) { repo := newPublishFixture(t) t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") @@ -133,6 +163,27 @@ func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testin } } +func requireRacePrerequisites(t *testing.T) { + t.Helper() + supported := runtime.GOOS == "darwin" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") || + (runtime.GOOS == "freebsd" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "ppc64le") || + runtime.GOOS == "windows" && runtime.GOARCH == "amd64" + if !supported { + t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) + } + cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output() + if err != nil || strings.TrimSpace(string(cgo)) != "1" { + t.Skip("Go race detector requires CGO_ENABLED=1") + } + cc, err := exec.Command("go", "env", "CC").Output() + if err != nil || len(strings.Fields(string(cc))) == 0 { + t.Skip("Go race detector requires a C compiler") + } + if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil { + t.Skip("Go race detector C compiler is unavailable") + } +} + func TestCheckReturnsPublicValidationError(t *testing.T) { repo := newPublishFixture(t) t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") @@ -627,6 +678,25 @@ func TestFixture(t *testing.T) { t.Fatal("fixture standard suite failure") } } +`) + writeFile(t, work, "race_fixture_test.go", `//go:build race + +package fixture + +import ( + "os" + "testing" +) + +func TestRaceBuildSelection(t *testing.T) { + marker := os.Getenv("RELEASEKIT_RACE_MARKER") + if marker == "" { + return + } + if err := os.WriteFile(marker, []byte("race enabled"), 0o600); err != nil { + t.Fatal(err) + } +} `) writeFile(t, work, "docs/releases/v1.2.3.md", releaseNote) writeFile(t, work, "source.txt", "candidate") diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..a4e19da --- /dev/null +++ b/validation_test.go @@ -0,0 +1,12 @@ +package releasekit + +import "testing" + +func TestCandidateRequestMapsRaceValidation(t *testing.T) { + request := candidateRequest(ReleaseRequest{ + Validation: ValidationSpec{Go: GoValidationSpec{Race: true}}, + }) + if !request.Validation.Race { + t.Fatalf("candidate request validation = %#v, want race enabled", request.Validation) + } +}