Version and bind module build receipts

This commit is contained in:
2026-10-10 03:16:40 +00:00
parent 926b74bc0b
commit d06b62efe2
7 changed files with 225 additions and 95 deletions

View File

@@ -2,7 +2,6 @@ package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -12,10 +11,12 @@ import (
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
const BuildReceiptName = ".releasekit-build.json"
const BuildReceiptSchemaVersion = 1
type ReceiptTarget struct {
OS string `json:"os"`
@@ -31,6 +32,7 @@ type ReceiptArtifact struct {
// BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct {
SchemaVersion int `json:"schema_version"`
RepositoryDir string `json:"repository_dir"`
ModuleDir string `json:"module_dir"`
ModulePath string `json:"module_path"`
@@ -52,7 +54,7 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
return "", fmt.Errorf("resolve build receipt directory: %w", err)
}
receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil {
if err := validateBuildReceipt(receipt); err != nil {
return "", err
}
contents, err := json.MarshalIndent(receipt, "", " ")
@@ -114,7 +116,10 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) {
}
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
if err := validateBuildReceipt(receipt, true); err != nil {
if err := validateBuildReceipt(receipt); err != nil {
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
return BuildReceipt{}, fmt.Errorf("%w; regenerate it with the updated build command", err)
}
return BuildReceipt{}, err
}
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) {
@@ -128,22 +133,26 @@ func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string,
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
return false
}
if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName {
if receipt.CandidateCommit != commit || receipt.Version != version {
return false
}
want := normalizeReceiptTargets(settings.Targets)
if len(want) != len(receipt.Targets) {
want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets))
got := artifact.NormalizeSettings(receipt.MainPackage, receipt.BinaryName, parseStoredTargets(receipt.Targets))
if want.MainPackage != got.MainPackage || want.BinaryName != got.BinaryName || len(want.Targets) != len(got.Targets) {
return false
}
for i, target := range want {
if target != receipt.Targets[i] {
for i, target := range want.Targets {
if target != got.Targets[i] {
return false
}
}
return true
}
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
func validateBuildReceipt(receipt BuildReceipt) error {
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion)
}
if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
}
@@ -153,6 +162,9 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" {
return errors.New("build receipt is missing module path or full tag")
}
if err := validateRecordedIdentity(receipt); err != nil {
return fmt.Errorf("build receipt has inconsistent module identity: %w", err)
}
if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit")
}
@@ -197,39 +209,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !validSHA256(artifact.SHA256) {
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
}
if verifyFiles {
if err := verifyReceiptArtifact(artifact); err != nil {
return err
}
}
}
return nil
}
func verifyReceiptArtifact(artifact ReceiptArtifact) error {
info, err := os.Lstat(artifact.Path)
func validateRecordedIdentity(receipt BuildReceipt) error {
repositoryDir := filepath.Clean(receipt.RepositoryDir)
moduleDir := filepath.Clean(receipt.ModuleDir)
relative, err := filepath.Rel(repositoryDir, moduleDir)
if err != nil {
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
return fmt.Errorf("relate module directory to repository: %w", err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return errors.New("module directory is outside repository")
}
file, err := os.Open(artifact.Path)
if relative == "" {
relative = "."
}
identity, err := releaseidentity.Derive(releaseidentity.Selection{
RepositoryDir: repositoryDir,
ModuleDir: moduleDir,
ModuleSubdir: filepath.ToSlash(relative),
}, []byte("module "+receipt.ModulePath+"\n"), receipt.Version)
if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
return err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err)
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 {
return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256)
if identity.Tag != receipt.Tag {
return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag)
}
return nil
}
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
func parseReceiptTargets(targets []string) []artifact.Target {
parsed := make([]artifact.Target, 0, len(targets))
for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/")
@@ -238,12 +249,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget {
}
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
}
normalized := artifact.NormalizeTargets(parsed)
result := make([]ReceiptTarget, 0, len(normalized))
for _, target := range normalized {
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
return parsed
}
func parseStoredTargets(targets []ReceiptTarget) []artifact.Target {
parsed := make([]artifact.Target, 0, len(targets))
for _, target := range targets {
parsed = append(parsed, artifact.Target{OS: target.OS, Arch: target.Arch})
}
return result
return parsed
}
func validCommitID(value string) bool {