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.
|
// 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user