Compile packages for selected targets

This commit is contained in:
2026-10-07 22:27:31 +00:00
parent 1c41f7a0d1
commit cabc56b134
20 changed files with 406 additions and 40 deletions

View File

@@ -49,6 +49,11 @@ type GoValidationSpec struct {
// FullVet runs go vet after the suite instead of the test-time vet pass. // FullVet runs go vet after the suite instead of the test-time vet pass.
// Failures use the go-vet validation check identity. // Failures use the go-vet validation check identity.
FullVet bool FullVet bool
// CompileTargets checks that every package builds for each complete target
// pair after testing and vet. Compilation uses CGO_ENABLED=0 and does not
// produce artifacts or execute target binaries. Failures use the
// package-compile validation check identity.
CompileTargets []Target
} }
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
@@ -56,8 +61,11 @@ type GoValidationSpec struct {
// and an unused local tag. It always runs the offline Go test suite with the // 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. Validation.Go.Race runs that one suite with // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
// the race detector, and Validation.Go.FullVet runs go vet after the suite // the race detector, Validation.Go.FullVet runs go vet after the suite instead
// instead of test-time vet. Their zero values preserve the ordinary suite. If // of test-time vet, and Validation.Go.CompileTargets compiles all packages for
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
// compilation produces no artifacts and does not execute target binaries.
// Their zero values preserve the ordinary suite. If
// Build selects targets, Check builds them after validation in temporary // Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks // 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
@@ -75,18 +83,23 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
} }
func candidateRequest(request ReleaseRequest) candidate.Request { func candidateRequest(request ReleaseRequest) candidate.Request {
return candidate.Request{ result := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: validation.Settings{ Validation: validation.Settings{
Race: request.Validation.Go.Race, Race: request.Validation.Go.Race,
FullVet: request.Validation.Go.FullVet, FullVet: request.Validation.Go.FullVet,
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
}, },
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,
} }
for index, target := range request.Validation.Go.CompileTargets {
result.Validation.CompileTargets[index] = validation.Target{OS: target.OS, Arch: target.Arch}
}
return result
} }
func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error { func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error {

View File

@@ -130,8 +130,9 @@ type ValidationSettings struct {
// GoValidationSettings selects checks performed by the Go toolchain. // GoValidationSettings selects checks performed by the Go toolchain.
type GoValidationSettings struct { type GoValidationSettings struct {
Race bool `json:"race,omitempty"` Race bool `json:"race,omitempty"`
FullVet bool `json:"full_vet,omitempty"` FullVet bool `json:"full_vet,omitempty"`
CompileTargets []string `json:"compile_targets,omitempty"`
} }
// DeliverySettings selects the hosted Gitea release destination. // DeliverySettings selects the hosted Gitea release destination.
@@ -219,6 +220,17 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
} }
} }
} }
if settings.Validation != nil {
for i, target := range settings.Validation.Go.CompileTargets {
if strings.Count(target, "/") != 1 {
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
}
goos, goarch, _ := strings.Cut(target, "/")
if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" {
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
}
}
}
return settings, nil return settings, nil
} }

View File

@@ -144,7 +144,7 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
func TestLoadSettingsReadsGoValidation(t *testing.T) { func TestLoadSettingsReadsGoValidation(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") profilePath := filepath.Join(repoDir, "releasekit.json")
if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true,"full_vet":true}}}`), 0o600); err != nil { if err := os.WriteFile(profilePath, []byte(`{"product":"Example","validation":{"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -155,6 +155,9 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
if settings.Validation == nil || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { if settings.Validation == nil || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
t.Fatalf("validation settings = %#v, want Go race and full vet enabled", settings.Validation) t.Fatalf("validation settings = %#v, want Go race and full vet enabled", settings.Validation)
} }
if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want {
t.Fatalf("compile targets = %q, want %q", got, want)
}
} }
func TestExampleProfileLoads(t *testing.T) { func TestExampleProfileLoads(t *testing.T) {
@@ -207,6 +210,10 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
{name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"}, {name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"},
{name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"}, {name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"}, {name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
{name: "compile target format", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target missing architecture", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target missing operating system", content: `{"product":"Example","validation":{"go":{"compile_targets":["/amd64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target repeated separator", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/amd64/arm64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"}, {name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"}, {name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"}, {name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},

View File

@@ -159,12 +159,18 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
if settings == nil { if settings == nil {
return releasekit.ValidationSpec{} return releasekit.ValidationSpec{}
} }
return releasekit.ValidationSpec{ result := releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{ Go: releasekit.GoValidationSpec{
Race: settings.Go.Race, Race: settings.Go.Race,
FullVet: settings.Go.FullVet, FullVet: settings.Go.FullVet,
CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)),
}, },
} }
for index, target := range settings.Go.CompileTargets {
goos, goarch, _ := strings.Cut(target, "/")
result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch}
}
return result
} }
func candidateFailureText(err error) string { func candidateFailureText(err error) string {

View File

@@ -116,12 +116,21 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{ request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{
Product: "Example Product", Product: "Example Product",
Validation: &cli.ValidationSettings{ Validation: &cli.ValidationSettings{
Go: cli.GoValidationSettings{Race: true, FullVet: true}, Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
}, },
}, nil) }, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet { if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation) t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation)
} }
wantTargets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.Go.CompileTargets) != len(wantTargets) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets)
}
for index := range wantTargets {
if request.Validation.Go.CompileTargets[index] != wantTargets[index] {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets)
}
}
} }
func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) { func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) {
@@ -434,7 +443,11 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
settings := cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} settings := cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []string{"linux/amd64"}}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs. cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs.

View File

@@ -30,6 +30,13 @@ pass after successful tests when `validation.go.full_vet` is true. It then runs
configured additional checks in profile order. It does not inspect remote configured additional checks in profile order. It does not inspect remote
availability or publish a tag. availability or publish a tag.
When `validation.go.compile_targets` is selected, `check` compiles all packages
for each configured `GOOS/GOARCH` pair after tests and full vet, using static
mode with cgo disabled. This check produces no artifacts and does not execute
target binaries. Compilation failures use the `package-compile` identity and
show the selected target. Any artifact build selected by the separate `build`
setting runs after shared validation and configured checks.
`check` writes errors and diagnostics to standard error. It returns status 0 `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,
profile, or invalid-version errors. Shared validation failures identify the profile, or invalid-version errors. Shared validation failures identify the
@@ -59,6 +66,11 @@ bounded reconciliation determines whether the exact tag was accepted.
Shared validation failures use the same check and finding presentation as Shared validation failures use the same check and finding presentation as
`check`, alongside publication state and phase. `check`, alongside publication state and phase.
When `validation.go.compile_targets` is selected, package compilation runs
after tests and full vet and before configured checks. Each failure is reported
as `package-compile` with its selected target; this does not create build
artifacts or execute target binaries.
The command returns status 0 for confirmed publication, 1 for a known The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version publication failure or conflict, 2 for usage, profile, or invalid-version
errors, and 3 when the push outcome remains unknown. Unknown status must be errors, and 3 when the push outcome remains unknown. Unknown status must be

View File

@@ -30,13 +30,24 @@ The optional `validation` object selects shared checks implemented by ReleaseKit
`validation.go.race` enables the race detector for the single standard Go test `validation.go.race` enables the race detector for the single standard Go test
suite. `validation.go.full_vet` runs a separate native `go vet` pass after that suite. `validation.go.full_vet` runs a separate native `go vet` pass after that
suite succeeds; ReleaseKit disables test-time vet for that suite to avoid suite succeeds; ReleaseKit disables test-time vet for that suite to avoid
running vet twice. When `full_vet` is omitted or false, the test command keeps running vet twice. `validation.go.compile_targets` is an ordered array of
its existing vet behavior. Both settings are independent. For example: `GOOS/GOARCH` pairs. After tests and selected full vet pass, ReleaseKit compiles
all packages for each target with cgo disabled. Each pair must include both
components; the selected Go toolchain reports unsupported complete targets.
This source compatibility check does not name or produce binaries, run a target
executable, or create an artifact manifest. Omitted or empty `compile_targets`
selects no package-compilation pass. When `full_vet` is omitted or false, the
test command keeps its existing vet behavior. These settings are independent.
For example:
```json ```json
{ {
"product": "Example", "product": "Example",
"validation": {"go": {"race": true, "full_vet": true}} "validation": {"go": {
"race": true,
"full_vet": true,
"compile_targets": ["linux/amd64", "linux/arm64"]
}}
} }
``` ```
@@ -52,8 +63,12 @@ The `check` and `publish` commands run one
access disabled, adding `-race` when `validation.go.race` is true. When access disabled, adding `-race` when `validation.go.race` is true. When
`validation.go.full_vet` is true, they add `-vet=off` to that test command and `validation.go.full_vet` is true, they add `-vet=off` to that test command and
run native `go vet -mod=readonly ./...` after successful tests. They then run run native `go vet -mod=readonly ./...` after successful tests. They then run
each configured `checks` command once, in order, as an additional product `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
check. Selected-target builds run after shared validation and product checks. `validation.go.compile_targets` entry, in profile order. Compile failures use
the `package-compile` identity and include the selected target. Configured
`checks` commands run afterward, once each in order, as additional product
checks. Artifact builds selected by the separate `build` setting run after
shared validation and product checks.
Do not repeat the standard suite or vet in `checks`; doing so repeats work. The Do not repeat the standard suite or vet in `checks`; doing so repeats work. The
`build` command uses the selected build settings and writes binaries to `dist` unless `build` command uses the selected build settings and writes binaries to `dist` unless
`--output` selects another path. `--output` selects another path.

View File

@@ -31,9 +31,13 @@ ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the
available compatible local Go toolchain. Set `Validation.Go.Race` to run that available compatible local Go toolchain. Set `Validation.Go.Race` to run that
suite with `-race`; set `Validation.Go.FullVet` to run suite with `-race`; set `Validation.Go.FullVet` to run
`go vet -mod=readonly ./...` after successful tests, with test-time vet disabled `go vet -mod=readonly ./...` after successful tests, with test-time vet disabled
for the suite. It calls `AdditionalChecks` once after shared validation when for the suite. Set `Validation.Go.CompileTargets` to compile packages for each
supplied. Their zero values retain the ordinary native test behavior. ReleaseKit complete target pair after tests and vet. Package compilation uses
does not download a different toolchain automatically. If race prerequisites `CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its
failures use the `package-compile` identity and include the target. The zero
values retain the ordinary native test behavior and select no compilation.
ReleaseKit calls `AdditionalChecks` once after shared validation when supplied.
It does not download a different toolchain automatically. If race prerequisites
are unavailable, the `go-test` check fails rather than being skipped; a selected are unavailable, the `go-test` check fails rather than being skipped; a selected
full-vet failure is reported as `go-vet`. The suite uses the Go host target, full-vet failure is reported as `go-vet`. The suite uses the Go host target,
disables workspace and module network access, and ignores ambient `GOFLAGS`, disables workspace and module network access, and ignores ambient `GOFLAGS`,

View File

@@ -36,6 +36,11 @@ If race prerequisites are unavailable, the selected suite fails as `go-test`
instead of silently running without the race detector. Full-vet failures are instead of silently running without the race detector. Full-vet failures are
reported as `go-vet` and stop publication before tag creation. reported as `go-vet` and stop publication before tag creation.
Set `Validation.Go.CompileTargets` to compile packages for each complete target
pair after tests and vet. This uses `CGO_ENABLED=0`, produces no artifact, and
does not execute target binaries. A failure is reported as `package-compile`
with the selected target and prevents tag creation.
If `Build` selects targets, ReleaseKit verifies them after the standard suite 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
candidate and remote guards, creates an annotated tag for the captured commit, candidate and remote guards, creates an annotated tag for the captured commit,

View File

@@ -41,6 +41,9 @@ baseline values. Effective `GOCACHE`, `GOCACHEPROG`, `GOMODCACHE`, `GOPATH`,
`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain `GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain
available to child commands. available to child commands.
Candidate validation uses native mode for the offline `go test` suite. Builds Candidate validation uses native mode for the offline `go test` suite and
use static mode for selected-target compilation and the native version-check selected full `go vet` pass. Package-compilation checks use static mode for each
build. The native version executable itself runs as a normal child process. selected target and direct Go's output to the operating system null device, so
they leave no binary artifact. Artifact builds also use static mode for
selected-target compilation, then build the native version-check executable;
that executable runs as a normal child process.

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 Go validation with optional race and full-vet selections, structured check failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, structured check failures, and lazy committed-file access for captured candidates; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/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` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestCandidateRequestMapsRaceValidation`, `TestLoadSettingsReadsRaceValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | 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

@@ -10,9 +10,13 @@ candidate path.
The executor runs one offline Go suite, optionally with the race detector when The executor runs one offline Go suite, optionally with the race detector when
selected. When full vet is selected, it disables test-time vet for that suite selected. When full vet is selected, it disables test-time vet for that suite
and runs native `go vet -mod=readonly ./...` after the suite succeeds, using the and runs native `go vet -mod=readonly ./...` after the suite succeeds. It then
same discovered Go tool and native environment. Test and vet failures retain runs `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
their separate `go-test` and `go-vet` identities and underlying causes. Its selected package-compilation target, preserving target order and stopping on
the first failure. This compiles source without producing an artifact or
executing a target binary. Test, vet, and package-compilation failures retain
their `go-test`, `go-vet`, and `package-compile` identities, selected target
where applicable, and underlying causes. Its
request carries the captured repository directory and commit, output writer, request carries the captured repository directory and commit, output writer,
and internal settings. `Request.ReadCandidateTree` lets a and internal settings. `Request.ReadCandidateTree` lets a
selected file check request candidate contents; the Go-only path does not selected file check request candidate contents; the Go-only path does not
@@ -70,13 +74,14 @@ for product behavior.
## Go command environment reuse ## Go command environment reuse
The current executor uses the [shared Go command environment](gotool.md) for The current executor uses the [shared Go command environment](gotool.md) for
its offline suite. The [roadmap coordination design](../roadmap/implementation.md#execution-and-ownership) its offline suite, vet pass, and selected-target package compilation. The
owns the proposed test, vet, and package-compilation procedure. [implementation roadmap](../roadmap/implementation.md#execution-and-ownership)
records the shared ordering across validation and later file checks.
Reuse `internal/gotool` for native and static environments and target handling. Reuse `internal/gotool` for native and static environments and target handling.
Keep toolchain and module-cache discovery in `gotool`; do not reproduce its Keep toolchain and module-cache discovery in `gotool`; do not reproduce its
environment logic in a checker. Build output names, version injection, native environment logic in a checker. Artifact output names, version injection,
smoke checks, and artifact digests remain owned by `internal/build`. native smoke checks, and artifact digests remain owned by `internal/build`.
## File-check coordination ## File-check coordination

View File

@@ -271,6 +271,11 @@ Protect the selected command relationship without freezing incidental flag order
## Stage 3: Compile packages for selected targets ## Stage 3: Compile packages for selected targets
**Completion:** Complete. `Go.CompileTargets` and profile
`validation.go.compile_targets` select static package compilation after tests
and vet. Failures use `package-compile` and include the selected target;
artifact building remains separate.
**Outcome:** Source compatibility checks work independently of artifacts. **Outcome:** Source compatibility checks work independently of artifacts.
1. Add `Go.CompileTargets` and string-array profile targets. Convert to internal 1. Add `Go.CompileTargets` and string-array profile targets. Convert to internal

View File

@@ -0,0 +1,204 @@
package validation
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRunCompilesTargetsAfterTestAndVetInSelectionOrder(t *testing.T) {
logPath := installGoShim(t, "", false)
targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}}
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true, CompileTargets: targets},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var commands []string
for _, command := range readGoShimLog(t, logPath) {
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
commands = append(commands, command)
}
}
if len(commands) != len(targets)+2 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") {
t.Fatalf("validation commands = %q, want test, vet, then one build per target", commands)
}
for index, target := range targets {
command := commands[index+2]
if !strings.HasPrefix(command, "build ") {
t.Fatalf("command %d = %q, want package build", index+2, command)
}
for _, argument := range []string{"build", "-mod=readonly", "-o " + os.DevNull, "./..."} {
if !strings.Contains(" "+command+" ", " "+argument+" ") {
t.Errorf("build command %q does not contain %q", command, argument)
}
}
if got, want := strings.Fields(command)[0], "build"; got != want {
t.Errorf("command = %q, want %q", got, want)
}
if target.OS == "" || target.Arch == "" {
t.Fatalf("test target is incomplete: %#v", target)
}
}
environments, err := os.ReadFile(os.Getenv("RELEASEKIT_GO_ENV_LOG"))
if err != nil {
t.Fatal(err)
}
got := strings.Split(strings.TrimSpace(string(environments)), "\n")
if len(got) != len(targets) {
t.Fatalf("static build environments = %q, want %d entries", got, len(targets))
}
for index, target := range targets {
if want := fmt.Sprintf("%s/%s|0", target.OS, target.Arch); got[index] != want {
t.Errorf("static build environment %d = %q, want %q", index, got[index], want)
}
}
}
func TestRunAttributesTargetCompileFailureAndStops(t *testing.T) {
logPath := installGoShim(t, "darwin/arm64", false)
targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}, {OS: "windows", Arch: "amd64"}}
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true, CompileTargets: targets},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != PackageCompile {
t.Fatalf("Run() error = %v, want a package-compile failure", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Target != "darwin/arm64" {
t.Fatalf("package compilation findings = %#v, want target darwin/arm64", failure.Findings)
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("Run() error = %v, want the Go command failure preserved", err)
}
var commands []string
for _, command := range readGoShimLog(t, logPath) {
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
commands = append(commands, command)
}
}
if len(commands) != 4 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") || !strings.HasPrefix(commands[2], "build ") || !strings.HasPrefix(commands[3], "build ") {
t.Fatalf("validation commands after target failure = %q, want test, vet, two builds", commands)
}
}
func TestRunRejectsIncompleteCompileTargets(t *testing.T) {
installGoShim(t, "", false)
for _, target := range []Target{{OS: "linux"}, {Arch: "amd64"}, {OS: " ", Arch: "amd64"}, {OS: "linux/other", Arch: "amd64"}} {
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{CompileTargets: []Target{target}},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != PackageCompile {
t.Errorf("Run(%#v) error = %v, want a package-compile validation failure", target, err)
continue
}
if len(failure.Findings) != 1 || failure.Findings[0].Target != target.OS+"/"+target.Arch {
t.Errorf("Run(%#v) findings = %#v, want the incomplete target", target, failure.Findings)
}
}
}
func TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts(t *testing.T) {
fixtures := []struct {
name string
files map[string]string
}{
{
name: "source library",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
},
},
{
name: "single main",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
"cmd/app/main.go": "package main\n\nfunc main() {}\n",
},
},
{
name: "multiple mains with repeated basenames",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
"cmd/one/tool/main.go": "package main\n\nfunc main() {}\n",
"cmd/two/tool/main.go": "package main\n\nfunc main() {}\n",
},
},
}
for _, fixture := range fixtures {
t.Run(fixture.name, func(t *testing.T) {
module := writeCompileFixture(t, fixture.files)
before := readCompileFixture(t, module, fixture.files)
var targets []Target
if fixture.name == "source library" {
targets = []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}}
} else {
targets = []Target{{OS: "linux", Arch: "amd64"}}
}
if err := Run(context.Background(), Request{
RepositoryDir: module,
Settings: Settings{CompileTargets: targets},
}); err != nil {
t.Fatalf("Run() error = %v", err)
}
after := readCompileFixture(t, module, fixture.files)
for path, contents := range before {
if string(after[path]) != string(contents) {
t.Errorf("candidate file %q changed during compilation", path)
}
}
})
}
}
func writeCompileFixture(t *testing.T, files map[string]string) string {
t.Helper()
module := t.TempDir()
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/compilefixture\n\ngo 1.25.0\n"), 0o600); err != nil {
t.Fatal(err)
}
for path, contents := range files {
filename := filepath.Join(module, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
return module
}
func readCompileFixture(t *testing.T, module string, files map[string]string) map[string][]byte {
t.Helper()
contents := make(map[string][]byte, len(files)+1)
for _, path := range append([]string{"go.mod"}, fixturePaths(files)...) {
data, err := os.ReadFile(filepath.Join(module, filepath.FromSlash(path)))
if err != nil {
t.Fatal(err)
}
contents[path] = data
}
return contents
}
func fixturePaths(files map[string]string) []string {
paths := make([]string, 0, len(files))
for path := range files {
paths = append(paths, path)
}
return paths
}

View File

@@ -13,6 +13,8 @@ const (
GoTest Check = "go-test" GoTest Check = "go-test"
// GoVet identifies the separate full vet pass. // GoVet identifies the separate full vet pass.
GoVet Check = "go-vet" GoVet Check = "go-vet"
// PackageCompile identifies selected-target package compilation.
PackageCompile Check = "package-compile"
) )
// Finding describes a validation observation at an optional source location. // Finding describes a validation observation at an optional source location.

View File

@@ -213,6 +213,7 @@ func installGoShim(t *testing.T, failure string, delayVet bool) string {
} }
directory := t.TempDir() directory := t.TempDir()
logPath := filepath.Join(directory, "go-commands") logPath := filepath.Join(directory, "go-commands")
envLogPath := filepath.Join(directory, "go-environment")
settings := map[string]string{ settings := map[string]string{
"GOHOSTOS": runtime.GOOS, "GOHOSTOS": runtime.GOOS,
"GOHOSTARCH": runtime.GOARCH, "GOHOSTARCH": runtime.GOARCH,
@@ -250,8 +251,15 @@ if [ "${1-}" = "vet" ] && [ "%s" = "vet" ]; then
echo "Go vet failed" >&2 echo "Go vet failed" >&2
exit 2 exit 2
fi fi
if [ "${1-}" = "build" ]; then
printf '%%s/%%s|%%s\n' "${GOOS-}" "${GOARCH-}" "${CGO_ENABLED-}" >> "$RELEASEKIT_GO_ENV_LOG"
if [ "%s" = "build" ] || [ "%s" = "${GOOS-}/${GOARCH-}" ]; then
echo "Go build failed" >&2
exit 2
fi
fi
%s %s
`, failure, failure, failure, vetDelay) `, failure, failure, failure, failure, failure, vetDelay)
script := fmt.Sprintf(`#!/bin/sh script := fmt.Sprintf(`#!/bin/sh
set -eu set -eu
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
@@ -272,6 +280,7 @@ exit 0
} }
t.Setenv("PATH", path) t.Setenv("PATH", path)
t.Setenv("RELEASEKIT_GO_LOG", logPath) t.Setenv("RELEASEKIT_GO_LOG", logPath)
t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath)
return logPath return logPath
} }

View File

@@ -3,15 +3,25 @@ package validation
import ( import (
"context" "context"
"fmt"
"io" "io"
"os"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
) )
// Settings selects shared validation behavior. // Settings selects shared validation behavior.
type Settings struct { type Settings struct {
Race bool Race bool
FullVet bool FullVet bool
CompileTargets []Target
}
// Target selects one package-compilation target.
type Target struct {
OS string
Arch string
} }
// Request identifies the captured source and output used by shared validation. // Request identifies the captured source and output used by shared validation.
@@ -30,6 +40,17 @@ func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, e
// Run executes shared validation for the captured candidate. // Run executes shared validation for the captured candidate.
func Run(ctx context.Context, request Request) error { func Run(ctx context.Context, request Request) error {
for _, target := range request.Settings.CompileTargets {
if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") {
return &Failure{
Check: PackageCompile,
Findings: []Finding{{
Target: target.OS + "/" + target.Arch,
Message: "target must be one complete GOOS/GOARCH pair",
}},
}
}
}
tool, err := gotool.New(ctx, request.RepositoryDir) tool, err := gotool.New(ctx, request.RepositoryDir)
if err != nil { if err != nil {
return &Failure{Check: GoTest, Cause: err} return &Failure{Check: GoTest, Cause: err}
@@ -52,5 +73,20 @@ func Run(ctx context.Context, request Request) error {
return &Failure{Check: GoVet, Cause: err} return &Failure{Check: GoVet, Cause: err}
} }
} }
for _, target := range request.Settings.CompileTargets {
selected := gotool.Target{OS: target.OS, Arch: target.Arch}
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output,
"build", "-mod=readonly", "-o", os.DevNull, "./...")
if err != nil {
return &Failure{
Check: PackageCompile,
Findings: []Finding{{
Target: fmt.Sprintf("%s/%s", target.OS, target.Arch),
Message: "package compilation failed",
}},
Cause: err,
}
}
}
return nil return nil
} }

View File

@@ -50,8 +50,11 @@ type PublicationResult struct {
// Publish always runs one offline Go test suite for the captured candidate. // Publish always runs one offline Go test suite for the captured candidate.
// Validation.Go.Race enables the race detector for that suite, and // Validation.Go.Race enables the race detector for that suite, and
// Validation.Go.FullVet runs go vet afterward instead of test-time vet. It then // Validation.Go.FullVet runs go vet afterward instead of test-time vet, and
// calls AdditionalChecks when supplied. It optionally builds selected // Validation.Go.CompileTargets compiles all packages for each selected target
// with cgo disabled after tests and vet. Compilation produces no artifacts and
// does not execute target binaries. It then calls AdditionalChecks when
// supplied. It optionally builds selected
// targets in temporary storage outside the worktree and pushes the exact // 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

View File

@@ -8,7 +8,7 @@ import (
) )
// ValidationCheck identifies a shared validation check that failed. // ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test" and "go-vet". // Current checks include "go-test", "go-vet", and "package-compile".
type ValidationCheck string type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional // ValidationFinding describes a failed check observation at an optional

View File

@@ -9,11 +9,23 @@ import (
func TestCandidateRequestMapsGoValidation(t *testing.T) { func TestCandidateRequestMapsGoValidation(t *testing.T) {
request := candidateRequest(ReleaseRequest{ request := candidateRequest(ReleaseRequest{
Validation: ValidationSpec{Go: GoValidationSpec{Race: true, FullVet: true}}, Validation: ValidationSpec{Go: GoValidationSpec{
Race: true, FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
}},
}) })
if !request.Validation.Race || !request.Validation.FullVet { if !request.Validation.Race || !request.Validation.FullVet {
t.Fatalf("candidate request validation = %#v, want race and full vet enabled", request.Validation) t.Fatalf("candidate request validation = %#v, want race and full vet enabled", request.Validation)
} }
want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want)
}
for index := range want {
if request.Validation.CompileTargets[index] != want[index] {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want)
}
}
} }
func TestValidationErrorPreservesGoVetIdentity(t *testing.T) { func TestValidationErrorPreservesGoVetIdentity(t *testing.T) {