Integrate selected builds with candidate workflows
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -105,6 +105,8 @@ $RECYCLE.BIN/
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# ReleaseKit build output
|
||||
/dist/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
11
README.md
11
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`.
|
||||
|
||||
@@ -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
45
candidate_build.go
Normal 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
|
||||
}
|
||||
}
|
||||
14
check.go
14
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
267
cmd/releasekit/internal/cli/receipt.go
Normal file
267
cmd/releasekit/internal/cli/receipt.go
Normal 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
|
||||
}
|
||||
84
cmd/releasekit/internal/cli/receipt_test.go
Normal file
84
cmd/releasekit/internal/cli/receipt_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
25
docs/cli.md
25
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
9
examples/releasekit-binary.json
Normal file
9
examples/releasekit-binary.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"product": "Example",
|
||||
"remote": "origin",
|
||||
"build": {
|
||||
"main_package": "./cmd/example",
|
||||
"binary_name": "example",
|
||||
"targets": ["linux/amd64", "linux/arm64", "darwin/arm64"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user