Preserve build errors and available failure evidence

This commit is contained in:
2026-10-10 14:05:39 +00:00
parent 7555d3e6c9
commit f059fef320
6 changed files with 78 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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