Consolidate artifact declarations

This commit is contained in:
2026-10-06 17:29:41 +00:00
parent 3e9b5c1b99
commit 0eec4a30ed
9 changed files with 175 additions and 76 deletions

View File

@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -149,7 +150,7 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !validCommitID(receipt.CandidateCommit) { if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit") return errors.New("build receipt has invalid candidate commit")
} }
if receipt.MainPackage == "" || !safeReceiptComponent(receipt.BinaryName) || receipt.Compiler == "" { if receipt.MainPackage == "" || !artifact.ValidComponent(receipt.BinaryName) || receipt.Compiler == "" {
return errors.New("build receipt is missing build identity") return errors.New("build receipt is missing build identity")
} }
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) { if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
@@ -157,21 +158,29 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
} }
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets)) seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts)) seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
targets := make([]artifact.Target, 0, len(receipt.Targets))
for _, target := range receipt.Targets { for _, target := range receipt.Targets {
if !safeReceiptComponent(target.OS) || !safeReceiptComponent(target.Arch) { if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch) return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
} }
if seenTargets[target] { if seenTargets[target] {
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch) return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
} }
seenTargets[target] = true seenTargets[target] = true
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
}
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
} }
for _, artifact := range receipt.Artifacts { for _, artifact := range receipt.Artifacts {
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] { 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) return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
} }
seenArtifacts[artifact.Target] = true seenArtifacts[artifact.Target] = true
wantName := receiptArtifactFilename(receipt.BinaryName, receipt.Version, artifact.Target) wantName := wantByTarget[artifact.Target]
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename { if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename) return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
} }
@@ -215,34 +224,22 @@ func verifyReceiptArtifact(artifact ReceiptArtifact) error {
} }
func normalizeReceiptTargets(targets []string) []ReceiptTarget { func normalizeReceiptTargets(targets []string) []ReceiptTarget {
seen := make(map[ReceiptTarget]bool, len(targets)) parsed := make([]artifact.Target, 0, len(targets))
result := make([]ReceiptTarget, 0, len(targets))
for _, value := range targets { for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/") goos, goarch, ok := strings.Cut(value, "/")
if !ok { if !ok {
continue continue
} }
target := ReceiptTarget{OS: goos, Arch: goarch} parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
if !seen[target] { }
seen[target] = true normalized := artifact.NormalizeTargets(parsed)
result = append(result, target) result := make([]ReceiptTarget, 0, len(normalized))
} for _, target := range normalized {
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
} }
return result return result
} }
func receiptArtifactFilename(binaryName, version string, target ReceiptTarget) string {
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
return filename
}
func safeReceiptComponent(value string) bool {
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
}
func validCommitID(value string) bool { func validCommitID(value string) bool {
if len(value) != 40 && len(value) != 64 { if len(value) != 40 && len(value) != 64 {
return false return false

View File

@@ -35,6 +35,7 @@ these implemented packages:
| `cmd/releasekit` | Executable entry point, command routing, output, and exit status. | | `cmd/releasekit` | Executable entry point, command routing, output, and exit status. |
| `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. | | `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. |
| `internal/apicheck` | Compiled-export and import-boundary checks run during development. | | `internal/apicheck` | Compiled-export and import-boundary checks run during development. |
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
| `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | | `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. |

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # ReleaseKit audit implementation plan
Status: Stages 1–3 complete; Stage 4 not started. Status: Stages 1–4 complete; Stage 5 not started.
Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. and completion criteria for that work.
@@ -236,6 +236,14 @@ and manifests. Add only missing coverage for disagreement between these
boundaries. The API/dependency checker confirms that the shared internal owner boundaries. The API/dependency checker confirms that the shared internal owner
has not leaked through exported signatures or runtime dependencies. has not leaked through exported signatures or runtime dependencies.
**Completed:** `internal/artifact` now owns target normalization, filename
components, binary declarations, and checksum-manifest naming. The builder,
receipt adapter, and delivery validation share those rules while retaining
their build, receipt-path/file, and hosted-content responsibilities. Existing
coverage plus a mismatched delivery declaration check preserve cross-boundary
behavior. Focused tests, the full offline suite, API boundary checks, and
documentation whitespace checks pass.
## Stage 5 — Make delivery outcomes disjoint and actionable ## Stage 5 — Make delivery outcomes disjoint and actionable
**Covers:** F7. **Primary owners:** `internal/gitea/upload.go`, delivery result **Covers:** F7. **Primary owners:** `internal/gitea/upload.go`, delivery result

View File

@@ -0,0 +1,62 @@
// Package artifact owns shared build artifact identity rules.
package artifact
import (
"fmt"
"strings"
)
const ManifestFilename = "SHA256SUMS"
type Target struct {
OS string
Arch string
}
type Declaration struct {
Target Target
Filename string
}
// NormalizeTargets removes duplicate targets while preserving their first
// occurrence.
func NormalizeTargets(targets []Target) []Target {
seen := make(map[Target]struct{}, len(targets))
result := make([]Target, 0, len(targets))
for _, target := range targets {
if _, ok := seen[target]; ok {
continue
}
seen[target] = struct{}{}
result = append(result, target)
}
return result
}
// ValidComponent reports whether value is a nonempty filename component under
// the build and receipt contracts.
func ValidComponent(value string) bool {
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
}
// Filename returns the declared binary filename for one target.
func Filename(binaryName, version string, target Target) string {
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
return filename
}
// ExpectedBuild returns artifact declarations in target order. Callers retain
// responsibility for validating their boundary-specific build identity.
func ExpectedBuild(binaryName, version string, targets []Target) []Declaration {
declarations := make([]Declaration, 0, len(targets))
for _, target := range targets {
declarations = append(declarations, Declaration{
Target: target,
Filename: Filename(binaryName, version, target),
})
}
return declarations
}

View File

@@ -0,0 +1,42 @@
package artifact
import (
"reflect"
"testing"
)
func TestNormalizeTargetsPreservesOrderAndFirstOccurrence(t *testing.T) {
targets := []Target{
{OS: "linux", Arch: "amd64"},
{OS: "windows", Arch: "arm64"},
{OS: "linux", Arch: "amd64"},
}
want := []Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "arm64"}}
if got := NormalizeTargets(targets); !reflect.DeepEqual(got, want) {
t.Fatalf("NormalizeTargets() = %#v, want %#v", got, want)
}
}
func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
want := []Declaration{
{Target: targets[0], Filename: "app-v1.2.3-windows-arm64.exe"},
{Target: targets[1], Filename: "app-v1.2.3-linux-amd64"},
}
if got := ExpectedBuild("app", "v1.2.3", targets); !reflect.DeepEqual(got, want) {
t.Fatalf("ExpectedBuild() = %#v, want %#v", got, want)
}
}
func TestValidComponentPreservesBuildAndReceiptRules(t *testing.T) {
for _, value := range []string{"", "path/name", `path\name`, "line\nbreak", "line\rbreak"} {
if ValidComponent(value) {
t.Errorf("ValidComponent(%q) = true, want false", value)
}
}
for _, value := range []string{"app", "..", "nul\x00byte"} {
if !ValidComponent(value) {
t.Errorf("ValidComponent(%q) = false, want true", value)
}
}
}

View File

@@ -13,15 +13,13 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
type Target struct { type Target = artifact.Target
OS string
Arch string
}
type Spec struct { type Spec struct {
MainPackage string MainPackage string
@@ -62,7 +60,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
Version: request.Version, Version: request.Version,
MainPackage: request.Spec.MainPackage, MainPackage: request.Spec.MainPackage,
BinaryName: request.Spec.BinaryName, BinaryName: request.Spec.BinaryName,
Targets: normalizeTargets(request.Spec.Targets), Targets: artifact.NormalizeTargets(request.Spec.Targets),
} }
if !versioncontract.ValidStableVersion(request.Version) { if !versioncontract.ValidStableVersion(request.Version) {
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version) return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
@@ -74,11 +72,11 @@ func Build(ctx context.Context, request Request) (Result, error) {
if request.Spec.MainPackage == "" { if request.Spec.MainPackage == "" {
return result, errors.New("build requires a main package when targets are selected") return result, errors.New("build requires a main package when targets are selected")
} }
if !safeComponent(request.Spec.BinaryName) { if !artifact.ValidComponent(request.Spec.BinaryName) {
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName) return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
} }
for _, target := range result.Targets { for _, target := range result.Targets {
if !safeComponent(target.OS) || !safeComponent(target.Arch) { if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch) return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
} }
} }
@@ -107,9 +105,10 @@ func Build(ctx context.Context, request Request) (Result, error) {
} }
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
filenames := make([]string, 0, len(result.Targets)) declarations := artifact.ExpectedBuild(request.Spec.BinaryName, request.Version, result.Targets)
for _, target := range result.Targets { filenames := make([]string, len(declarations))
filenames = append(filenames, artifactFilename(request.Spec.BinaryName, request.Version, target)) for i, declaration := range declarations {
filenames[i] = declaration.Filename
} }
if err := os.MkdirAll(result.OutputDir, 0o755); err != nil { if err := os.MkdirAll(result.OutputDir, 0o755); err != nil {
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
@@ -188,23 +187,6 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, nil return result, nil
} }
func normalizeTargets(targets []Target) []Target {
seen := make(map[Target]struct{}, len(targets))
result := make([]Target, 0, len(targets))
for _, target := range targets {
if _, ok := seen[target]; ok {
continue
}
seen[target] = struct{}{}
result = append(result, target)
}
return result
}
func safeComponent(value string) bool {
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
}
func resolvedOutputDir(repositoryDir, outputDir string) string { func resolvedOutputDir(repositoryDir, outputDir string) string {
if outputDir == "" { if outputDir == "" {
outputDir = filepath.Join(repositoryDir, "dist") outputDir = filepath.Join(repositoryDir, "dist")
@@ -218,14 +200,6 @@ func resolvedOutputDir(repositoryDir, outputDir string) string {
return resolved return resolved
} }
func artifactFilename(binaryName, version string, target Target) string {
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
return filename
}
func gitCommit(ctx context.Context, repositoryDir string) (string, error) { func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
output, err := command.Output() output, err := command.Output()

View File

@@ -7,8 +7,8 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
@@ -23,30 +23,26 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
if len(result.Artifacts) != len(result.Targets) { if len(result.Artifacts) != len(result.Targets) {
return nil, fmt.Errorf("build result must contain one artifact for each selected target") return nil, fmt.Errorf("build result must contain one artifact for each selected target")
} }
wantByTarget := make(map[string]string, len(result.Targets)) declarations := artifact.ExpectedBuild(result.BinaryName, result.Version, result.Targets)
for _, target := range result.Targets { wantByTarget := make(map[artifact.Target]string, len(declarations))
if target.OS == "" || target.Arch == "" || strings.ContainsAny(target.OS+target.Arch, "/\\\r\n") { 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") return nil, fmt.Errorf("build result contains an invalid target")
} }
key := target.OS + "/" + target.Arch if _, exists := wantByTarget[target]; exists {
if _, exists := wantByTarget[key]; exists { return nil, fmt.Errorf("build result repeats target %q", target.OS+"/"+target.Arch)
return nil, fmt.Errorf("build result repeats target %q", key)
} }
name := result.BinaryName + "-" + result.Version + "-" + target.OS + "-" + target.Arch wantByTarget[target] = declaration.Filename
if target.OS == "windows" {
name += ".exe"
}
wantByTarget[key] = name
} }
assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1) assets := make([]gitea.ExpectedAsset, 0, len(result.Artifacts)+1)
seenTargets := make(map[string]bool, len(result.Artifacts)) seenTargets := make(map[artifact.Target]bool, len(result.Artifacts))
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
key := artifact.Target.OS + "/" + artifact.Target.Arch wantName, ok := wantByTarget[artifact.Target]
wantName, ok := wantByTarget[key] if !ok || seenTargets[artifact.Target] || artifact.Filename != wantName {
if !ok || seenTargets[key] || artifact.Filename != wantName {
return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename) return nil, fmt.Errorf("build artifact %q does not match a selected target and its declared name", artifact.Filename)
} }
seenTargets[key] = true seenTargets[artifact.Target] = true
if filepath.Base(artifact.Path) != artifact.Filename { if filepath.Base(artifact.Path) != artifact.Filename {
return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename) return nil, fmt.Errorf("build artifact path %q does not end with filename %q", artifact.Path, artifact.Filename)
} }

View File

@@ -61,6 +61,23 @@ func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
} }
} }
func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64")
contents := []byte("built application")
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
_, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
Artifacts: []internalbuild.Artifact{{
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents),
}},
})
if err == nil || !strings.Contains(err.Error(), "does not match a selected target") {
t.Fatalf("declaredBuildAssets() error = %v, want declaration mismatch", err)
}
}
func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) { func TestDeclaredBuildAssetsKeepsSourceOnlyBuildManifestFree(t *testing.T) {
assets, err := declaredBuildAssets(internalbuild.Result{}) assets, err := declaredBuildAssets(internalbuild.Result{})
if err != nil || len(assets) != 0 { if err != nil || len(assets) != 0 {

View File

@@ -13,9 +13,11 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
) )
const ManifestFilename = "SHA256SUMS" const ManifestFilename = artifact.ManifestFilename
type AssetState string type AssetState string