From 6f2e62bea35d38898883e6506ee24c3ba939310c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 03:22:07 +0000 Subject: [PATCH] Report only fully hashed build artifacts --- build.go | 5 +-- build_test.go | 55 +++++++++++++++++++++++++++++++ cmd/releasekit/main_test.go | 60 ++++++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 2 +- internal/build/build.go | 18 ++++------ 5 files changed, 123 insertions(+), 17 deletions(-) diff --git a/build.go b/build.go index 55f75d1..2407cad 100644 --- a/build.go +++ b/build.go @@ -145,10 +145,7 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec { func internalBuildResult(result BuildResult) *internalbuild.Result { input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets}) settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets) - converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler} - for _, target := range settings.Targets { - converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) - } + converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, OutputDir: result.OutputDir, Compiler: result.Compiler} for _, artifact := range result.Artifacts { converted.Artifacts = append(converted.Artifacts, internalbuild.Artifact{Path: artifact.Path, Filename: artifact.Filename, Target: internalbuild.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256}) } diff --git a/build_test.go b/build_test.go index 1e6df58..3461777 100644 --- a/build_test.go +++ b/build_test.go @@ -521,6 +521,61 @@ func TestBuildReportsMissingMainPackage(t *testing.T) { } } +func TestBuildRetainsOnlyHashedArtifactsAfterReadFailure(t *testing.T) { + repository := newBuildConsumer(t, "package main\nfunc main() {}\n") + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + count := filepath.Join(t.TempDir(), "count") + script := `#!/bin/sh +if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi +output= +previous= +for arg in "$@"; do + if [ "$previous" = -o ]; then output=$arg; fi + previous=$arg +done +"$RELEASEKIT_REAL_GO" "$@" || exit $? +if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then + rm "$output" + mkdir "$output" +else + : > "$RELEASEKIT_BUILD_COUNT" +fi +` + if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_REAL_GO", realGo) + t.Setenv("RELEASEKIT_BUILD_COUNT", count) + secondArch := "arm64" + if runtime.GOARCH == secondArch { + secondArch = "amd64" + } + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, Version: "v1.2.3", OutputDir: t.TempDir(), + Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, {OS: runtime.GOOS, Arch: secondArch}}}, + }) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), "hash build output") || len(result.Artifacts) != 1 || len(result.Targets) != 2 || result.MainPackage != "./cmd/app" || result.BinaryName != "fixture" || result.CandidateCommit == "" || result.SourceVerified { + t.Fatalf("partial build = %#v, error = %v", result, err) + } + contents, readErr := os.ReadFile(result.Artifacts[0].Path) + if readErr != nil { + t.Fatal(readErr) + } + digest := sha256.Sum256(contents) + if result.Artifacts[0].SHA256 != hex.EncodeToString(digest[:]) { + t.Fatalf("completed artifact digest = %q", result.Artifacts[0].SHA256) + } + if info, statErr := os.Stat(pathErr.Path); statErr != nil || !info.IsDir() { + t.Fatalf("failed output was not retained: %v", statErr) + } +} + func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) { repository := newBuildConsumer(t, `package main import ( diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index b8ccfda..14c1e2f 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -588,6 +589,65 @@ func main() { } } +func TestBuildCommandReportsOnlyHashedArtifactsAfterReadFailure(t *testing.T) { + repository := newCLIBuildRepository(t, "package main\nfunc main() {}\n") + secondArch := "arm64" + if runtime.GOARCH == secondArch { + secondArch = "amd64" + } + settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []string{runtime.GOOS + "/" + runtime.GOARCH, runtime.GOOS + "/" + secondArch}}} + profile, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, "releasekit.json", string(profile)) + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + script := `#!/bin/sh +if [ "$1" != build ]; then exec "$RELEASEKIT_REAL_GO" "$@"; fi +output= +previous= +for arg in "$@"; do + if [ "$previous" = -o ]; then output=$arg; fi + previous=$arg +done +"$RELEASEKIT_REAL_GO" "$@" || exit $? +if [ -f "$RELEASEKIT_BUILD_COUNT" ]; then + rm "$output" + mkdir "$output" +else + : > "$RELEASEKIT_BUILD_COUNT" +fi +` + if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_REAL_GO", realGo) + t.Setenv("RELEASEKIT_BUILD_COUNT", filepath.Join(t.TempDir(), "count")) + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr) + first := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH) + second := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, secondArch) + if runtime.GOOS == "windows" { + first, second = first+".exe", second+".exe" + } + content, err := os.ReadFile(filepath.Join(repository, "dist", first)) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(content) + if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), fmt.Sprintf("built %s %x", first, digest)) || strings.Contains(stderr.String(), "built "+second) || !strings.Contains(stderr.String(), "hash build output") || !strings.Contains(stderr.String(), second) { + t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial build left receipt: %v", err) + } +} + func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) { repository := newCLIBuildRepository(t, `package main import ( diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 05b97be..caf1e18 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -43,7 +43,7 @@ the agent policy; an intermediate compilation dependency is recorded accurately. | 5 | A03, A18: piped lifetime and queued cancellation | Complete — process and Git lifecycle tests passed under the race detector; public preparation cancellation passed. | | 6 | A04 | Complete — Git display, endpoint change, public mismatch, CLI, publication, and Git failure regressions passed. | | 7 | A05 | Complete — real-repository inspection failures preserve the local ref, available object ID, and Git cause without a push. | -| 8 | A06, A14 | Pending | +| 8 | A06, A14 | Complete — public build and prebuilt-delivery checks and CLI receipt/partial-artifact regressions passed. | | 9 | A16 | Pending | | 10 | A07: allocation and temporary metadata | Pending | | 11 | A07: build and delivery outcomes | Pending | diff --git a/internal/build/build.go b/internal/build/build.go index 28b25f2..bfc4e77 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -50,10 +50,7 @@ type Result struct { SourceVerified bool Settings artifact.Settings Version string - MainPackage string - BinaryName string OutputDir string - Targets []Target Artifacts []Artifact Compiler string } @@ -68,9 +65,6 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { Tag: identity.Tag, Settings: settings, Version: identity.Version, - MainPackage: settings.MainPackage, - BinaryName: settings.BinaryName, - Targets: settings.Targets, } if !versioncontract.ValidStableVersion(identity.Version) { return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version) @@ -78,14 +72,14 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" { return result, errors.New("build requires a resolved module identity") } - if len(result.Targets) != 0 { + if len(settings.Targets) != 0 { if settings.MainPackage == "" { return result, errors.New("build requires a main package when targets are selected") } if !artifact.ValidComponent(settings.BinaryName) { return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName) } - for _, target := range result.Targets { + for _, target := range settings.Targets { if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) { return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch) } @@ -114,7 +108,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("inspect build source: %w", err) } - if len(result.Targets) == 0 { + if len(settings.Targets) == 0 { result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir) return result, nil } @@ -145,7 +139,7 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { goHost := goTool.HostTarget() host := Target{OS: goHost.OS, Arch: goHost.Arch} - for index, target := range result.Targets { + for index, target := range settings.Targets { if err := ctx.Err(); err != nil { return result, fmt.Errorf("build canceled: %w", err) } @@ -158,10 +152,10 @@ func BuildResolved(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err) } artifact := Artifact{Path: path, Filename: filename, Target: target} - result.Artifacts = append(result.Artifacts, artifact) - if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil { + if err := hashArtifact(&artifact); err != nil { return result, fmt.Errorf("hash build output %q: %w", path, err) } + result.Artifacts = append(result.Artifacts, artifact) } var smokePath string