From f059fef320e02b3e944c7e2572fa1aba5d10bff3 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 14:05:39 +0000 Subject: [PATCH] Preserve build errors and available failure evidence --- build.go | 30 ++++++++++++++++++++------- build_test.go | 23 ++++++++++++++++++++ cmd/releasekit/internal/cli/config.go | 3 +++ cmd/releasekit/repository_test.go | 9 ++++++++ docs/roadmap/remediation.md | 9 +++++++- internal/releaseidentity/identity.go | 21 +++++++++++-------- 6 files changed, 78 insertions(+), 17 deletions(-) diff --git a/build.go b/build.go index 7b72504..4036663 100644 --- a/build.go +++ b/build.go @@ -2,12 +2,14 @@ package releasekit import ( "context" + "fmt" "io" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) // Target identifies one explicit Go operating-system and architecture pair. @@ -71,22 +73,36 @@ type BuildResult struct { // empty target set records source identity without running the Go toolchain. // Errors return partial build facts in the result. func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { - public := BuildResult{Version: request.Version} + spec := *internalBuildSpec(&request.Spec) + result := internalbuild.Result{Version: request.Version} + failure := func(err error) (BuildResult, error) { + result.Settings = artifact.NormalizeSettings(spec.MainPackage, spec.BinaryName, spec.Targets) + return publicBuildResult(result), err + } + if !versioncontract.ValidStableVersion(request.Version) { + return failure(fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)) + } repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir) if err != nil { - return public, err + return failure(err) } + result.RepositoryDir = repositoryDir identity, err := releaseidentity.FromWorkingTree(ctx, repositoryDir, request.ModuleDir, request.Version) + result.ModuleDir, result.ModulePath, result.Tag = identity.ModuleDir, identity.ModulePath, identity.Tag if err != nil { - return public, err + return failure(err) } - result, err := internalbuild.BuildResolved(ctx, internalbuild.Request{ + result, err = internalbuild.BuildResolved(ctx, internalbuild.Request{ Identity: identity, - Spec: *internalBuildSpec(&request.Spec), + Spec: spec, OutputDir: request.OutputDir, Output: request.Output, }) - public = BuildResult{ + return publicBuildResult(result), err +} + +func publicBuildResult(result internalbuild.Result) BuildResult { + public := BuildResult{ RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, @@ -112,7 +128,7 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { SHA256: artifact.SHA256, }) } - return public, err + return public } func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec { diff --git a/build_test.go b/build_test.go index 4d976be..8e35e8b 100644 --- a/build_test.go +++ b/build_test.go @@ -18,6 +18,29 @@ import ( releasekit "gitea.maximumdirect.net/eric/ReleaseKit" ) +func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) { + spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}} + invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: filepath.Join(t.TempDir(), "absent"), Version: "bad-version", Spec: spec, + }) + if !errors.Is(err, releasekit.ErrInvalidVersion) || invalid.Version != "bad-version" || invalid.MainPackage != spec.MainPackage || invalid.BinaryName != spec.BinaryName || len(invalid.Targets) != 1 { + t.Fatalf("invalid version result = %#v, error = %v", invalid, err) + } + repo := newPublishFixture(t) + writeFile(t, repo.work, "nested/go.mod", "go 1.26.0\n") + failed, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repo.work, ModuleDir: "nested", Version: "v1.2.3", Spec: spec, + }) + if err == nil || failed.RepositoryDir != repo.work || failed.ModuleDir != filepath.Join(repo.work, "nested") || failed.Version != "v1.2.3" || failed.MainPackage != spec.MainPackage || failed.BinaryName != spec.BinaryName || len(failed.Targets) != 1 || failed.SourceVerified { + t.Fatalf("declaration failure = %#v, error = %v", failed, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := releasekit.Build(ctx, releasekit.BuildRequest{RepositoryDir: repo.work, Version: "v1.2.3", Spec: spec}); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled build error = %v", err) + } +} + func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) { repository := newBuildConsumer(t, `package main diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index dc8842a..e95aefa 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -105,6 +105,9 @@ func Parse(args []string) (Invocation, error) { return Invocation{}, errors.New("ci-deliver TAG must end in a stable vMAJOR.MINOR.PATCH version") } } + if !versioncontract.ValidStableVersion(version) { + return Invocation{}, errors.New("VERSION must be a stable vMAJOR.MINOR.PATCH version") + } parsedRemote := "" if remote != nil { parsedRemote = *remote diff --git a/cmd/releasekit/repository_test.go b/cmd/releasekit/repository_test.go index 5a5fd14..5890b1c 100644 --- a/cmd/releasekit/repository_test.go +++ b/cmd/releasekit/repository_test.go @@ -23,3 +23,12 @@ func TestNativeCheckFromChildUsesRootProfileAndSuite(t *testing.T) { t.Fatalf("child invocation error = %v, output = %s", err, output) } } + +func TestNativeBuildClassifiesInvalidVersionBeforeSourceAccess(t *testing.T) { + binary := buildWorkflowCLI(t, cliRepositoryRoot(t)) + command := exec.Command(binary, "build", "--repo", filepath.Join(t.TempDir(), "absent"), "bad-version") + output, err := command.CombinedOutput() + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 || !strings.Contains(string(output), "stable vMAJOR.MINOR.PATCH") { + t.Fatalf("invalid build error = %v, output = %s", err, output) + } +} diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index fff8ff5..6d0c4d6 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -64,7 +64,14 @@ independent worktree behavior. ## Issue 2: Preserve standalone Build error classification and partial evidence (P2) -Status: open. Category: regression and incomplete failure evidence. +Status: complete. Category: regression and incomplete failure evidence. + +**Completion:** Standalone builds classify invalid versions before source access +and retain resolved selection and normalized build settings on identity failures. +Working-tree derivation preserves progressive identity evidence. Native CLI +validation reports invalid-version status 2 before repository access. Focused +API, native CLI, parser, identity, cancellation, and partial-artifact tests pass +offline. **Evidence:** [public Build](../../build.go) now calls working-tree identity resolution before the internal builder. Invalid stable versions are returned by diff --git a/internal/releaseidentity/identity.go b/internal/releaseidentity/identity.go index c3a158f..a512ece 100644 --- a/internal/releaseidentity/identity.go +++ b/internal/releaseidentity/identity.go @@ -99,16 +99,17 @@ func FromCommittedSelection(ctx context.Context, selection Selection, version, c // working tree. The selected module must contain its own go.mod. func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version string) (Identity, error) { selection, err := ResolveSelection(repositoryDir, moduleDir) + identity := Identity{Selection: selection, Version: version} if err != nil { - return Identity{}, err + return identity, err } if err := ctx.Err(); err != nil { - return Identity{}, fmt.Errorf("resolve working-tree release identity: %w", err) + return identity, fmt.Errorf("resolve working-tree release identity: %w", err) } declarationPath := filepath.Join(selection.ModuleDir, "go.mod") declaration, err := os.ReadFile(declarationPath) if err != nil { - return Identity{}, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err) + return identity, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err) } return Derive(selection, declaration, version) } @@ -116,25 +117,27 @@ func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version stri // Derive applies Go module path-major rules and derives the standard full tag // and module-local release-note path from a declaration and selection. func Derive(selection Selection, declaration []byte, version string) (Identity, error) { + identity := Identity{Selection: selection, Version: version} if selection.RepositoryDir == "" || selection.ModuleDir == "" || selection.ModuleSubdir == "" { - return Identity{}, fmt.Errorf("incomplete module selection") + return identity, fmt.Errorf("incomplete module selection") } modulePath := modfile.ModulePath(declaration) if modulePath == "" { - return Identity{}, fmt.Errorf("selected module declaration has no valid module directive") + return identity, fmt.Errorf("selected module declaration has no valid module directive") } + identity.ModulePath = modulePath if err := module.CheckPath(modulePath); err != nil { - return Identity{}, fmt.Errorf("invalid module path %q: %w", modulePath, err) + return identity, fmt.Errorf("invalid module path %q: %w", modulePath, err) } _, pathMajor, ok := module.SplitPathVersion(modulePath) if !ok { - return Identity{}, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath) + return identity, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath) } if !versioncontract.ValidStableVersion(version) { - return Identity{}, fmt.Errorf("invalid stable release version %q", version) + return identity, fmt.Errorf("invalid stable release version %q", version) } if err := module.CheckPathMajor(version, pathMajor); err != nil { - return Identity{}, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err) + return identity, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err) } tagPrefix := selection.ModuleSubdir