Regenerate local artifacts and invalidate stale receipts

This commit is contained in:
2026-10-06 00:56:44 +00:00
parent b744346210
commit b4d348dd1f
9 changed files with 90 additions and 40 deletions

View File

@@ -54,7 +54,7 @@ type BuildResult struct {
} }
// Build compiles the selected targets with Version injected through buildinfo. // 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 // and verifies the version command on the native host before succeeding. An
// empty target set records source identity without running the Go toolchain. // empty target set records source identity without running the Go toolchain.
// Errors return partial build facts in the result. // Errors return partial build facts in the result.

View File

@@ -152,38 +152,45 @@ func main() {}
} }
} }
func TestBuildRejectsUnsafeNameAndPreservesExistingOutput(t *testing.T) { func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
repository := newBuildConsumer(t, "package main\nfunc main() {}\n") const source = `package main
outputDir := filepath.Join(t.TempDir(), "artifacts") import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
if err := os.MkdirAll(outputDir, 0o755); err != nil { 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) t.Fatal(err)
} }
ownedPath := filepath.Join(outputDir, "fixture-v1.2.3-linux-amd64") 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}
if err := os.WriteFile(ownedPath, []byte("keep this file"), 0o600); err != nil { 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) t.Fatal(err)
} }
request := releasekit.BuildRequest{ repeated, err := releasekit.Build(context.Background(), request)
RepositoryDir: repository, if err != nil {
Version: "v1.2.3", t.Fatal(err)
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}},
},
OutputDir: outputDir,
} }
unsafeRequest := request if repeated.Artifacts[0].SHA256 != first.Artifacts[0].SHA256 {
unsafeRequest.Spec.BinaryName = "../unsafe" t.Fatal("identical build changed output")
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)
} }
request.Spec.BinaryName = "fixture" writeBuildFixture(t, repository, "cmd/app/main.go", source+"\nfunc init(){if len([]byte(buildinfo.Version()))==0 {panic(\"changed build\")}}\n")
if _, err := releasekit.Build(context.Background(), request); err == nil || !strings.Contains(err.Error(), "already exists") { changed, err := releasekit.Build(context.Background(), request)
t.Fatalf("Build() error = %v, want existing-output failure", err) if err != nil {
t.Fatal(err)
} }
contents, err := os.ReadFile(ownedPath) if changed.Artifacts[0].SHA256 == first.Artifacts[0].SHA256 {
if err != nil || string(contents) != "keep this file" { t.Fatal("changed source did not regenerate binary")
t.Fatalf("caller output = %q, error = %v", contents, err) }
content, err := os.ReadFile(unrelated)
if err != nil || string(content) != "keep this file" {
t.Fatalf("unrelated file=%q %v", content, err)
} }
} }

View File

@@ -266,3 +266,15 @@ func validSHA256(value string) bool {
} }
return true 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
}

View File

@@ -100,6 +100,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
OutputDir: invocation.OutputDir, OutputDir: invocation.OutputDir,
Output: stderr, 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 { if err != nil {
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err) fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err)
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {

View File

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

View File

@@ -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` targets with the requested version injected, checks the native `version`
invocation, and writes binaries plus the private `.releasekit-build.json` invocation, and writes binaries plus the private `.releasekit-build.json`
receipt into `dist` by default. `--output` selects a different directory; receipt into `dist` by default. `--output` selects a different directory;
relative paths are resolved from the repository. Existing files with a selected relative paths are resolved from the repository. Selected artifact filenames
artifact name are preserved and cause an error. A complete nonempty build are regenerated while unrelated files are preserved. A complete nonempty build
writes a receipt only after compilation and smoke checking succeed. On partial 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 empty target list returns the source commit without building or writing a
receipt. receipt.

View File

@@ -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. that fact. This observation is not a source snapshot or attestation guarantee.
The default output directory is `<repository>/dist`; a relative `OutputDir` is The default output directory is `<repository>/dist`; a relative `OutputDir` is
resolved from the repository. Existing files with a selected artifact name are resolved from the repository. Selected artifact filenames are regenerated. Other files in the output
preserved and cause an error. Other files in the output directory are left in directory are left in place. A failed later build or native version check returns the binaries already
place. A failed later build or native version check returns the binaries already
generated in `BuildResult.Artifacts`. generated in `BuildResult.Artifacts`.
Builds use the prepared local Go toolchain, readonly module mode, disabled Builds use the prepared local Go toolchain, readonly module mode, disabled

View File

@@ -163,6 +163,8 @@ write outcomes. Both affected suites pass.
### R4 — Medium: Local artifact regeneration is prohibited ### R4 — Medium: Local artifact regeneration is prohibited
Status: Resolved.
Category: Inadvertently strengthened prohibition. Category: Inadvertently strengthened prohibition.
**Evidence:** [Build](../../internal/build/build.go), around line 114, refuses **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 changed local build can regenerate its owned names; unrelated files survive;
a partial rebuild does not authorize incomplete assets through a stale receipt. 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 ### R5 — Medium: The root facade still owns release mechanics
Category: Architectural ownership gap and maintainability. Category: Architectural ownership gap and maintainability.

View File

@@ -114,14 +114,6 @@ func Build(ctx context.Context, request Request) (Result, error) {
if err := os.MkdirAll(result.OutputDir, 0o755); err != nil { if err := os.MkdirAll(result.OutputDir, 0o755); err != nil {
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) 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") compiler, err := runGo(ctx, repositoryDir, nil, "version")
if err != nil { if err != nil {