Rediscover Go settings only when Go work needs them

This commit is contained in:
2026-10-10 14:11:48 +00:00
parent 9333573cd8
commit c8eb64adec
8 changed files with 104 additions and 48 deletions

View File

@@ -64,7 +64,9 @@ Candidate checking captures and validates one clean committed source revision
in `internal/candidate`. Its validation bridge calls `internal/validation` for in `internal/candidate`. Its validation bridge calls `internal/validation` for
the selected module's default offline Go suite, then runs product-specific the selected module's default offline Go suite, then runs product-specific
checks once. Validation and its post-validation build callback share one local checks once. Validation and its post-validation build callback share one local
Go settings owner; the candidate package owns those builds and local-state Go settings owner. Each Go acquisition checks inherited and persistent inputs;
settings are rediscovered when subsequent Go work needs them. An unavailable
persistent-settings snapshot disables reuse. The candidate package owns builds and local-state
rechecks; publication owns endpoint guards, tag creation, push, and recovery rechecks; publication owns endpoint guards, tag creation, push, and recovery
around that same candidate validation path. `internal/validation.CandidateTree` reads around that same candidate validation path. `internal/validation.CandidateTree` reads
committed blobs lazily and resolves local destinations from committed entries; committed blobs lazily and resolves local destinations from committed entries;

View File

@@ -157,7 +157,15 @@ a genuinely different repository or module must still fail before hosted work.
## Issue 5: Keep Go environment reuse lazy and proportionate (P2) ## Issue 5: Keep Go environment reuse lazy and proportionate (P2)
Status: open. Category: additional validation gates and unrequested restrictions. Status: complete. Category: additional validation gates and unrequested restrictions.
**Completion:** Go discovery happens on acquisition for actual Go work. Completed
callbacks trigger no additional discovery; settings-file snapshot failures disable
reuse while preserving the Go tool's accepted configurations. Regression tests
cover check/publication completion after Go leaves PATH, subsequent builds needing
rediscovery, the final callback, unreadable GOENV snapshots, normal environment
and persistent-file changes, and cancellation. Focused public cases and complete
candidate and Go-tool suites pass offline.
**Evidence:** [candidate coordination](../../internal/candidate/check.go) and **Evidence:** [candidate coordination](../../internal/candidate/check.go) and
[publication](../../internal/publish/publish.go) eagerly call [publication](../../internal/publish/publish.go) eagerly call

View File

@@ -53,13 +53,9 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
} }
if request.AfterValidate != nil { if request.AfterValidate != nil {
callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner) callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner)
refreshErr := request.ToolOwner.Refresh(ctx)
if callbackErr != nil { if callbackErr != nil {
return captured, fmt.Errorf("after candidate validation: %w", callbackErr) return captured, fmt.Errorf("after candidate validation: %w", callbackErr)
} }
if refreshErr != nil {
return captured, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr)
}
} }
if err := Recheck(ctx, captured, ""); err != nil { if err := Recheck(ctx, captured, ""); err != nil {
return captured, err return captured, err
@@ -143,13 +139,9 @@ func Validate(ctx context.Context, request Request, captured Candidate) error {
} }
if request.AdditionalChecks != nil { if request.AdditionalChecks != nil {
callbackErr := request.AdditionalChecks(ctx, captured) callbackErr := request.AdditionalChecks(ctx, captured)
refreshErr := owner.Refresh(ctx)
if callbackErr != nil { if callbackErr != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr) return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr)
} }
if refreshErr != nil {
return fmt.Errorf("refresh Go settings after additional checks: %w", refreshErr)
}
} }
return nil return nil
} }

View File

@@ -304,6 +304,28 @@ func TestCheckRefreshesGoSettingsAfterAdditionalChecks(t *testing.T) {
} }
} }
func TestCheckNeedsNoGoDiscoveryAfterFinalCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
gitOnly := t.TempDir()
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
t.Fatal(err)
}
_, err = Check(context.Background(), Request{
RepositoryDir: repository, Product: "Example Product", Version: testVersion,
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
t.Setenv("PATH", gitOnly)
return nil
},
})
if err != nil {
t.Fatalf("completed Go work was rediscovered after final callback: %v", err)
}
}
func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) { func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("the controlled Go command fixture uses a POSIX shell") t.Skip("the controlled Go command fixture uses a POSIX shell")

View File

@@ -320,9 +320,6 @@ func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) {
cache := filepath.Join(t.TempDir(), "changed-cache") cache := filepath.Join(t.TempDir(), "changed-cache")
t.Setenv("GOCACHE", cache) t.Setenv("GOCACHE", cache)
if err := owner.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
third, err := owner.Acquire(context.Background()) third, err := owner.Acquire(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -332,6 +329,24 @@ func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) {
} }
} }
func TestOwnerAcceptsGoConfigurationWithoutReadableSnapshot(t *testing.T) {
clearGoSettings(t)
t.Setenv("GOENV", t.TempDir())
directory := t.TempDir()
if _, err := New(context.Background(), directory); err != nil {
t.Fatalf("underlying Go discovery rejected fixture: %v", err)
}
owner := NewOwner(directory)
first, err := owner.Acquire(context.Background())
if err != nil {
t.Fatalf("owner added a snapshot prerequisite: %v", err)
}
second, err := owner.Acquire(context.Background())
if err != nil || first == second {
t.Fatalf("unavailable snapshot should rediscover on demand: first=%p second=%p error=%v", first, second, err)
}
}
func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) { func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) {
clearGoSettings(t) clearGoSettings(t)
firstCache := filepath.Join(t.TempDir(), "first-cache") firstCache := filepath.Join(t.TempDir(), "first-cache")
@@ -355,9 +370,6 @@ func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) {
t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache) t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache)
} }
writeSettings(secondCache) writeSettings(secondCache)
if err := owner.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
second, err := owner.Acquire(context.Background()) second, err := owner.Acquire(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -19,6 +18,7 @@ type Owner struct {
file string file string
contents []byte contents []byte
fileFound bool fileFound bool
snapshotAvailable bool
} }
// NewOwner creates an operation-local Go tool owner without running Go. // NewOwner creates an operation-local Go tool owner without running Go.
@@ -29,7 +29,7 @@ func NewOwner(directory string) *Owner {
// Acquire returns the operation's Go tool, rediscovering settings when their // Acquire returns the operation's Go tool, rediscovering settings when their
// process or persistent inputs have changed. // process or persistent inputs have changed.
func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) { func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) {
if owner.tool != nil { if owner.tool != nil && owner.snapshotAvailable {
changed, err := owner.inputsChanged() changed, err := owner.inputsChanged()
if err == nil && !changed { if err == nil && !changed {
return owner.tool, nil return owner.tool, nil
@@ -41,38 +41,24 @@ func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) {
return owner.tool, nil return owner.tool, nil
} }
// Refresh checks the owner's inputs after a consumer callback and rediscovers
// settings when the callback changed the environment or persistent file.
func (owner *Owner) Refresh(ctx context.Context) error {
if owner.tool == nil {
return nil
}
changed, err := owner.inputsChanged()
if err == nil && !changed {
return nil
}
return owner.discover(ctx)
}
func (owner *Owner) discover(ctx context.Context) error { func (owner *Owner) discover(ctx context.Context) error {
environment := inheritedEnvironment() environment := inheritedEnvironment()
tool, err := New(ctx, owner.directory) tool, err := New(ctx, owner.directory)
if err != nil { if err != nil {
return err return err
} }
settingsFile, err := resolveSettingsFile(owner.directory, tool.settingsFile) settingsFile, snapshotErr := resolveSettingsFile(owner.directory, tool.settingsFile)
if err != nil { var contents []byte
return err var found bool
} if snapshotErr == nil {
contents, found, err := readSettingsFile(settingsFile) contents, found, snapshotErr = readSettingsFile(settingsFile)
if err != nil {
return fmt.Errorf("read Go settings file %q: %w", settingsFile, err)
} }
owner.tool = tool owner.tool = tool
owner.env = environment owner.env = environment
owner.file = settingsFile owner.file = settingsFile
owner.contents = contents owner.contents = contents
owner.fileFound = found owner.fileFound = found
owner.snapshotAvailable = snapshotErr == nil
return nil return nil
} }

View File

@@ -127,13 +127,9 @@ func Publish(ctx context.Context, request Request) (Result, error) {
} }
if request.AfterValidate != nil { if request.AfterValidate != nil {
callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner) callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner)
refreshErr := checks.ToolOwner.Refresh(ctx)
if callbackErr != nil { if callbackErr != nil {
return result, fmt.Errorf("after candidate validation: %w", callbackErr) return result, fmt.Errorf("after candidate validation: %w", callbackErr)
} }
if refreshErr != nil {
return result, fmt.Errorf("refresh Go settings after candidate callback: %w", refreshErr)
}
} }
if err := candidate.Recheck(ctx, captured, ""); err != nil { if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("post-validation local guard: %w", err) return result, fmt.Errorf("post-validation local guard: %w", err)

View File

@@ -18,6 +18,44 @@ import (
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n" const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
func TestCandidateOperationsDiscoverGoOnlyWhenLaterWorkNeedsIt(t *testing.T) {
gitPath := mustLookPath(t, "git")
gitOnly := t.TempDir()
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
t.Fatal(err)
}
for _, operation := range []string{"check", "publish"} {
for _, withBuild := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/build=%t", operation, withBuild), func(t *testing.T) {
repo := newPublishFixture(t)
request := releasekit.ReleaseRequest{
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
t.Setenv("PATH", gitOnly)
return nil
},
}
if withBuild {
request.Build = &releasekit.BuildSpec{MainPackage: ".", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}}
}
var err error
if operation == "check" {
_, err = releasekit.Check(context.Background(), request)
} else {
_, err = releasekit.Publish(context.Background(), request)
}
if withBuild {
if !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("later build did not rediscover changed Go inputs: %v", err)
}
} else if err != nil {
t.Fatalf("finished Go work added a discovery gate: %v", err)
}
})
}
}
}
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass") wrapper := installGitWrapper(t, repo, "pass")