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
.history/
# ReleaseKit build output
/dist/
# Built Visual Studio Code Extensions
*.vsix

View File

@@ -3,9 +3,10 @@
ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, selected-target build API,
and `version`, `check`, and `publish` commands are implemented. ReleaseKit's
source-only maintainer procedure is documented. The build command and delivery
remain future work. The [product roadmap](docs/roadmap/releasekit.md) and
and `version`, `check`, `publish`, and `build` commands are implemented.
ReleaseKit's source-only maintainer procedure is documented. Hosted delivery
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
behavior and sequence.
@@ -43,8 +44,8 @@ failure recovery.
version injection, and delivery, with the initial implementation plan.
- [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading.
- [CLI contract](docs/cli.md): the `version`, local `check`, and source
`publish` commands.
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, and `build`
commands.
- [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
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 {
t.Helper()
repository, err := os.MkdirTemp("", "releasekit-build-consumer-")
@@ -355,6 +426,22 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
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) {
t.Helper()
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
}
// ReleaseRequest contains the local inputs used by Check. Remote is retained
// for publication operations; Check makes no remote availability claim.
// ReleaseRequest contains the inputs used to validate and optionally build one
// local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim.
type ReleaseRequest struct {
RepositoryDir string
Product string
Version string
Remote string
Validate func(context.Context, Candidate) error
Build *BuildSpec // optional selected-target build after validation.
Output io.Writer
}
// Check validates one local, committed release candidate. It requires a clean
// 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.
// It rechecks branch, HEAD, worktree, index, and local tag state after
// validation. Success establishes local candidate evidence only; it does not
// establish remote availability.
// If Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks branch, HEAD, worktree, index,
// 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) {
var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil {
@@ -51,6 +54,7 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
Product: request.Product,
Version: request.Version,
Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output),
Output: request.Output,
})
if err != nil {

View File

@@ -16,7 +16,7 @@ import (
"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
@@ -24,6 +24,7 @@ const (
VersionCommand Command = "version"
CheckAction Command = "check"
PublishAction Command = "publish"
BuildAction Command = "build"
)
// Invocation contains parsed command settings owned by the CLI adapter.
@@ -31,6 +32,7 @@ type Invocation struct {
Command Command
Repository string
ConfigPath string
OutputDir string
Version string
}
@@ -45,19 +47,23 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments")
}
return Invocation{Command: VersionCommand}, nil
case string(CheckAction), string(PublishAction):
case string(CheckAction), string(PublishAction), string(BuildAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
repository := flags.String("repo", ".", "repository directory")
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 {
return Invocation{}, err
}
if flags.NArg() != 1 {
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:
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
}
@@ -75,6 +81,14 @@ type Settings struct {
Product string `json:"product"`
Remote string `json:"remote,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.
@@ -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)
}
}
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
}

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" {
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 {
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) {
tests := []struct {
name string
@@ -129,7 +162,9 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
}{
{name: "missing product", content: `{}`, want: "product is required"},
{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: "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"},

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"
"os"
"os/signal"
"strings"
"syscall"
"gitea.maximumdirect.net/eric/ReleaseKit"
@@ -43,6 +44,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr,
}
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)
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
}
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/exec"
"path/filepath"
"runtime"
"strings"
"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) {
if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" {
return
@@ -164,6 +234,33 @@ func newCLIRepository(t *testing.T) string {
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) {
t.Helper()
commandArgs := append([]string{"git"}, args...)

View File

@@ -1,6 +1,6 @@
# Command line
ReleaseKit currently provides three commands.
ReleaseKit currently provides four commands.
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
[maintainer procedure](release.md) selects the repository profile and gives
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. |
| `remote` | No | Selected Git remote; defaults to `origin`. |
| `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
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.
The overlay does not modify the ReleaseKit process environment.
Unknown fields are rejected so a setting cannot be silently ignored. Build and
delivery fields will be added when those operations are implemented. The `check`
and `publish` commands use `product`, `remote`, and `checks`. If `checks` is
Unknown fields are rejected so a setting cannot be silently ignored. The
`build` object contains `main_package`, `binary_name`, and `targets`. A selected
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
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.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.

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
main package or binary name. This supports source-only operations without
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
run and reports the publication state reached by that invocation.
- `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
adapter. The adapter uses the process runner and imports neither public facade.

View File

@@ -1,6 +1,6 @@
# 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
@@ -714,7 +714,7 @@ cross-platform execution guarantee is added.
## 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.
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,
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
current implemented boundary; CLI build integration, delivery, and broader
integrations remain planned. The public facade architecture is accepted in
current implemented boundary; delivery and broader integrations remain
planned. The public facade architecture is accepted in
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
candidate validation, and source publication.
The [implementation plan](implementation.md) owns ordered stages, execution
@@ -55,9 +55,9 @@ cleanup.
The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate
validation, source publication, and selected-target builds; CLI build
integration and delivery remain planned. The separate public `buildinfo`
package provides lightweight runtime version access.
validation, source publication, and selected-target builds. Delivery remains
planned. The separate public `buildinfo` package provides lightweight runtime
version access.
Each facade owns its public named types and documented contracts. Public
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
Version string
Validate func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error
Output io.Writer
}
@@ -41,6 +42,11 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
if err := Validate(ctx, request, captured); err != nil {
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 {
return Candidate{}, err
}

View File

@@ -41,6 +41,7 @@ type Request struct {
Version string
Remote string
Validate func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error
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 {
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 {
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
}
// Publish validates the captured candidate and pushes its exact annotated tag
// object to the selected remote. It returns a result with errors: preflight
// Publish validates the captured candidate, optionally builds its selected
// 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
// 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
@@ -74,6 +75,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Version: request.Version,
Remote: request.Remote,
Validate: validate,
AfterValidate: candidateBuildCallback(request.Build, request.Output),
Output: request.Output,
})
return PublicationResult{