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 ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -12,10 +11,12 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
const BuildReceiptName = ".releasekit-build.json" const BuildReceiptName = ".releasekit-build.json"
const BuildReceiptSchemaVersion = 1
type ReceiptTarget struct { type ReceiptTarget struct {
OS string `json:"os"` OS string `json:"os"`
@@ -31,6 +32,7 @@ type ReceiptArtifact struct {
// BuildReceipt is private local coordination data for generated binaries. // BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct { type BuildReceipt struct {
SchemaVersion int `json:"schema_version"`
RepositoryDir string `json:"repository_dir"` RepositoryDir string `json:"repository_dir"`
ModuleDir string `json:"module_dir"` ModuleDir string `json:"module_dir"`
ModulePath string `json:"module_path"` 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) return "", fmt.Errorf("resolve build receipt directory: %w", err)
} }
receipt.OutputDir = resolvedDir receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil { if err := validateBuildReceipt(receipt); err != nil {
return "", err return "", err
} }
contents, err := json.MarshalIndent(receipt, "", " ") contents, err := json.MarshalIndent(receipt, "", " ")
@@ -114,7 +116,10 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) {
} }
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err) 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 return BuildReceipt{}, err
} }
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) { 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) { if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
return false 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 return false
} }
want := normalizeReceiptTargets(settings.Targets) want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets))
if len(want) != len(receipt.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 return false
} }
for i, target := range want { for i, target := range want.Targets {
if target != receipt.Targets[i] { if target != got.Targets[i] {
return false return false
} }
} }
return true 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) { if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", 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) == "" { if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" {
return errors.New("build receipt is missing module path or full 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) { if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit") return errors.New("build receipt has invalid candidate commit")
} }
@@ -197,39 +209,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !validSHA256(artifact.SHA256) { if !validSHA256(artifact.SHA256) {
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename) 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 return nil
} }
func verifyReceiptArtifact(artifact ReceiptArtifact) error { func validateRecordedIdentity(receipt BuildReceipt) error {
info, err := os.Lstat(artifact.Path) repositoryDir := filepath.Clean(receipt.RepositoryDir)
moduleDir := filepath.Clean(receipt.ModuleDir)
relative, err := filepath.Rel(repositoryDir, moduleDir)
if err != nil { 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() { if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path) 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 { if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err) return err
} }
defer file.Close() if identity.Tag != receipt.Tag {
hash := sha256.New() return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag)
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)
} }
return nil return nil
} }
func normalizeReceiptTargets(targets []string) []ReceiptTarget { func parseReceiptTargets(targets []string) []artifact.Target {
parsed := make([]artifact.Target, 0, len(targets)) parsed := make([]artifact.Target, 0, len(targets))
for _, value := range targets { for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/") goos, goarch, ok := strings.Cut(value, "/")
@@ -238,12 +249,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget {
} }
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch}) parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
} }
normalized := artifact.NormalizeTargets(parsed) return parsed
result := make([]ReceiptTarget, 0, len(normalized)) }
for _, target := range normalized {
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch}) 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 { func validCommitID(value string) bool {

View File

@@ -3,65 +3,86 @@ package cli
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
) )
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) { func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
outputDir := t.TempDir() for _, test := range []struct {
repositoryDir := t.TempDir() name string
filename := "example-v1.2.3-linux-amd64" moduleDir string
artifactPath := filepath.Join(outputDir, filename) modulePath string
contents := []byte("built application") tag string
if err := os.WriteFile(artifactPath, contents, 0o755); err != nil { }{
t.Fatal(err) {name: "root", moduleDir: ".", modulePath: "example.com/receipt", tag: "v1.2.3"},
} {name: "nested", moduleDir: "integrations/ckconfig", modulePath: "example.com/ckconfig", tag: "integrations/ckconfig/v1.2.3"},
digest := sha256.Sum256(contents) } {
receipt := BuildReceipt{ t.Run(test.name, func(t *testing.T) {
RepositoryDir: repositoryDir, outputDir := t.TempDir()
ModuleDir: repositoryDir, repositoryDir := t.TempDir()
ModulePath: "example.com/receipt", moduleDir := repositoryDir
Tag: "v1.2.3", if test.moduleDir != "." {
CandidateCommit: strings.Repeat("a", 40), moduleDir = filepath.Join(repositoryDir, filepath.FromSlash(test.moduleDir))
Version: "v1.2.3", if err := os.MkdirAll(moduleDir, 0o755); err != nil {
MainPackage: "./cmd/example", t.Fatal(err)
BinaryName: "example", }
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, }
Compiler: "go version go1.25.0 linux/amd64", filename := "example-v1.2.3-linux-amd64"
Artifacts: []ReceiptArtifact{{ artifactPath := filepath.Join(outputDir, filename)
Path: artifactPath, contents := []byte("built application")
Filename: filename, if err := os.WriteFile(artifactPath, contents, 0o755); err != nil {
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, t.Fatal(err)
SHA256: hex.EncodeToString(digest[:]), }
}}, digest := sha256.Sum256(contents)
} receipt := BuildReceipt{
path, err := WriteBuildReceipt(outputDir, receipt) SchemaVersion: BuildReceiptSchemaVersion,
if err != nil { RepositoryDir: repositoryDir,
t.Fatal(err) ModuleDir: moduleDir,
} ModulePath: test.modulePath,
loaded, err := LoadBuildReceipt(path) Tag: test.tag,
if err != nil { CandidateCommit: strings.Repeat("a", 40),
t.Fatal(err) Version: "v1.2.3",
} MainPackage: "./cmd/example",
settings := BuildSettings{ BinaryName: "example",
MainPackage: "./cmd/example", Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
BinaryName: "example", Compiler: "go version go1.25.0 linux/amd64",
Targets: []string{"linux/amd64", "linux/amd64"}, Artifacts: []ReceiptArtifact{{
} Path: artifactPath, Filename: filename,
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
t.Fatalf("receipt does not match normalized build settings: %#v", loaded) }},
} }
settings.BinaryName = "other" path, err := WriteBuildReceipt(outputDir, receipt)
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { if err != nil {
t.Fatal("receipt matched changed build settings") t.Fatal(err)
} }
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil { loaded, err := LoadBuildReceipt(path)
t.Fatal(err) if err != nil {
} t.Fatal(err)
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") { }
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err) settings := BuildSettings{MainPackage: "./cmd/example", BinaryName: "example", Targets: []string{"linux/amd64", "linux/amd64"}}
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
}
settings.BinaryName = "other"
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatal("receipt matched changed build settings")
}
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err != nil {
t.Fatalf("receipt loading verified changed artifact bytes: %v", err)
}
if err := os.Remove(artifactPath); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err != nil {
t.Fatalf("receipt loading required artifact bytes: %v", err)
}
})
} }
} }
@@ -71,7 +92,9 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
body string body string
want string want string
}{ }{
{name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"}, {name: "unknown field", body: `{"schema_version":1,"unexpected":true}`, want: "unknown field"},
{name: "unversioned receipt", body: `{}`, want: "regenerate it with the updated build command"},
{name: "unknown schema", body: `{"schema_version":2}`, want: "regenerate it with the updated build command"},
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"}, {name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -85,3 +108,66 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
}) })
} }
} }
func TestWriteBuildReceiptRejectsInconsistentRecordedIdentity(t *testing.T) {
repositoryDir := t.TempDir()
moduleDir := filepath.Join(repositoryDir, "integrations", "ckconfig")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
outputDir := t.TempDir()
receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion,
RepositoryDir: repositoryDir, ModuleDir: moduleDir, ModulePath: "example.com/ckconfig", Tag: "v1.2.3",
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
Artifacts: []ReceiptArtifact{{
Path: filepath.Join(outputDir, "helper-v1.2.3-linux-amd64"), Filename: "helper-v1.2.3-linux-amd64",
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64),
}},
}
if _, err := WriteBuildReceipt(outputDir, receipt); err == nil || !strings.Contains(err.Error(), "full tag") {
t.Fatalf("WriteBuildReceipt() error = %v, want inconsistent full tag", err)
}
}
func TestLoadBuildReceiptRejectsMismatchedModuleIdentity(t *testing.T) {
repositoryDir := t.TempDir()
outputDir := t.TempDir()
artifactPath := filepath.Join(outputDir, "helper-v1.2.3-linux-amd64")
contents := []byte("fixture")
if err := os.WriteFile(artifactPath, contents, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(contents)
receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion,
RepositoryDir: repositoryDir, ModuleDir: repositoryDir, ModulePath: "example.com/receipt", Tag: "v1.2.3",
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
Artifacts: []ReceiptArtifact{{
Path: artifactPath, Filename: filepath.Base(artifactPath),
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
}},
}
contents, err := json.Marshal(receipt)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(contents, &fields); err != nil {
t.Fatal(err)
}
fields["module_dir"] = filepath.Join(repositoryDir, "integrations", "ckconfig")
contents, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(outputDir, BuildReceiptName)
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "inconsistent module identity") {
t.Fatalf("LoadBuildReceipt() error = %v, want inconsistent module identity", err)
}
}

View File

@@ -394,6 +394,7 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
SchemaVersion: cli.BuildReceiptSchemaVersion,
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir, ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath, ModulePath: result.ModulePath,

View File

@@ -381,7 +381,7 @@ func main() {
BinaryName: "fixture", BinaryName: "fixture",
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
} }
if !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 { if receipt.SchemaVersion != cli.BuildReceiptSchemaVersion || !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 {
t.Fatalf("build receipt = %#v", receipt) t.Fatalf("build receipt = %#v", receipt)
} }
if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) { if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) {
@@ -881,7 +881,7 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
} }
func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) { func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) {
for _, mode := range []string{"valid receipt", "tampered", "stale settings"} { for _, mode := range []string{"valid receipt", "tampered", "missing artifact", "stale settings"} {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}
@@ -902,6 +902,16 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
t.Fatal(err) t.Fatal(err)
} }
} }
if mode == "missing artifact" {
if err := os.Remove(artifactPath); err != nil {
t.Fatal(err)
}
}
if mode == "tampered" || mode == "missing artifact" {
if _, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)); err != nil {
t.Fatalf("receipt loading performed artifact verification: %v", err)
}
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
@@ -917,6 +927,11 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 {
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String()) t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String())
} }
if mode == "tampered" || mode == "missing artifact" {
if strings.Contains(stderr.String(), "load verified build receipt") {
t.Fatalf("%s artifact was rejected while loading its receipt: %q", mode, stderr.String())
}
}
}) })
} }
} }
@@ -937,6 +952,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
digest := sha256.Sum256(content) digest := sha256.Sum256(content)
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
SchemaVersion: cli.BuildReceiptSchemaVersion,
RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3", RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3",
CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"),
SourceVerified: true, SourceVerified: true,

View File

@@ -83,6 +83,8 @@ points inside the source tree.
For delivery from a tagged checkout, `deliver` builds selected tagged-source For delivery from a tagged checkout, `deliver` builds selected tagged-source
targets into temporary storage by default. Use `--assets DIR` to deliver a targets into temporary storage by default. Use `--assets DIR` to deliver a
separate build output; ReleaseKit requires its receipt and verifies the source, separate build output; ReleaseKit requires the current module-bound receipt and
build settings, and artifact hashes before the first Gitea write. See the checks its source identity and normalized build settings. It verifies artifact
bytes and sizes during delivery before the first Gitea write. Older receipts
must be regenerated with the updated `build` command. See the
[delivery consumer guide](delivery.md) for page-only delivery and retry states. [delivery consumer guide](delivery.md) for page-only delivery and retry states.

View File

@@ -89,9 +89,11 @@ RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3
The build command does not need delivery credentials. The `--assets DIR` The build command does not need delivery credentials. The `--assets DIR`
directory must contain the private `.releasekit-build.json` receipt created by directory must contain the private `.releasekit-build.json` receipt created by
`build`. ReleaseKit checks the receipt's repository, commit, version, resolved `build`. ReleaseKit checks its schema, repository, commit, version, resolved
module directory and path, full tag, build settings, and file hashes before module directory and path, full tag, normalized build settings, and artifact
making a remote request; caller-owned files remain available for retries. declarations. Old receipts must be regenerated with the updated `build`
command. Delivery verifies the artifact bytes and sizes before making a hosted
request; caller-owned files remain available for retries.
Ordinary `deliver` does not download application dependencies or change the Ordinary `deliver` does not download application dependencies or change the
checkout. checkout.

View File

@@ -499,6 +499,15 @@ changed or missing artifact bytes fails during delivery before hosted requests.
Protect verification ownership through an observable read boundary rather than Protect verification ownership through an observable read boundary rather than
tests tied to private helper choreography. Run receipt and CLI prebuilt tests. tests tied to private helper choreography. Run receipt and CLI prebuilt tests.
**Completed:** Replaced the unversioned receipt with schema version 1 and pure
recorded-identity checks for module path, physical directory, version, and full
tag. Receipt loading now validates structure and normalized settings without
opening artifacts; delivery remains responsible for verifying artifact bytes
and sizes before hosted requests. Old and unknown schemas require regeneration.
Verified root and nested receipts, malformed identity and settings, changed and
missing artifacts, `go test ./...`, `go run ./internal/apicheck`, and
`git diff --check`.
## Stage 10: Map profiles and product commands to module operations ## Stage 10: Map profiles and product commands to module operations
**Outcome:** Local check, publication, and build commands express the new API with **Outcome:** Local check, publication, and build commands express the new API with