Regenerate local artifacts and invalidate stale receipts
This commit is contained in:
2
build.go
2
build.go
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user