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.
// 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.

View File

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

View File

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

View File

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

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`
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.

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.
The default output directory is `<repository>/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

View File

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

View File

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