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"
"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,

View File

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

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) {
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil {

View File

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

View File

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

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. The repository defaults to the current directory, and the profile
defaults to `<repository>/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

View File

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

View File

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

View File

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

View File

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

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
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.

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
**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

View File

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

View File

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

View File

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

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"
)
// 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}
}

View File

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

View File

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

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