diff --git a/build.go b/build.go index 9b7265f..d27c237 100644 --- a/build.go +++ b/build.go @@ -54,7 +54,7 @@ type BuildResult struct { } // Build compiles the selected targets with Version injected through buildinfo. -// It uses the prepared local Go environment, preserves existing output files, +// It uses the prepared local Go environment, regenerates selected output files while preserving unrelated files, // and verifies the version command on the native host before succeeding. An // empty target set records source identity without running the Go toolchain. // Errors return partial build facts in the result. diff --git a/build_test.go b/build_test.go index 30fef48..6117bf2 100644 --- a/build_test.go +++ b/build_test.go @@ -152,38 +152,45 @@ func main() {} } } -func TestBuildRejectsUnsafeNameAndPreservesExistingOutput(t *testing.T) { - repository := newBuildConsumer(t, "package main\nfunc main() {}\n") - outputDir := filepath.Join(t.TempDir(), "artifacts") - if err := os.MkdirAll(outputDir, 0o755); err != nil { +func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) { + const source = `package main +import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") +func main(){fmt.Println("fixture",buildinfo.Version())} +` + repository := newBuildConsumer(t, source) + outputDir := t.TempDir() + unrelated := filepath.Join(outputDir, "keep.txt") + if err := os.WriteFile(unrelated, []byte("keep this file"), 0600); err != nil { t.Fatal(err) } - ownedPath := filepath.Join(outputDir, "fixture-v1.2.3-linux-amd64") - if err := os.WriteFile(ownedPath, []byte("keep this file"), 0o600); err != nil { + request := releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3", Spec: releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}, OutputDir: outputDir} + unsafe := request + unsafe.Spec.BinaryName = "../unsafe" + if _, err := releasekit.Build(context.Background(), unsafe); err == nil { + t.Fatal("unsafe binary name accepted") + } + first, err := releasekit.Build(context.Background(), request) + if err != nil { t.Fatal(err) } - request := releasekit.BuildRequest{ - RepositoryDir: repository, - Version: "v1.2.3", - Spec: releasekit.BuildSpec{ - MainPackage: "./cmd/app", - BinaryName: "fixture", - Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}, - }, - OutputDir: outputDir, + repeated, err := releasekit.Build(context.Background(), request) + if err != nil { + t.Fatal(err) } - unsafeRequest := request - unsafeRequest.Spec.BinaryName = "../unsafe" - if result, err := releasekit.Build(context.Background(), unsafeRequest); err == nil || !strings.Contains(err.Error(), "unsafe binary name") { - t.Fatalf("Build() result = %#v, error = %v; expected unsafe-name failure", result, err) + if repeated.Artifacts[0].SHA256 != first.Artifacts[0].SHA256 { + t.Fatal("identical build changed output") } - request.Spec.BinaryName = "fixture" - if _, err := releasekit.Build(context.Background(), request); err == nil || !strings.Contains(err.Error(), "already exists") { - t.Fatalf("Build() error = %v, want existing-output failure", err) + writeBuildFixture(t, repository, "cmd/app/main.go", source+"\nfunc init(){if len([]byte(buildinfo.Version()))==0 {panic(\"changed build\")}}\n") + changed, err := releasekit.Build(context.Background(), request) + if err != nil { + t.Fatal(err) } - contents, err := os.ReadFile(ownedPath) - if err != nil || string(contents) != "keep this file" { - t.Fatalf("caller output = %q, error = %v", contents, err) + if changed.Artifacts[0].SHA256 == first.Artifacts[0].SHA256 { + t.Fatal("changed source did not regenerate binary") + } + content, err := os.ReadFile(unrelated) + if err != nil || string(content) != "keep this file" { + t.Fatalf("unrelated file=%q %v", content, err) } } diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index 96d3dea..d0fa759 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -266,3 +266,15 @@ func validSHA256(value string) bool { } return true } + +// RemoveBuildReceipt invalidates prior CLI coordination data after a rebuild. +func RemoveBuildReceipt(outputDir string) error { + err := os.Remove(filepath.Join(outputDir, BuildReceiptName)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("invalidate prior build receipt: %w", err) + } + return nil +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 4ff5cf4..0ca5977 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -100,6 +100,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { OutputDir: invocation.OutputDir, Output: stderr, }) + if result.OutputDir != "" && len(result.Targets) != 0 { + if receiptErr := cli.RemoveBuildReceipt(result.OutputDir); receiptErr != nil { + fmt.Fprintf(stderr, "%v\n", receiptErr) + return 1 + } + } if err != nil { fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err) for _, artifact := range result.Artifacts { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index b4ef570..9256f07 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -737,3 +737,28 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) { }) } } + +func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) { + const source = `package main +import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") +func main(){fmt.Println("fixture",buildinfo.Version())} +` + repository := newCLIBuildRepository(t, source) + var stdout, stderr bytes.Buffer + args := []string{"build", "--repo", repository, "v1.2.3"} + for attempt := 0; attempt < 2; attempt++ { + if status := run(context.Background(), args, &stdout, &stderr); status != 0 { + t.Fatalf("build %d: status=%d %s", attempt, status, stderr.String()) + } + } + path := filepath.Join(repository, "cmd/app/main.go") + if err := os.WriteFile(path, []byte("package main\nfunc main(){}\n"), 0600); err != nil { + t.Fatal(err) + } + if status := run(context.Background(), args, &stdout, &stderr); status != 1 { + t.Fatalf("failed rebuild status=%d", status) + } + if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !os.IsNotExist(err) { + t.Fatalf("stale receipt remains: %v", err) + } +} diff --git a/docs/cli.md b/docs/cli.md index 9e3fa66..4cc393d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -60,10 +60,11 @@ go run ./cmd/releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSIO targets with the requested version injected, checks the native `version` invocation, and writes binaries plus the private `.releasekit-build.json` receipt into `dist` by default. `--output` selects a different directory; -relative paths are resolved from the repository. Existing files with a selected -artifact name are preserved and cause an error. A complete nonempty build +relative paths are resolved from the repository. Selected artifact filenames +are regenerated while unrelated files are preserved. A complete nonempty build writes a receipt only after compilation and smoke checking succeed. On partial -failure, standard error lists completed outputs and no receipt is written. An +failure, standard error lists completed outputs and prior receipts in that +output directory are invalidated; no new receipt is written. An empty target list returns the source commit without building or writing a receipt. diff --git a/docs/consumers/build.md b/docs/consumers/build.md index d7d5da2..b07984d 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -46,9 +46,8 @@ source and the same HEAD before and after compilation; binary delivery requires that fact. This observation is not a source snapshot or attestation guarantee. The default output directory is `/dist`; a relative `OutputDir` is -resolved from the repository. Existing files with a selected artifact name are -preserved and cause an error. Other files in the output directory are left in -place. A failed later build or native version check returns the binaries already +resolved from the repository. Selected artifact filenames are regenerated. Other files in the output +directory are left in place. A failed later build or native version check returns the binaries already generated in `BuildResult.Artifacts`. Builds use the prepared local Go toolchain, readonly module mode, disabled diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index be1bad7..50dd507 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -163,6 +163,8 @@ write outcomes. Both affected suites pass. ### R4 — Medium: Local artifact regeneration is prohibited +Status: Resolved. + Category: Inadvertently strengthened prohibition. **Evidence:** [Build](../../internal/build/build.go), around line 114, refuses @@ -188,6 +190,12 @@ machinery. a changed local build can regenerate its owned names; unrelated files survive; a partial rebuild does not authorize incomplete assets through a stale receipt. +**Resolution:** Builds regenerate selected local artifact names and preserve +unrelated output files. The CLI invalidates prior receipts once a rebuild has +reached its output directory, including failed rebuilds. API regressions cover +repeated and changed-source builds; CLI coverage proves a failed smoke check +removes the previous receipt. Affected suites pass. + ### R5 — Medium: The root facade still owns release mechanics Category: Architectural ownership gap and maintainability. diff --git a/internal/build/build.go b/internal/build/build.go index 090660f..d7d95fc 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -114,14 +114,6 @@ func Build(ctx context.Context, request Request) (Result, error) { if err := os.MkdirAll(result.OutputDir, 0o755); err != nil { return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) } - for _, filename := range filenames { - path := filepath.Join(result.OutputDir, filename) - if _, err := os.Lstat(path); err == nil { - return result, fmt.Errorf("build output %q already exists; preserving caller-owned file", path) - } else if !errors.Is(err, os.ErrNotExist) { - return result, fmt.Errorf("inspect build output %q: %w", path, err) - } - } compiler, err := runGo(ctx, repositoryDir, nil, "version") if err != nil {