From a1ee067b5323849730a1188c4b2fcbac8264c5e7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 00:51:56 +0000 Subject: [PATCH] Bind delivered binaries to verified build source --- build.go | 22 ++++++----- build_test.go | 51 ++++++++++++++++++++++++++ cmd/releasekit/internal/cli/receipt.go | 1 + cmd/releasekit/main.go | 3 +- cmd/releasekit/main_test.go | 42 ++++++++++++++++++++- delivery.go | 1 + docs/consumers/build.md | 5 ++- docs/consumers/delivery.md | 5 ++- docs/roadmap/remediation.md | 12 +++++- internal/build/build.go | 23 ++++++++++++ internal/delivery/delivery.go | 4 ++ 11 files changed, 154 insertions(+), 15 deletions(-) diff --git a/build.go b/build.go index 7a6027e..9b7265f 100644 --- a/build.go +++ b/build.go @@ -39,15 +39,18 @@ type BuildArtifact struct { // BuildResult describes the source and artifacts reached by Build. type BuildResult struct { - RepositoryDir string // absolute source repository path. - CandidateCommit string // Git HEAD observed when the build started. - Version string // requested injected version. - MainPackage string // Go package path built. - BinaryName string // artifact name prefix. - OutputDir string // resolved output directory. - Targets []Target // normalized target pairs. - Artifacts []BuildArtifact // completed output files, including partial success. - Compiler string // output of the selected local Go toolchain's version command. + RepositoryDir string // absolute source repository path. + CandidateCommit string // Git HEAD observed when the build started. + // SourceVerified reports clean source and the same HEAD observed before and + // after a completed build. It is required for binary delivery, not an attestation. + SourceVerified bool + Version string // requested injected version. + MainPackage string // Go package path built. + BinaryName string // artifact name prefix. + OutputDir string // resolved output directory. + Targets []Target // normalized target pairs. + Artifacts []BuildArtifact // completed output files, including partial success. + Compiler string // output of the selected local Go toolchain's version command. } // Build compiles the selected targets with Version injected through buildinfo. @@ -74,6 +77,7 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { public := BuildResult{ RepositoryDir: result.RepositoryDir, CandidateCommit: result.CandidateCommit, + SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, diff --git a/build_test.go b/build_test.go index 5a21a20..30fef48 100644 --- a/build_test.go +++ b/build_test.go @@ -462,3 +462,54 @@ func buildGit(t *testing.T, directory string, args ...string) string { } return strings.TrimSpace(string(output)) } + +func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) { + const source = `package main +import("fmt";"os";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") +func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} } +` + for _, mode := range []string{"clean", "dirty before build", "changed during build"} { + t.Run(mode, func(t *testing.T) { + repository := newBuildConsumer(t, source) + writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n") + buildGit(t, repository, "add", "--all") + buildGit(t, repository, "commit", "-m", "release note") + buildGit(t, repository, "tag", "v1.2.3") + mutate := func() { writeBuildFixture(t, repository, "cmd/app/main.go", source+"\n// local change\n") } + if mode == "dirty before build" { + mutate() + } + var writer *sourceChangeWriter + 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: t.TempDir()} + if mode == "changed during build" { + writer = &sourceChangeWriter{change: mutate} + request.Output = writer + } + result, err := releasekit.Build(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if result.SourceVerified != (mode == "clean") { + t.Fatalf("source eligibility=%t for %s", result.SourceVerified, mode) + } + if mode == "clean" { + return + } + buildGit(t, repository, "restore", "cmd/app/main.go") + delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Build: &result}) + if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted { + t.Fatalf("dirty source delivered: %#v %v", delivered, err) + } + }) + } +} + +type sourceChangeWriter struct{ change func() } + +func (w *sourceChangeWriter) Write(p []byte) (int, error) { + if w.change != nil { + w.change() + w.change = nil + } + return len(p), nil +} diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index b28daeb..96d3dea 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -32,6 +32,7 @@ type ReceiptArtifact struct { type BuildReceipt struct { RepositoryDir string `json:"repository_dir"` CandidateCommit string `json:"candidate_commit"` + SourceVerified bool `json:"source_verified"` Version string `json:"version"` MainPackage string `json:"main_package"` BinaryName string `json:"binary_name"` diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index d9201f8..4ff5cf4 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -253,7 +253,7 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) { func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { result := &releasekit.BuildResult{ - RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, + RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified, Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName, OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, } @@ -289,6 +289,7 @@ func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { receipt := cli.BuildReceipt{ RepositoryDir: result.RepositoryDir, CandidateCommit: result.CandidateCommit, + SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index b472498..a4221fe 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -411,7 +411,8 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} receipt := cli.BuildReceipt{ RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), - Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, + SourceVerified: true, + Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture", Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}}, } @@ -635,3 +636,42 @@ func cliGitOutput(t *testing.T, repository string, args ...string) string { } return strings.TrimSpace(string(output)) } + +func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) { + repository := newDeliveryBuildRepository(t) + state := &deliveryServerState{} + server := newDeliveryServer(t, state) + defer server.Close() + settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} + writeDeliveryProfile(t, repository, settings) + state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") + cliGitRun(t, repository, "tag", "v1.2.3") + mainPath := filepath.Join(repository, "main.go") + original, err := os.ReadFile(mainPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mainPath, append(original, []byte("\n// uncommitted build\n")...), 0600); err != nil { + t.Fatal(err) + } + assets := t.TempDir() + var stdout, stderr bytes.Buffer + if status := run(context.Background(), []string{"build", "--repo", repository, "--output", assets, "v1.2.3"}, &stdout, &stderr); status != 0 { + t.Fatalf("local build status=%d %s", status, stderr.String()) + } + receipt, err := cli.LoadBuildReceipt(filepath.Join(assets, cli.BuildReceiptName)) + if err != nil { + t.Fatal(err) + } + if receipt.SourceVerified { + t.Fatal("dirty build receipt claims verified source") + } + cliGitRun(t, repository, "restore", "main.go") + t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") + stdout.Reset() + stderr.Reset() + status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) + if status != 1 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "build source") { + t.Fatalf("status=%d requests=%d %s", status, state.apiRequests, stderr.String()) + } +} diff --git a/delivery.go b/delivery.go index 1bf540a..34a19e4 100644 --- a/delivery.go +++ b/delivery.go @@ -86,6 +86,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro build = &internaldelivery.BuildIdentity{ RepositoryDir: request.Build.RepositoryDir, CandidateCommit: request.Build.CandidateCommit, + SourceVerified: request.Build.SourceVerified, Version: request.Build.Version, MainPackage: request.Build.MainPackage, BinaryName: request.Build.BinaryName, diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 2340671..d7d5da2 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -40,7 +40,10 @@ func build(ctx context.Context) error { version into `buildinfo.Version`, and returns the compiler identity, normalized targets, artifact paths, filenames, and SHA-256 digests. Names follow `---`; Windows targets add `.exe`. A repeated -target pair is built once, at its first position. +target pair is built once, at its first position. Local builds may use modified +source. `SourceVerified` is true only after a complete build observes clean +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 diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index 2269c9d..d25ea5c 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -54,7 +54,10 @@ understood. The command does not run candidate checks or publish tags. Pass the Gitea credential explicitly. A `BuildResult` may be supplied for a binary-producing release; it must identify the same tagged repository commit -and selected version, and its local artifacts must still match their digests. +and selected version, its `SourceVerified` field must confirm the completed +build's clean, unchanged source observations, and its local artifacts must still +match their digests. Cleaning the checkout after a dirty build does not make +that build eligible for delivery. Leave `Build` nil for a source-only release. ```go diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index a8650f6..d587cd9 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,7 +1,6 @@ # ReleaseKit implementation review and remediation -Status: Open. These findings describe work to address; this review changes no -implementation or supported contract. +Status: In progress. Resolution notes record completed fixes and verification. Date: 2026-10-06 @@ -57,6 +56,8 @@ ownership or runtime dependency weight. ### R1 — High: Delivery accepts binaries built from uncommitted source +Status: Resolved. + Category: Correctness and divergence from tagged-source delivery. **Evidence:** [The builder](../../internal/build/build.go) records `HEAD` in @@ -86,6 +87,13 @@ become eligible for tagged delivery merely by cleaning the worktree afterward. Cover source changes during a build and unchanged clean tagged builds through the supported API and CLI receipt path. +**Resolution:** Build results and private receipts now carry `SourceVerified`, +set only after completed builds observe clean source and unchanged HEAD. Local +dirty builds remain supported; binary delivery rejects their results and +receipts even after checkout cleanup. API tests cover clean, initially dirty, +and changed-during-build source; a CLI regression covers receipt delivery after +cleanup. The offline suite and public boundary checks pass. + ### R2 — High: Some successful page writes are reported as not attempted Category: Failure handling and recovery-state correctness. diff --git a/internal/build/build.go b/internal/build/build.go index 50d29ab..090660f 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -47,6 +47,7 @@ type Artifact struct { type Result struct { RepositoryDir string CandidateCommit string + SourceVerified bool Version string MainPackage string BinaryName string @@ -95,6 +96,10 @@ func Build(ctx context.Context, request Request) (Result, error) { return result, err } result.CandidateCommit = commit + cleanBefore, err := cleanSource(ctx, repositoryDir) + if err != nil { + return result, err + } if len(result.Targets) == 0 { result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) @@ -177,6 +182,15 @@ func Build(ctx context.Context, request Request) (Result, error) { if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil { return result, err } + commitAfter, err := gitCommit(ctx, repositoryDir) + if err != nil { + return result, err + } + cleanAfter, err := cleanSource(ctx, repositoryDir) + if err != nil { + return result, err + } + result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit return result, nil } @@ -330,3 +344,12 @@ func checkVersion(ctx context.Context, binaryPath, binaryName, version string) e } return nil } + +func cleanSource(ctx context.Context, repositoryDir string) (bool, error) { + command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all") + output, err := command.Output() + if err != nil { + return false, fmt.Errorf("inspect build source: %w", err) + } + return len(output) == 0, nil +} diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 9362ed5..90e5371 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -26,6 +26,7 @@ const ( type BuildIdentity struct { RepositoryDir string CandidateCommit string + SourceVerified bool Version string MainPackage string BinaryName string @@ -139,6 +140,9 @@ func validateBuild(repositoryDir, commit, version string, build *BuildIdentity, } return nil } + if !build.SourceVerified { + return errors.New("build source was not verified as clean and unchanged during compilation") + } buildRepository, err := filepath.Abs(build.RepositoryDir) if err != nil { return fmt.Errorf("resolve build repository: %w", err)