From e98ee0f7b959d06b873e9692fbed4a60e959baf2 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 5 Oct 2026 19:38:50 +0000 Subject: [PATCH] Integrate selected builds with candidate workflows --- .gitignore | 4 +- README.md | 11 +- build_test.go | 87 +++++++ candidate_build.go | 45 ++++ check.go | 14 +- cmd/releasekit/internal/cli/config.go | 45 +++- cmd/releasekit/internal/cli/config_test.go | 37 ++- cmd/releasekit/internal/cli/receipt.go | 267 ++++++++++++++++++++ cmd/releasekit/internal/cli/receipt_test.go | 84 ++++++ cmd/releasekit/main.go | 96 +++++++ cmd/releasekit/main_test.go | 97 +++++++ docs/cli.md | 25 +- docs/config.md | 17 +- docs/consumers/build.md | 6 + docs/internal/overview.md | 3 +- docs/roadmap/implementation.md | 4 +- docs/roadmap/releasekit.md | 10 +- examples/releasekit-binary.json | 9 + internal/candidate/check.go | 6 + internal/publish/publish.go | 6 + publish.go | 6 +- 21 files changed, 848 insertions(+), 31 deletions(-) create mode 100644 candidate_build.go create mode 100644 cmd/releasekit/internal/cli/receipt.go create mode 100644 cmd/releasekit/internal/cli/receipt_test.go create mode 100644 examples/releasekit-binary.json diff --git a/.gitignore b/.gitignore index 6d14d34..6c4a54d 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,8 @@ $RECYCLE.BIN/ # Local History for Visual Studio Code .history/ +# ReleaseKit build output +/dist/ + # Built Visual Studio Code Extensions *.vsix - diff --git a/README.md b/README.md index e9d9e54..f28fd28 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/build_test.go b/build_test.go index 29bbf00..5a21a20 100644 --- a/build_test.go +++ b/build_test.go @@ -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)) diff --git a/candidate_build.go b/candidate_build.go new file mode 100644 index 0000000..8c68844 --- /dev/null +++ b/candidate_build.go @@ -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 + } +} diff --git a/check.go b/check.go index 8a5799c..af76f01 100644 --- a/check.go +++ b/check.go @@ -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 { diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 781e035..0a77701 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -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 } diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index 2ef8b73..8d6307c 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -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"}, diff --git a/cmd/releasekit/internal/cli/receipt.go b/cmd/releasekit/internal/cli/receipt.go new file mode 100644 index 0000000..b28daeb --- /dev/null +++ b/cmd/releasekit/internal/cli/receipt.go @@ -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 +} diff --git a/cmd/releasekit/internal/cli/receipt_test.go b/cmd/releasekit/internal/cli/receipt_test.go new file mode 100644 index 0000000..936f86b --- /dev/null +++ b/cmd/releasekit/internal/cli/receipt_test.go @@ -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) + } + }) + } +} diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 3cd6d5b..fb30a45 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -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 +} diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 6de6cbd..11f26af 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -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...) diff --git a/docs/cli.md b/docs/cli.md index 487b5ae..764c9a6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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. diff --git a/docs/config.md b/docs/config.md index aaaf97a..b13dc32 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 8d7a560..793a8b3 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -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. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 74aedc6..087aacc 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6a00734..7e645bc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index 2eb0f34..dcdac8f 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -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 diff --git a/examples/releasekit-binary.json b/examples/releasekit-binary.json new file mode 100644 index 0000000..eb71fa9 --- /dev/null +++ b/examples/releasekit-binary.json @@ -0,0 +1,9 @@ +{ + "product": "Example", + "remote": "origin", + "build": { + "main_package": "./cmd/example", + "binary_name": "example", + "targets": ["linux/amd64", "linux/arm64", "darwin/arm64"] + } +} diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 8ccf7fa..fde2b8b 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -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 } diff --git a/internal/publish/publish.go b/internal/publish/publish.go index a97fc29..c191b10 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -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) } diff --git a/publish.go b/publish.go index 2e83571..123ee32 100644 --- a/publish.go +++ b/publish.go @@ -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{