From 0666b68ff66376d44fcb77aae1e7b56254297092 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 03:23:15 +0000 Subject: [PATCH] Share artifact declaration validation --- cmd/releasekit/internal/cli/receipt.go | 29 ++++++-------------------- docs/roadmap/implementation.md | 2 +- internal/artifact/artifact.go | 27 ++++++++++++++++++++++++ internal/artifact/artifact_test.go | 28 +++++++++++++++++++++++++ internal/delivery/assets.go | 24 +++++---------------- 5 files changed, 67 insertions(+), 43 deletions(-) diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go index 5b6a575..b8c26ab 100644 --- a/cmd/releasekit/internal/cli/receipt.go +++ b/cmd/releasekit/internal/cli/receipt.go @@ -182,32 +182,15 @@ func validateBuildReceipt(receipt BuildReceipt) error { if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) { return errors.New("build receipt target and artifact counts do not match") } - seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets)) - seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts)) - targets := make([]artifact.Target, 0, len(receipt.Targets)) - for _, target := range receipt.Targets { - if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) { - return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch) - } - if seenTargets[target] { - return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch) - } - seenTargets[target] = true - targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch}) + declarations := make([]artifact.Declaration, 0, len(receipt.Artifacts)) + for _, item := range receipt.Artifacts { + declarations = append(declarations, artifact.Declaration{Target: artifact.Target{OS: item.Target.OS, Arch: item.Target.Arch}, Filename: item.Filename}) } - declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets) - wantByTarget := make(map[ReceiptTarget]string, len(declarations)) - for _, declaration := range declarations { - target := ReceiptTarget{OS: declaration.Target.OS, Arch: declaration.Target.Arch} - wantByTarget[target] = declaration.Filename + if err := artifact.ValidateDeclarations(receipt.BinaryName, receipt.Version, parseStoredTargets(receipt.Targets), declarations); err != nil { + return fmt.Errorf("build receipt: %w", err) } for _, artifact := range receipt.Artifacts { - if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] { - return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch) - } - seenArtifacts[artifact.Target] = true - wantName := wantByTarget[artifact.Target] - if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename { + if filepath.Base(artifact.Filename) != artifact.Filename { return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename) } wantPath := filepath.Join(receipt.OutputDir, artifact.Filename) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index caf1e18..9a2caaf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -44,7 +44,7 @@ the agent policy; an intermediate compilation dependency is recorded accurately. | 6 | A04 | Complete — Git display, endpoint change, public mismatch, CLI, publication, and Git failure regressions passed. | | 7 | A05 | Complete — real-repository inspection failures preserve the local ref, available object ID, and Git cause without a push. | | 8 | A06, A14 | Complete — public build and prebuilt-delivery checks and CLI receipt/partial-artifact regressions passed. | -| 9 | A16 | Pending | +| 9 | A16 | Complete — shared declaration, receipt, delivery, prebuilt, and source-only regressions passed. | | 10 | A07: allocation and temporary metadata | Pending | | 11 | A07: build and delivery outcomes | Pending | | 12 | A08 | Pending | diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go index 9bc8e9e..25a4f6a 100644 --- a/internal/artifact/artifact.go +++ b/internal/artifact/artifact.go @@ -90,3 +90,30 @@ func ExpectedBuild(binaryName, version string, targets []Target) []Declaration { } return declarations } + +// ValidateDeclarations matches recorded artifact identities to selected targets. +func ValidateDeclarations(binaryName, version string, targets []Target, recorded []Declaration) error { + if len(recorded) != len(targets) { + return fmt.Errorf("artifact and target counts do not match") + } + want := make(map[Target]string, len(targets)) + for _, declaration := range ExpectedBuild(binaryName, version, targets) { + target := declaration.Target + if !ValidComponent(target.OS) || !ValidComponent(target.Arch) { + return fmt.Errorf("invalid target %q/%q", target.OS, target.Arch) + } + if _, exists := want[target]; exists { + return fmt.Errorf("repeats target %q/%q", target.OS, target.Arch) + } + want[target] = declaration.Filename + } + seen := make(map[Target]bool, len(recorded)) + for _, declaration := range recorded { + name, ok := want[declaration.Target] + if !ok || seen[declaration.Target] || declaration.Filename != name { + return fmt.Errorf("artifact %q does not match a selected target and its declared name", declaration.Filename) + } + seen[declaration.Target] = true + } + return nil +} diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go index aed6cdb..f68c5ae 100644 --- a/internal/artifact/artifact_test.go +++ b/internal/artifact/artifact_test.go @@ -56,3 +56,31 @@ func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) { } } } + +func TestValidateDeclarationsMatchesTargetsAndNames(t *testing.T) { + linux := Target{OS: "linux", Arch: "amd64"} + windows := Target{OS: "windows", Arch: "arm64"} + first := Declaration{Target: linux, Filename: "app-v1.2.3-linux-amd64"} + second := Declaration{Target: windows, Filename: "app-v1.2.3-windows-arm64.exe"} + for _, test := range []struct { + name string + targets []Target + recorded []Declaration + valid bool + }{ + {"reordered", []Target{linux, windows}, []Declaration{second, first}, true}, + {"count", []Target{linux, windows}, []Declaration{first}, false}, + {"duplicate target", []Target{linux, linux}, []Declaration{first, first}, false}, + {"duplicate artifact", []Target{linux, windows}, []Declaration{first, first}, false}, + {"incorrect name", []Target{linux}, []Declaration{{Target: linux, Filename: "other"}}, false}, + {"unknown target", []Target{linux}, []Declaration{second}, false}, + {"unsafe target", []Target{{OS: "linux/other", Arch: "amd64"}}, []Declaration{{Target: linux, Filename: first.Filename}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + err := ValidateDeclarations("app", "v1.2.3", test.targets, test.recorded) + if (err == nil) != test.valid { + t.Fatalf("ValidateDeclarations() error = %v, valid = %t", err, test.valid) + } + }) + } +} diff --git a/internal/delivery/assets.go b/internal/delivery/assets.go index 9cfe4de..9771057 100644 --- a/internal/delivery/assets.go +++ b/internal/delivery/assets.go @@ -17,29 +17,15 @@ func declaredBuildAssets(result internalbuild.Result) ([]artifact.ExpectedAsset, } return nil, nil } - if len(result.Artifacts) != len(settings.Targets) { - return nil, fmt.Errorf("build result must contain one artifact for each selected target") + declarations := make([]artifact.Declaration, 0, len(result.Artifacts)) + for _, built := range result.Artifacts { + declarations = append(declarations, artifact.Declaration{Target: built.Target, Filename: built.Filename}) } - declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets) - wantByTarget := make(map[artifact.Target]string, len(declarations)) - for _, declaration := range declarations { - target := declaration.Target - if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) { - return nil, fmt.Errorf("build result contains an invalid target") - } - if _, exists := wantByTarget[target]; exists { - return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch) - } - wantByTarget[target] = declaration.Filename + if err := artifact.ValidateDeclarations(settings.BinaryName, result.Version, settings.Targets, declarations); err != nil { + return nil, fmt.Errorf("build result: %w", err) } assets := make([]artifact.ExpectedAsset, 0, len(result.Artifacts)+1) - seenTargets := make(map[artifact.Target]bool, len(result.Artifacts)) for _, builtArtifact := range result.Artifacts { - wantName, ok := wantByTarget[builtArtifact.Target] - if !ok || seenTargets[builtArtifact.Target] || builtArtifact.Filename != wantName { - return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", builtArtifact.Filename) - } - seenTargets[builtArtifact.Target] = true if filepath.Base(builtArtifact.Path) != builtArtifact.Filename { return nil, fmt.Errorf("build artifact path %q does not end with filename %q", builtArtifact.Path, builtArtifact.Filename) }