Bind delivered binaries to verified build source
This commit is contained in:
22
build.go
22
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
`<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
|
||||
resolved from the repository. Existing files with a selected artifact name are
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user