Enable race testing in validation

This commit is contained in:
2026-10-07 22:10:34 +00:00
parent 89981abbe5
commit 30fe4fe206
20 changed files with 510 additions and 61 deletions

View File

@@ -6,6 +6,7 @@ import (
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "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. // ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
@@ -27,18 +28,34 @@ type ReleaseRequest struct {
Product string Product string
Version string Version string
Remote 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 AdditionalChecks func(context.Context, Candidate) error
Build *BuildSpec // optional selected-target build after validation. Build *BuildSpec // optional selected-target build after validation.
Output io.Writer 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 // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // worktree and index on main, a committed note matching Product and Version,
// and an unused local tag. It always runs the offline Go test suite with the // 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 // available compatible local Go toolchain and Go host target, then calls
// AdditionalChecks when supplied. If Build selects targets, Check builds them // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
// after validation in temporary storage outside the worktree, then rechecks // 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 // branch, HEAD, worktree, index, and local tag state. Success establishes local
// candidate evidence only; it does not establish remote availability. // candidate evidence only; it does not establish remote availability.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
@@ -58,6 +75,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: validation.Settings{Race: request.Validation.Go.Race},
AdditionalChecks: candidateChecks(request.AdditionalChecks), AdditionalChecks: candidateChecks(request.AdditionalChecks),
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output, Output: request.Output,

View File

@@ -112,14 +112,25 @@ type CheckCommand struct {
Env map[string]string `json:"env,omitempty"` Env map[string]string `json:"env,omitempty"`
} }
// Settings contains the product, remote, and check commands read from a // Settings contains the product, remote, shared validation selections, and
// releasekit.json profile. // check commands read from a releasekit.json profile.
type Settings struct { type Settings struct {
Product string `json:"product"` Product string `json:"product"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"` Validation *ValidationSettings `json:"validation,omitempty"`
Delivery *DeliverySettings `json:"delivery,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. // DeliverySettings selects the hosted Gitea release destination.

View File

@@ -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) { func TestExampleProfileLoads(t *testing.T) {
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil { if err != nil {

View File

@@ -139,6 +139,7 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
Remote: settings.Remote, Remote: settings.Remote,
Validation: validationSpec(settings.Validation),
Build: configuredBuildSpec(settings.Build), Build: configuredBuildSpec(settings.Build),
Output: output, Output: output,
} }
@@ -154,6 +155,15 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
return request 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 { func candidateFailureText(err error) string {
return err.Error() return err.Error()
} }

View File

@@ -49,6 +49,7 @@ func TestUnknownCommand(t *testing.T) {
} }
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) repository := newCLIRepository(t)
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") profilePath := filepath.Join(t.TempDir(), "profile with spaces.json")
orderPath := filepath.Join(t.TempDir(), "check-order") orderPath := filepath.Join(t.TempDir(), "check-order")
@@ -73,6 +74,9 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
Remote: "origin", Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")}, Checks: []cli.CheckCommand{check("first"), check("second")},
} }
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings) contents, err := json.Marshal(settings)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -82,6 +86,11 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
var stdout, stderr bytes.Buffer 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"} args := []string{"check", "--repo", repository, "--config", profilePath, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) 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 { 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) 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) { func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) {
@@ -165,6 +191,7 @@ func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) {
} }
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) 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, "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 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}, Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog},
}}, }},
} }
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings) contents, err := json.Marshal(settings)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -204,6 +234,11 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
var stdout, stderr bytes.Buffer 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"} args := []string{"publish", "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) 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 { 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) 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) { 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("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("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") write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "candidate") cliGitRun(t, repository, "commit", "-m", "candidate")
return repository 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) { func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}

View File

@@ -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` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile version. The repository defaults to the current directory, and the profile
defaults to `<repository>/releasekit.json`. A successful command prints the defaults to `<repository>/releasekit.json`. A successful command prints the
captured local commit and version. It checks the committed release note, local captured local commit and version. It checks the committed release note and
Git state, runs the standard offline Go test suite, and then runs any configured local Git state, runs the standard offline Go test suite (with `-race` when
additional checks in profile order. It does not inspect remote availability or `validation.go.race` is true), and then runs any configured additional checks
publish a tag. 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 `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, 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 go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION
``` ```
`publish` reads the same product, remote, and additional-check settings as `publish` reads the same product, remote, validation, and additional-check
`check`. It checks the authoritative remote and validates the candidate: the settings as `check`. It checks the authoritative remote and validates the
standard offline Go test suite runs first, followed by configured checks in candidate: the single standard offline Go test suite runs first, with `-race`
profile order. It then creates an annotated local tag and pushes only its exact 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 tag object. It prints the confirmed version, candidate commit, and tag object
on success. Validation on success. Validation
progress and failed publication state, including the captured publication progress and failed publication state, including the captured publication

View File

@@ -11,6 +11,7 @@ The current profile fields are:
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `remote` | No | Selected Git remote; defaults to `origin`. |
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `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. | | `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. | | `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 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. `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 The `delivery` object contains `base_url`, `owner`, `repository`, and optional
`token_env`. Its values are required when running `deliver`, but credentials are `token_env`. Its values are required when running `deliver`, but credentials are
not required for `check`, `publish`, or `build`. `token_env` names the 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 `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
passes its value explicitly to the library operation. passes its value explicitly to the library operation.
The `check` and `publish` commands always run The `check` and `publish` commands run one
`go test -mod=readonly -count=1 ./...` with Go module and toolchain network `go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network
access disabled. They then run each configured `checks` command once, in order, access disabled, adding `-race` when `validation.go.race` is true. They then run
as an additional product check. Selected-target builds run after both. Do not 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` 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 command uses the selected build settings and writes binaries to `dist` unless
`--output` selects another path. `--output` selects another path.

View File

@@ -8,6 +8,9 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true},
},
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir) 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 product name and exact version as complete title elements. No local tag with
that version may already exist. that version may already exist.
ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the
compatible local Go toolchain, then calls `AdditionalChecks` once when supplied. available compatible local Go toolchain, then calls `AdditionalChecks` once
It does not download a different toolchain automatically. The standard suite when supplied. Set `Validation.Go.Race` to run that suite with `-race`; the zero
uses the Go host target, disables workspace and module network access, and value keeps the ordinary native suite. ReleaseKit does not download a different
ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective toolchain automatically. If the race prerequisite is unavailable, the `go-test`
cache and native cgo/C-tool settings are preserved, including values from Go's check fails rather than being skipped. The suite uses the Go host target,
persistent environment file. The check revalidates the branch, commit, and disables workspace and module network access, and ignores ambient `GOFLAGS`,
cleanliness after validation. experiment, and CPU feature overrides. Effective cache and native cgo/C-tool
settings are preserved, including values from Go's persistent environment
file. The check revalidates the branch, commit, and cleanliness after
validation.
A successful check is evidence about the local candidate only. It does not A successful check is evidence about the local candidate only. It does not
check whether a remote branch or tag is available and does not create a tag. check whether a remote branch or tag is available and does not create a tag.

View File

@@ -8,6 +8,9 @@ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true},
},
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir) 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 Publication captures a clean candidate on `main`, validates its committed
release note, and checks the selected remote before running candidate checks. release note, and checks the selected remote before running candidate checks.
It always runs `go test -mod=readonly -count=1 ./...` with the available It runs one `go test -mod=readonly -count=1 ./...` suite with the available
compatible local Go toolchain. It then calls `AdditionalChecks` once when compatible local Go toolchain. Set `Validation.Go.Race` to run that suite with
supplied. The standard suite cannot be replaced; move product-specific behavior `-race`; its zero value keeps the ordinary native suite. It then calls
into `AdditionalChecks` and remove repeated standard-suite commands from custom `AdditionalChecks` once when supplied. The standard suite cannot be replaced;
checks. A failed suite or additional check prevents tag creation, and wrapped move product-specific behavior into `AdditionalChecks` and remove repeated
errors preserve their causes. 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 If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the and additional checks, before creating the tag. The operation then rechecks the

View File

@@ -46,7 +46,7 @@ these implemented packages:
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `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/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/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
@@ -78,7 +78,7 @@ not a separate release matrix:
| Capability | Implementation owner | Existing evidence | | Capability | Implementation owner | Existing evidence |
| --- | --- | --- | | --- | --- | --- |
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
| Candidate capture, 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` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |

View File

@@ -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 adds its endpoint guards, tag creation, push, and recovery around that same
candidate path. candidate path.
The current executor runs the default offline Go suite. Its request carries the The executor runs one offline Go suite, optionally with the race detector when
captured repository directory and commit, output writer, and internal selected. Its request carries the captured repository directory and commit,
settings. `Request.ReadCandidateTree` lets a selected file check request output writer, and internal settings. `Request.ReadCandidateTree` lets a
candidate contents; the default Go-only path does not enumerate the tree. 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 `CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or lazily, and resolves destinations without consulting the worktree or
neighboring files. neighboring files.

View File

@@ -226,6 +226,11 @@ imports only lightweight `buildinfo`. Stages 12–14 implement this agreed appro
## Stage 1: Introduce the specification with race selection ## 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. **Outcome:** The first public/profile selection changes the one existing suite.
1. Add `ReleaseRequest.Validation`, `ValidationSpec.Go`, and 1. Add `ReleaseRequest.Validation`, `ValidationSpec.Go`, and

View File

@@ -1,7 +1,7 @@
# Validation API and profile model # Validation API and profile model
Status: Shared validation foundation implemented; optional v0.2.0 capabilities Status: Shared validation foundation and Go race selection implemented;
and their public/profile settings remain future work. remaining v0.2.0 capabilities and their settings are future work.
This roadmap describes the shared model that enables the optional validation 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, 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. commands to the additional-check callback.
This provides a common procedure and a product-specific extension point. The This provides a common procedure and a product-specific extension point. The
request and profile currently have no structured selection for shared optional request and profile now have a structured selection for race testing; full vet,
validation. Race testing, full vet, package compilation, formatting, Markdown, package compilation, formatting, Markdown, module-distribution, and file-size
module-distribution, and file-size checks need a coherent home as they are added. checks will use the same model as their capabilities are added.
The model should let consumers declare their choices once while ReleaseKit owns The model should let consumers declare their choices once while ReleaseKit owns
execution and coordination. This follows [ADR 0002](../adr/0002-routine-releases.md): execution and coordination. This follows [ADR 0002](../adr/0002-routine-releases.md):
@@ -124,11 +124,10 @@ failure-stopping behavior of the candidate workflow.
## Implementation ## Implementation
The [implementation plan](implementation.md) records the completed foundation: The [implementation plan](implementation.md) records the completed foundation
shared execution, internal diagnostics, captured candidate-tree inspection and and current rollout. The facade exposes `ReleaseRequest.Validation.Go.Race`, and
destination resolution, facade diagnostics, and consistent CLI presentation. profiles accept `validation.go.race`; both select `-race` for the one base Go
The public `ReleaseRequest.Validation` field and profile `validation` object are suite. Each remaining setting will be introduced with its first working
not yet available; each setting will be introduced with its first working
capability. Individual checkers, module-selected CI tooling, and the publication 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). endpoint expectation remain future work in the [v0.2.0 roadmap](v0.2.0.md).

View File

@@ -29,6 +29,7 @@ type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Validation validation.Settings
AdditionalChecks func(context.Context, Candidate) error AdditionalChecks func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate) error
Output io.Writer 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 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. // when supplied, for the captured candidate.
func Validate(ctx context.Context, request Request, captured Candidate) error { func Validate(ctx context.Context, request Request, captured Candidate) error {
if err := validation.Run(ctx, validation.Request{ if err := validation.Run(ctx, validation.Request{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit, Commit: captured.Commit,
Output: request.Output, Output: request.Output,
Settings: request.Validation,
}); err != nil { }); err != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
} }

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
type State string type State string
@@ -40,6 +41,7 @@ type Request struct {
Product string Product string
Version string Version string
Remote string Remote string
Validation validation.Settings
AdditionalChecks func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error
Output io.Writer Output io.Writer
@@ -55,7 +57,8 @@ type Result struct {
} }
// Publish validates one captured candidate and pushes only its annotated tag // 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 // A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified, // object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error. // 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, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: request.Validation,
AdditionalChecks: request.AdditionalChecks, AdditionalChecks: request.AdditionalChecks,
Output: request.Output, Output: request.Output,
} }

View File

@@ -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
}
}

View File

@@ -8,9 +8,10 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
) )
// Settings selects shared validation behavior. Its zero value runs the current // Settings selects shared validation behavior.
// default Go suite. type Settings struct {
type Settings struct{} Race bool
}
// Request identifies the captured source and output used by shared validation. // Request identifies the captured source and output used by shared validation.
type Request struct { type Request struct {
@@ -32,8 +33,12 @@ func Run(ctx context.Context, request Request) error {
if err != nil { if err != nil {
return &Failure{Check: GoTest, Cause: err} return &Failure{Check: GoTest, Cause: err}
} }
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args := []string{"test", "-mod=readonly", "-count=1"}
"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 { if err != nil {
return &Failure{Check: GoTest, Cause: err} return &Failure{Check: GoTest, Cause: err}
} }

View File

@@ -48,8 +48,9 @@ type PublicationResult struct {
State PublicationState // publication outcome for this invocation State PublicationState // publication outcome for this invocation
} }
// Publish always runs the offline Go test suite for the captured candidate, // Publish always runs one offline Go test suite for the captured candidate;
// then calls AdditionalChecks when supplied. It optionally builds selected // 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 // 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 // annotated tag object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and // 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, Product: candidateInput.Product,
Version: candidateInput.Version, Version: candidateInput.Version,
Remote: request.Remote, Remote: request.Remote,
Validation: candidateInput.Validation,
AdditionalChecks: candidateInput.AdditionalChecks, AdditionalChecks: candidateInput.AdditionalChecks,
AfterValidate: candidateInput.AfterValidate, AfterValidate: candidateInput.AfterValidate,
Output: candidateInput.Output, Output: candidateInput.Output,

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") 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) { func TestCheckReturnsPublicValidationError(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
@@ -627,6 +678,25 @@ func TestFixture(t *testing.T) {
t.Fatal("fixture standard suite failure") 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, "docs/releases/v1.2.3.md", releaseNote)
writeFile(t, work, "source.txt", "candidate") writeFile(t, work, "source.txt", "candidate")

12
validation_test.go Normal file
View File

@@ -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)
}
}