Integrate selected builds with candidate workflows

This commit is contained in:
2026-10-05 19:38:50 +00:00
parent 5d6a6d10bd
commit e98ee0f7b9
21 changed files with 848 additions and 31 deletions

4
.gitignore vendored
View File

@@ -105,6 +105,8 @@ $RECYCLE.BIN/
# Local History for Visual Studio Code # Local History for Visual Studio Code
.history/ .history/
# ReleaseKit build output
/dist/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix

View File

@@ -3,9 +3,10 @@
ReleaseKit is an opinionated Go library and executable for a shared release ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package, process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, selected-target build API, local candidate check, source-tag publication API, selected-target build API,
and `version`, `check`, and `publish` commands are implemented. ReleaseKit's and `version`, `check`, `publish`, and `build` commands are implemented.
source-only maintainer procedure is documented. The build command and delivery ReleaseKit's source-only maintainer procedure is documented. Hosted delivery
remain future work. The [product roadmap](docs/roadmap/releasekit.md) and and ReleaseKit's own binary artifacts remain future work. The
[product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md) describe their selected [implementation plan](docs/roadmap/implementation.md) describe their selected
behavior and sequence. behavior and sequence.
@@ -43,8 +44,8 @@ failure recovery.
version injection, and delivery, with the initial implementation plan. version injection, and delivery, with the initial implementation plan.
- [Contributor guide](docs/development.md): orientation, validation, and selective - [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading. policy reading.
- [CLI contract](docs/cli.md): the `version`, local `check`, and source - [CLI contract](docs/cli.md): the `version`, `check`, `publish`, and `build`
`publish` commands. commands.
- [Configuration](docs/config.md): profile fields and child-command settings. - [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications - [Runtime version guidance](docs/consumers/runtime-version.md): how applications
read their embedded version through `buildinfo`. read their embedded version through `buildinfo`.

View File

@@ -333,6 +333,77 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
} }
} }
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
repository := newPublishFixture(t)
prepareCandidateBuild(t, repository.work, `package main
import (
"fmt"
"os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
)
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } }
`)
var validations int
candidate, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
validations++
return nil
},
Build: &releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
},
})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") {
t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations)
}
if got := gitText(t, repository.work, "status", "--porcelain"); got != "" {
t.Fatalf("candidate build dirtied worktree: %s", got)
}
if paths, err := filepath.Glob(filepath.Join(filepath.Dir(repository.work), ".releasekit-candidate-build-*")); err != nil || len(paths) != 0 {
t.Fatalf("temporary candidate output remains: %v, error=%v", paths, err)
}
}
func TestPublishBuildFailurePreventsTagCreation(t *testing.T) {
repository := newPublishFixture(t)
prepareCandidateBuild(t, repository.work, "package main\nfunc main() {}\n")
var validations int
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error {
validations++
return nil
},
Build: &releasekit.BuildSpec{
MainPackage: "./cmd/missing",
BinaryName: "fixture",
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
},
})
if err == nil || !strings.Contains(err.Error(), "build candidate") || result.TagObject != "" {
t.Fatalf("Publish() result=%#v error=%v; want build failure before tag creation", result, err)
}
if validations != 1 {
t.Fatalf("validation calls = %d, want 1", validations)
}
if _, err := exec.Command("git", "--git-dir", repository.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("build failure published the release tag")
}
if _, err := exec.Command("git", "-C", repository.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
t.Fatal("build failure created a local release tag")
}
}
func newBuildConsumer(t *testing.T, mainSource string) string { func newBuildConsumer(t *testing.T, mainSource string) string {
t.Helper() t.Helper()
repository, err := os.MkdirTemp("", "releasekit-build-consumer-") repository, err := os.MkdirTemp("", "releasekit-build-consumer-")
@@ -355,6 +426,22 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
return repository return repository
} }
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
t.Helper()
root, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
writeFile(t, repository, "go.mod", module)
writeFile(t, repository, ".gitignore", "dist/\n")
writeFile(t, repository, "cmd/app/main.go", mainSource)
gitRun(t, repository, "add", "--all")
gitRun(t, repository, "commit", "-m", "add buildable candidate")
gitRun(t, repository, "config", "remote.origin.mirror", "false")
gitRun(t, repository, "push", "origin", "HEAD:refs/heads/main")
}
func writeBuildFixture(t *testing.T, directory, name, contents string) { func writeBuildFixture(t *testing.T, directory, name, contents string) {
t.Helper() t.Helper()
path := filepath.Join(directory, filepath.FromSlash(name)) path := filepath.Join(directory, filepath.FromSlash(name))

45
candidate_build.go Normal file
View File

@@ -0,0 +1,45 @@
package releasekit
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
)
func candidateBuildCallback(spec *BuildSpec, output io.Writer) func(context.Context, candidate.Candidate) error {
if spec == nil || len(spec.Targets) == 0 {
return nil
}
selected := *spec
selected.Targets = append([]Target(nil), spec.Targets...)
return func(ctx context.Context, captured candidate.Candidate) error {
temporaryDir, err := os.MkdirTemp(filepath.Dir(captured.RepositoryDir), ".releasekit-candidate-build-")
if err != nil {
return fmt.Errorf("create temporary candidate build output: %w", err)
}
defer os.RemoveAll(temporaryDir)
relative, err := filepath.Rel(captured.RepositoryDir, temporaryDir)
if err != nil {
return fmt.Errorf("verify temporary candidate build output: %w", err)
}
if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("temporary candidate build output %q is inside the repository", temporaryDir)
}
_, err = Build(ctx, BuildRequest{
RepositoryDir: captured.RepositoryDir,
Version: captured.Version,
Spec: selected,
OutputDir: temporaryDir,
Output: output,
})
if err != nil {
return fmt.Errorf("build candidate: %w", err)
}
return nil
}
}

View File

@@ -18,23 +18,26 @@ type Candidate struct {
Version string Version string
} }
// ReleaseRequest contains the local inputs used by Check. Remote is retained // ReleaseRequest contains the inputs used to validate and optionally build one
// for publication operations; Check makes no remote availability claim. // local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
Validate func(context.Context, Candidate) error Validate func(context.Context, Candidate) error
Build *BuildSpec // optional selected-target build after validation.
Output io.Writer Output io.Writer
} }
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // worktree and index on main, a committed note matching Product and Version,
// and an unused local tag. If Validate is nil, the offline Go test suite runs. // and an unused local tag. If Validate is nil, the offline Go test suite runs.
// It rechecks branch, HEAD, worktree, index, and local tag state after // If Build selects targets, Check builds them after validation in temporary
// validation. Success establishes local candidate evidence only; it does not // storage outside the worktree, then rechecks branch, HEAD, worktree, index,
// establish remote availability. // and local tag state. Success establishes local candidate evidence only; it
// does not establish remote availability.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
var validate func(context.Context, candidate.Candidate) error var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil { if request.Validate != nil {
@@ -51,6 +54,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validate: validate, Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output),
Output: request.Output, Output: request.Output,
}) })
if err != nil { if err != nil {

View File

@@ -16,7 +16,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
) )
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION"
type Command string type Command string
@@ -24,6 +24,7 @@ const (
VersionCommand Command = "version" VersionCommand Command = "version"
CheckAction Command = "check" CheckAction Command = "check"
PublishAction Command = "publish" PublishAction Command = "publish"
BuildAction Command = "build"
) )
// Invocation contains parsed command settings owned by the CLI adapter. // Invocation contains parsed command settings owned by the CLI adapter.
@@ -31,6 +32,7 @@ type Invocation struct {
Command Command Command Command
Repository string Repository string
ConfigPath string ConfigPath string
OutputDir string
Version string Version string
} }
@@ -45,19 +47,23 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments") return Invocation{}, errors.New("version takes no arguments")
} }
return Invocation{Command: VersionCommand}, nil return Invocation{Command: VersionCommand}, nil
case string(CheckAction), string(PublishAction): case string(CheckAction), string(PublishAction), string(BuildAction):
action := Command(args[0]) action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError) flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard) flags.SetOutput(io.Discard)
repository := flags.String("repo", ".", "repository directory") repository := flags.String("repo", ".", "repository directory")
configPath := flags.String("config", "", "profile path") configPath := flags.String("config", "", "profile path")
var outputDir string
if action == BuildAction {
flags.StringVar(&outputDir, "output", "", "build output directory")
}
if err := flags.Parse(args[1:]); err != nil { if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err return Invocation{}, err
} }
if flags.NArg() != 1 { if flags.NArg() != 1 {
return Invocation{}, fmt.Errorf("%s requires one version argument", action) return Invocation{}, fmt.Errorf("%s requires one version argument", action)
} }
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, Version: flags.Arg(0)}, nil return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, Version: flags.Arg(0)}, nil
default: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }
@@ -75,6 +81,14 @@ type Settings struct {
Product string `json:"product"` Product string `json:"product"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
}
// BuildSettings selects the Go application package, artifact name, and targets.
type BuildSettings struct {
MainPackage string `json:"main_package,omitempty"`
BinaryName string `json:"binary_name,omitempty"`
Targets []string `json:"targets,omitempty"`
} }
// LoadSettings reads a profile path relative to repoDir unless it is absolute. // LoadSettings reads a profile path relative to repoDir unless it is absolute.
@@ -122,6 +136,31 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i) return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i)
} }
} }
if settings.Build != nil {
if len(settings.Build.Targets) != 0 {
if strings.TrimSpace(settings.Build.MainPackage) == "" {
return Settings{}, fmt.Errorf("profile %q: build.main_package is required when targets are selected", configPath)
}
if strings.TrimSpace(settings.Build.BinaryName) == "" {
return Settings{}, fmt.Errorf("profile %q: build.binary_name is required when targets are selected", configPath)
}
if strings.ContainsAny(settings.Build.BinaryName, "/\\\r\n") {
return Settings{}, fmt.Errorf("profile %q: build.binary_name must be a filename component", configPath)
}
}
for i, target := range settings.Build.Targets {
if strings.Count(target, "/") != 1 {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i)
}
goos, goarch, _ := strings.Cut(target, "/")
if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i)
}
if strings.ContainsAny(goos+goarch, "\\\r\n") {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] contains an unsafe filename component", configPath, i)
}
}
}
return settings, nil return settings, nil
} }

View File

@@ -33,6 +33,13 @@ func TestParse(t *testing.T) {
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish) t.Fatalf("Parse(publish) = %#v", publish)
} }
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
if err != nil {
t.Fatal(err)
}
if build.Command != BuildAction || build.Repository != "candidate" || build.ConfigPath != "releasekit.json" || build.OutputDir != "output with spaces" || build.Version != "v1.2.5" {
t.Fatalf("Parse(build) = %#v", build)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil { if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version") t.Fatal("Parse accepted a flag after the version")
} }
@@ -121,6 +128,32 @@ func TestExampleProfileLoads(t *testing.T) {
} }
} }
func TestBuildProfileLoads(t *testing.T) {
repoDir := t.TempDir()
path := filepath.Join(repoDir, "profile.json")
if err := os.WriteFile(path, []byte(`{"product":"Example","build":{"main_package":"./cmd/app","binary_name":"example","targets":["linux/amd64","darwin/arm64"]}}`), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, path)
if err != nil {
t.Fatal(err)
}
if settings.Build == nil || settings.Build.MainPackage != "./cmd/app" || settings.Build.BinaryName != "example" || len(settings.Build.Targets) != 2 {
t.Fatalf("build settings = %#v", settings.Build)
}
repoDir, err = filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil {
t.Fatal(err)
}
example, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit-binary.json"))
if err != nil {
t.Fatal(err)
}
if example.Build == nil || len(example.Build.Targets) != 3 {
t.Fatalf("binary example settings = %#v", example.Build)
}
}
func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) { func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -129,7 +162,9 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
}{ }{
{name: "missing product", content: `{}`, want: "product is required"}, {name: "missing product", content: `{}`, want: "product is required"},
{name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"}, {name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"},
{name: "unsupported build field", content: `{"product":"Example","build":{}}`, want: "unknown field"}, {name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"}, {name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"}, {name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},
{name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"}, {name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"},

View File

@@ -0,0 +1,267 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
const BuildReceiptName = ".releasekit-build.json"
type ReceiptTarget struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
type ReceiptArtifact struct {
Path string `json:"path"`
Filename string `json:"filename"`
Target ReceiptTarget `json:"target"`
SHA256 string `json:"sha256"`
}
// BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct {
RepositoryDir string `json:"repository_dir"`
CandidateCommit string `json:"candidate_commit"`
Version string `json:"version"`
MainPackage string `json:"main_package"`
BinaryName string `json:"binary_name"`
OutputDir string `json:"output_dir"`
Targets []ReceiptTarget `json:"targets"`
Compiler string `json:"compiler"`
Artifacts []ReceiptArtifact `json:"artifacts"`
}
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
resolvedDir, err := filepath.Abs(outputDir)
if err != nil {
return "", fmt.Errorf("resolve build receipt directory: %w", err)
}
receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil {
return "", err
}
contents, err := json.MarshalIndent(receipt, "", " ")
if err != nil {
return "", fmt.Errorf("encode build receipt: %w", err)
}
contents = append(contents, '\n')
temporary, err := os.CreateTemp(resolvedDir, ".releasekit-build-receipt-*")
if err != nil {
return "", fmt.Errorf("create temporary build receipt: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err := temporary.Write(contents); err != nil {
_ = temporary.Close()
return "", fmt.Errorf("write build receipt: %w", err)
}
if err := temporary.Close(); err != nil {
return "", fmt.Errorf("close build receipt: %w", err)
}
path := filepath.Join(resolvedDir, BuildReceiptName)
if err := os.Rename(temporaryPath, path); err != nil {
if _, statErr := os.Lstat(path); statErr != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
if removeErr := os.Remove(path); removeErr != nil {
return "", fmt.Errorf("replace prior build receipt: %w", removeErr)
}
if err := os.Rename(temporaryPath, path); err != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
}
return path, nil
}
func LoadBuildReceipt(path string) (BuildReceipt, error) {
resolvedPath, err := filepath.Abs(path)
if err != nil {
return BuildReceipt{}, fmt.Errorf("resolve build receipt path: %w", err)
}
if filepath.Base(resolvedPath) != BuildReceiptName {
return BuildReceipt{}, fmt.Errorf("build receipt filename must be %s", BuildReceiptName)
}
file, err := os.Open(resolvedPath)
if err != nil {
return BuildReceipt{}, fmt.Errorf("open build receipt: %w", err)
}
defer file.Close()
var receipt BuildReceipt
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&receipt); err != nil {
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return BuildReceipt{}, errors.New("decode build receipt: multiple JSON values")
}
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
if err := validateBuildReceipt(receipt, true); err != nil {
return BuildReceipt{}, err
}
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) {
return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
}
return receipt, nil
}
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
resolvedRepo, err := filepath.Abs(repositoryDir)
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 {
return false
}
want := normalizeReceiptTargets(settings.Targets)
if len(want) != len(receipt.Targets) {
return false
}
for i, target := range want {
if target != receipt.Targets[i] {
return false
}
}
return true
}
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
}
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) {
return errors.New("build receipt repository and output directories must be absolute")
}
if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit")
}
if receipt.MainPackage == "" || !safeReceiptComponent(receipt.BinaryName) || receipt.Compiler == "" {
return errors.New("build receipt is missing build identity")
}
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))
for _, target := range receipt.Targets {
if !safeReceiptComponent(target.OS) || !safeReceiptComponent(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
}
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 := receiptArtifactFilename(receipt.BinaryName, receipt.Version, artifact.Target)
if artifact.Filename != wantName || 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)
if !filepath.IsAbs(artifact.Path) || filepath.Clean(artifact.Path) != filepath.Clean(wantPath) {
return fmt.Errorf("build receipt artifact path does not match filename %q", artifact.Filename)
}
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)
if err != nil {
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
}
file, err := os.Open(artifact.Path)
if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, 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)
}
return nil
}
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
seen := make(map[ReceiptTarget]bool, len(targets))
result := make([]ReceiptTarget, 0, len(targets))
for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/")
if !ok {
continue
}
target := ReceiptTarget{OS: goos, Arch: goarch}
if !seen[target] {
seen[target] = true
result = append(result, target)
}
}
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 {
if len(value) != 40 && len(value) != 64 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}

View File

@@ -0,0 +1,84 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
outputDir := t.TempDir()
repositoryDir := t.TempDir()
filename := "example-v1.2.3-linux-amd64"
artifactPath := filepath.Join(outputDir, filename)
contents := []byte("built application")
if err := os.WriteFile(artifactPath, contents, 0o755); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(contents)
receipt := BuildReceipt{
RepositoryDir: repositoryDir,
CandidateCommit: strings.Repeat("a", 40),
Version: "v1.2.3",
MainPackage: "./cmd/example",
BinaryName: "example",
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
Compiler: "go version go1.25.0 linux/amd64",
Artifacts: []ReceiptArtifact{{
Path: artifactPath,
Filename: filename,
Target: ReceiptTarget{OS: "linux", Arch: "amd64"},
SHA256: hex.EncodeToString(digest[:]),
}},
}
path, err := WriteBuildReceipt(outputDir, receipt)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadBuildReceipt(path)
if err != nil {
t.Fatal(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 || !strings.Contains(err.Error(), "hash changed") {
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err)
}
}
func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
for _, test := range []struct {
name string
body string
want string
}{
{name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"},
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), BuildReceiptName)
if err := os.WriteFile(path, []byte(test.body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("LoadBuildReceipt() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"gitea.maximumdirect.net/eric/ReleaseKit" "gitea.maximumdirect.net/eric/ReleaseKit"
@@ -43,6 +44,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
Remote: settings.Remote, Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr, Output: stderr,
} }
if len(settings.Checks) != 0 { if len(settings.Checks) != 0 {
@@ -80,6 +82,100 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit) fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit)
return 0 return 0
case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for %s at %s\n", result.Version, result.CandidateCommit)
return 0
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
fmt.Fprintf(stdout, "built %d artifacts for %s at %s with %s\n", len(result.Artifacts), result.Version, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
} }
return 2 return 2
} }
func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
if settings == nil {
return nil
}
spec := &releasekit.BuildSpec{
MainPackage: settings.MainPackage,
BinaryName: settings.BinaryName,
Targets: make([]releasekit.Target, 0, len(settings.Targets)),
}
for _, value := range settings.Targets {
goos, goarch, _ := strings.Cut(value, "/")
spec.Targets = append(spec.Targets, releasekit.Target{OS: goos, Arch: goarch})
}
return spec
}
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{
RepositoryDir: result.RepositoryDir,
CandidateCommit: result.CandidateCommit,
Version: result.Version,
MainPackage: result.MainPackage,
BinaryName: result.BinaryName,
OutputDir: result.OutputDir,
Compiler: result.Compiler,
Targets: make([]cli.ReceiptTarget, 0, len(result.Targets)),
Artifacts: make([]cli.ReceiptArtifact, 0, len(result.Artifacts)),
}
for _, target := range result.Targets {
receipt.Targets = append(receipt.Targets, cli.ReceiptTarget{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range result.Artifacts {
receipt.Artifacts = append(receipt.Artifacts, cli.ReceiptArtifact{
Path: artifact.Path,
Filename: artifact.Filename,
Target: cli.ReceiptTarget{OS: artifact.Target.OS, Arch: artifact.Target.Arch},
SHA256: artifact.SHA256,
})
}
return receipt
}

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -128,6 +129,75 @@ func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
} }
} }
func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) {
repository := newCLIBuildRepository(t, `package main
import (
"fmt"
"os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
)
func main() {
if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) }
}
`)
var stdout, stderr bytes.Buffer
args := []string{"build", "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
receiptPath := filepath.Join(repository, "dist", cli.BuildReceiptName)
receipt, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
t.Fatal(err)
}
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
settings := cli.BuildSettings{
MainPackage: "./cmd/app",
BinaryName: "fixture",
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
}
if !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 {
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)) {
t.Fatalf("stdout does not report receipt artifacts: %q", stdout.String())
}
if got := cliGitOutput(t, repository, "status", "--porcelain"); got != "" {
t.Fatalf("build output dirtied candidate: %s", got)
}
}
func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) {
repository := newCLIBuildRepository(t, `package main
import (
"fmt"
"os"
)
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("wrong") } }
`)
var stdout, stderr bytes.Buffer
args := []string{"build", "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 1 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q, want 1", status, stdout.String(), stderr.String())
}
if stdout.Len() != 0 || !bytes.Contains(stderr.Bytes(), []byte("native version output")) {
t.Fatalf("stdout=%q stderr=%q; want smoke failure without final output", stdout.String(), stderr.String())
}
if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !os.IsNotExist(err) {
t.Fatalf("partial build wrote receipt: %v", err)
}
filename := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
filename += ".exe"
}
if _, err := os.Stat(filepath.Join(repository, "dist", filename)); err != nil {
t.Fatalf("completed artifact not reported or retained: %v; stderr=%q", err, stderr.String())
}
}
func TestCandidateCheckHelper(t *testing.T) { func TestCandidateCheckHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" { if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" {
return return
@@ -164,6 +234,33 @@ func newCLIRepository(t *testing.T) string {
return repository return repository
} }
func newCLIBuildRepository(t *testing.T, mainSource string) string {
t.Helper()
repository := newCLIRepository(t)
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
write := func(name, contents string) {
t.Helper()
path := filepath.Join(repository, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
write("go.mod", module)
write("cmd/app/main.go", mainSource)
write("releasekit.json", fmt.Sprintf(`{"product":"Example Product","build":{"main_package":"./cmd/app","binary_name":"fixture","targets":[%q]}}`, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)))
write(".gitignore", "dist/\n")
cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "configure build")
return repository
}
func cliGitRun(t *testing.T, repository string, args ...string) { func cliGitRun(t *testing.T, repository string, args ...string) {
t.Helper() t.Helper()
commandArgs := append([]string{"git"}, args...) commandArgs := append([]string{"git"}, args...)

View File

@@ -1,6 +1,6 @@
# Command line # Command line
ReleaseKit currently provides three commands. ReleaseKit currently provides four commands.
Print the executable version: Print the executable version:
@@ -49,3 +49,26 @@ errors, and 3 when the push outcome remains unknown. Unknown status must be
resolved against the authoritative remote before retrying. The resolved against the authoritative remote before retrying. The
[maintainer procedure](release.md) selects the repository profile and gives [maintainer procedure](release.md) selects the repository profile and gives
the source-release workflow. the source-release workflow.
Build selected application targets:
```sh
go run ./cmd/releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION
```
`build` requires a `build` object in the profile. It compiles the selected
targets with the requested version injected, checks the native `version`
invocation, and writes binaries plus the private `.releasekit-build.json`
receipt into `dist` by default. `--output` selects a different directory;
relative paths are resolved from the repository. Existing files with a selected
artifact name are preserved and cause an error. A complete nonempty build
writes a receipt only after compilation and smoke checking succeed. On partial
failure, standard error lists completed outputs and no receipt is written. An
empty target list returns the source commit without building or writing a
receipt.
The command returns status 0 on completion, 1 for build or output failure, and
2 for usage, profile, or invalid-version errors. It builds the current source
tree and does not perform candidate validation or remote publication. `check`
and `publish` run the same configured build after their one candidate
validation, using temporary output outside the worktree.

View File

@@ -11,6 +11,7 @@ The current profile fields are:
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `remote` | No | Selected Git remote; defaults to `origin`. |
| `checks` | No | Ordered child commands, each with an argument vector and optional environment map. | | `checks` | No | Ordered child commands, each with an argument vector and optional environment map. |
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
Each check requires a nonempty `args` array whose first element names the Each check requires a nonempty `args` array whose first element names the
executable. Arguments are passed directly to the child process; ReleaseKit does executable. Arguments are passed directly to the child process; ReleaseKit does
@@ -18,14 +19,20 @@ not parse a shell command string. Select a shell explicitly when a check needs
shell syntax. Each `env` map overlays the current environment for that child. shell syntax. Each `env` map overlays the current environment for that child.
The overlay does not modify the ReleaseKit process environment. The overlay does not modify the ReleaseKit process environment.
Unknown fields are rejected so a setting cannot be silently ignored. Build and Unknown fields are rejected so a setting cannot be silently ignored. The
delivery fields will be added when those operations are implemented. The `check` `build` object contains `main_package`, `binary_name`, and `targets`. A selected
and `publish` commands use `product`, `remote`, and `checks`. If `checks` is target requires the package and binary name; each target is written as one
`GOOS/GOARCH` pair. An omitted or empty target list means no binary work.
The `check` and `publish` commands use `product`, `remote`, and `checks`, and
build selected targets after the single validation callback. If `checks` is
omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module
and toolchain network access disabled. If checks are configured, they replace and toolchain network access disabled. If checks are configured, they replace
that default and run in order. that default and run in order. The `build` command uses the selected build
settings and writes binaries to `dist` unless `--output` selects another path.
ReleaseKit's source-only publication profile is ReleaseKit's source-only publication profile is
[releasekit.json](../releasekit.json). [releasekit.json](../releasekit.json).
See the [profile example](../examples/releasekit.json). The See the [source-only profile](../examples/releasekit.json) and
[binary profile](../examples/releasekit-binary.json). The
[CLI contract](cli.md) lists the currently available command. [CLI contract](cli.md) lists the currently available command.

View File

@@ -60,3 +60,9 @@ An empty target list records the source commit and returns without invoking Go,
creating the output directory, or running a version check. It does not require a creating the output directory, or running a version check. It does not require a
main package or binary name. This supports source-only operations without main package or binary name. This supports source-only operations without
silently adding a binary check. silently adding a binary check.
The [`build` command](../cli.md) reads the same build specification from the
profile and writes a private receipt alongside its artifacts. `check` and
`publish` run selected builds after their one validation callback using
temporary output outside the worktree; those candidate checks do not write a
receipt or leave build files in the repository.

View File

@@ -25,7 +25,8 @@ Implemented internal packages:
- `internal/publish` coordinates local and remote guards around one validation - `internal/publish` coordinates local and remote guards around one validation
run and reports the publication state reached by that invocation. run and reports the publication state reached by that invocation.
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON - `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, and adapts configured checks to the process runner. profiles, adapts configured checks, and writes and validates private local
build receipts.
The executable controller uses the runtime facade and the command-owned CLI The executable controller uses the runtime facade and the command-owned CLI
adapter. The adapter uses the process runner and imports neither public facade. adapter. The adapter uses the process runner and imports neither public facade.

View File

@@ -1,6 +1,6 @@
# ReleaseKit staged implementation plan # ReleaseKit staged implementation plan
Status: In progress. Stages 1–10 are complete; later stages remain pending. Status: In progress. Stages 1–11 are complete; later stages remain pending.
Date: 2026-10-05 Date: 2026-10-05
@@ -714,7 +714,7 @@ cross-platform execution guarantee is added.
## Stage 11: Integrate candidate builds and artifact command ## Stage 11: Integrate candidate builds and artifact command
Status: Pending. Depends on Stage 10. Status: Complete. Depends on Stage 10.
1. Add build profile fields, CLI `build`, and the private artifact receipt. 1. Add build profile fields, CLI `build`, and the private artifact receipt.
Bind receipts to source/build-input identity and generated hashes. Validate Bind receipts to source/build-input identity and generated hashes. Validate

View File

@@ -2,8 +2,8 @@
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
current implemented boundary; CLI build integration, delivery, and broader current implemented boundary; delivery and broader integrations remain
integrations remain planned. The public facade architecture is accepted in planned. The public facade architecture is accepted in
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, [ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
candidate validation, and source publication. candidate validation, and source publication.
The [implementation plan](implementation.md) owns ordered stages, execution The [implementation plan](implementation.md) owns ordered stages, execution
@@ -55,9 +55,9 @@ cleanup.
The [architecture policy](../policy/architecture.md) owns the adopted The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary. responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate The root `releasekit` package provides the public facade for candidate
validation, source publication, and selected-target builds; CLI build validation, source publication, and selected-target builds. Delivery remains
integration and delivery remain planned. The separate public `buildinfo` planned. The separate public `buildinfo` package provides lightweight runtime
package provides lightweight runtime version access. version access.
Each facade owns its public named types and documented contracts. Public Each facade owns its public named types and documented contracts. Public
operations, inputs, outcomes, errors, and callbacks express deliberately operations, inputs, outcomes, errors, and callbacks express deliberately

View File

@@ -0,0 +1,9 @@
{
"product": "Example",
"remote": "origin",
"build": {
"main_package": "./cmd/example",
"binary_name": "example",
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
}
}

View File

@@ -30,6 +30,7 @@ type Request struct {
Product string Product string
Version string Version string
Validate func(context.Context, Candidate) error Validate func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error
Output io.Writer Output io.Writer
} }
@@ -41,6 +42,11 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
if err := Validate(ctx, request, captured); err != nil { if err := Validate(ctx, request, captured); err != nil {
return Candidate{}, err return Candidate{}, err
} }
if request.AfterValidate != nil {
if err := request.AfterValidate(ctx, captured); err != nil {
return Candidate{}, fmt.Errorf("after candidate validation: %w", err)
}
}
if err := Recheck(ctx, captured, ""); err != nil { if err := Recheck(ctx, captured, ""); err != nil {
return Candidate{}, err return Candidate{}, err
} }

View File

@@ -41,6 +41,7 @@ type Request struct {
Version string Version string
Remote string Remote string
Validate func(context.Context, candidate.Candidate) error Validate func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error
Output io.Writer Output io.Writer
} }
@@ -98,6 +99,11 @@ func Publish(ctx context.Context, request Request) (Result, error) {
if err := candidate.Validate(ctx, checks, captured); err != nil { if err := candidate.Validate(ctx, checks, captured); err != nil {
return result, err return result, err
} }
if request.AfterValidate != nil {
if err := request.AfterValidate(ctx, captured); err != nil {
return result, fmt.Errorf("after candidate validation: %w", err)
}
}
if err := candidate.Recheck(ctx, captured, ""); err != nil { if err := candidate.Recheck(ctx, captured, ""); err != nil {
return result, fmt.Errorf("post-validation local guard: %w", err) return result, fmt.Errorf("post-validation local guard: %w", err)
} }

View File

@@ -49,8 +49,9 @@ type PublicationResult struct {
State PublicationState // publication outcome for this invocation State PublicationState // publication outcome for this invocation
} }
// Publish validates the captured candidate and pushes its exact annotated tag // Publish validates the captured candidate, optionally builds its selected
// object to the selected remote. It returns a result with errors: preflight // targets in temporary storage outside the worktree, and pushes its exact
// annotated tag object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and // failures are not_attempted, failed authoritative guards are conflict, and
// errors after local tag creation but before push are not_published. A failed // errors after local tag creation but before push are not_published. A failed
// push gets one bounded authoritative tag query: an exact object match confirms // push gets one bounded authoritative tag query: an exact object match confirms
@@ -74,6 +75,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Version: request.Version, Version: request.Version,
Remote: request.Remote, Remote: request.Remote,
Validate: validate, Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output),
Output: request.Output, Output: request.Output,
}) })
return PublicationResult{ return PublicationResult{