Bind delivered binaries to verified build source

This commit is contained in:
2026-10-06 00:51:56 +00:00
parent 1d6611601c
commit a1ee067b53
11 changed files with 154 additions and 15 deletions

View File

@@ -39,15 +39,18 @@ type BuildArtifact struct {
// BuildResult describes the source and artifacts reached by Build. // BuildResult describes the source and artifacts reached by Build.
type BuildResult struct { type BuildResult struct {
RepositoryDir string // absolute source repository path. RepositoryDir string // absolute source repository path.
CandidateCommit string // Git HEAD observed when the build started. CandidateCommit string // Git HEAD observed when the build started.
Version string // requested injected version. // SourceVerified reports clean source and the same HEAD observed before and
MainPackage string // Go package path built. // after a completed build. It is required for binary delivery, not an attestation.
BinaryName string // artifact name prefix. SourceVerified bool
OutputDir string // resolved output directory. Version string // requested injected version.
Targets []Target // normalized target pairs. MainPackage string // Go package path built.
Artifacts []BuildArtifact // completed output files, including partial success. BinaryName string // artifact name prefix.
Compiler string // output of the selected local Go toolchain's version command. 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. // 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{ public := BuildResult{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,
MainPackage: result.MainPackage, MainPackage: result.MainPackage,
BinaryName: result.BinaryName, BinaryName: result.BinaryName,

View File

@@ -462,3 +462,54 @@ func buildGit(t *testing.T, directory string, args ...string) string {
} }
return strings.TrimSpace(string(output)) 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
}

View File

@@ -32,6 +32,7 @@ type ReceiptArtifact struct {
type BuildReceipt struct { type BuildReceipt struct {
RepositoryDir string `json:"repository_dir"` RepositoryDir string `json:"repository_dir"`
CandidateCommit string `json:"candidate_commit"` CandidateCommit string `json:"candidate_commit"`
SourceVerified bool `json:"source_verified"`
Version string `json:"version"` Version string `json:"version"`
MainPackage string `json:"main_package"` MainPackage string `json:"main_package"`
BinaryName string `json:"binary_name"` BinaryName string `json:"binary_name"`

View File

@@ -253,7 +253,7 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &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, Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
} }
@@ -289,6 +289,7 @@ func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,
MainPackage: result.MainPackage, MainPackage: result.MainPackage,
BinaryName: result.BinaryName, BinaryName: result.BinaryName,

View File

@@ -411,7 +411,8 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), 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", OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture",
Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}}, 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)) 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())
}
}

View File

@@ -86,6 +86,7 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
build = &internaldelivery.BuildIdentity{ build = &internaldelivery.BuildIdentity{
RepositoryDir: request.Build.RepositoryDir, RepositoryDir: request.Build.RepositoryDir,
CandidateCommit: request.Build.CandidateCommit, CandidateCommit: request.Build.CandidateCommit,
SourceVerified: request.Build.SourceVerified,
Version: request.Build.Version, Version: request.Build.Version,
MainPackage: request.Build.MainPackage, MainPackage: request.Build.MainPackage,
BinaryName: request.Build.BinaryName, BinaryName: request.Build.BinaryName,

View File

@@ -40,7 +40,10 @@ func build(ctx context.Context) error {
version into `buildinfo.Version`, and returns the compiler identity, normalized version into `buildinfo.Version`, and returns the compiler identity, normalized
targets, artifact paths, filenames, and SHA-256 digests. Names follow targets, artifact paths, filenames, and SHA-256 digests. Names follow
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated `<binary>-<version>-<goos>-<goarch>`; 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 `<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. Existing files with a selected artifact name are

View File

@@ -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 Pass the Gitea credential explicitly. A `BuildResult` may be supplied for a
binary-producing release; it must identify the same tagged repository commit 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. Leave `Build` nil for a source-only release.
```go ```go

View File

@@ -1,7 +1,6 @@
# ReleaseKit implementation review and remediation # ReleaseKit implementation review and remediation
Status: Open. These findings describe work to address; this review changes no Status: In progress. Resolution notes record completed fixes and verification.
implementation or supported contract.
Date: 2026-10-06 Date: 2026-10-06
@@ -57,6 +56,8 @@ ownership or runtime dependency weight.
### R1 — High: Delivery accepts binaries built from uncommitted source ### R1 — High: Delivery accepts binaries built from uncommitted source
Status: Resolved.
Category: Correctness and divergence from tagged-source delivery. Category: Correctness and divergence from tagged-source delivery.
**Evidence:** [The builder](../../internal/build/build.go) records `HEAD` in **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 Cover source changes during a build and unchanged clean tagged builds through
the supported API and CLI receipt path. 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 ### R2 — High: Some successful page writes are reported as not attempted
Category: Failure handling and recovery-state correctness. Category: Failure handling and recovery-state correctness.

View File

@@ -47,6 +47,7 @@ type Artifact struct {
type Result struct { type Result struct {
RepositoryDir string RepositoryDir string
CandidateCommit string CandidateCommit string
SourceVerified bool
Version string Version string
MainPackage string MainPackage string
BinaryName string BinaryName string
@@ -95,6 +96,10 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, err return result, err
} }
result.CandidateCommit = commit result.CandidateCommit = commit
cleanBefore, err := cleanSource(ctx, repositoryDir)
if err != nil {
return result, err
}
if len(result.Targets) == 0 { if len(result.Targets) == 0 {
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) 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 { if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil {
return result, err 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 return result, nil
} }
@@ -330,3 +344,12 @@ func checkVersion(ctx context.Context, binaryPath, binaryName, version string) e
} }
return nil 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
}

View File

@@ -26,6 +26,7 @@ const (
type BuildIdentity struct { type BuildIdentity struct {
RepositoryDir string RepositoryDir string
CandidateCommit string CandidateCommit string
SourceVerified bool
Version string Version string
MainPackage string MainPackage string
BinaryName string BinaryName string
@@ -139,6 +140,9 @@ func validateBuild(repositoryDir, commit, version string, build *BuildIdentity,
} }
return nil 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) buildRepository, err := filepath.Abs(build.RepositoryDir)
if err != nil { if err != nil {
return fmt.Errorf("resolve build repository: %w", err) return fmt.Errorf("resolve build repository: %w", err)