Add local candidate validation

This commit is contained in:
2026-10-05 17:47:50 +00:00
parent be225cf634
commit 617c243923
14 changed files with 939 additions and 34 deletions

View File

@@ -2,9 +2,9 @@
ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
and `releasekit version` command are established; the library operations beyond
runtime version access, remaining CLI commands, and release automation remain
to be implemented. Their selected behavior and sequence are described in the
local candidate check, and `version` and `check` commands are implemented.
Publication, build, delivery, and release automation remain to be implemented.
Their selected behavior and sequence are described in the
[product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md).
@@ -14,22 +14,31 @@ Run the offline Go test suite from the repository root with:
go test ./...
```
The current command prints ReleaseKit's version:
The version command prints ReleaseKit's version:
```sh
go run ./cmd/releasekit version
```
The local candidate check validates a committed release note and runs the
configured checks (or the default offline Go test command):
```sh
go run ./cmd/releasekit check v1.2.3
```
## Documentation
- [ReleaseKit proposal](docs/roadmap/releasekit.md): proposed publication, builds,
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 current `version` command and its output.
- [CLI contract](docs/cli.md): the `version` and local `check` 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`.
- [Candidate check guidance](docs/consumers/check.md): local candidate validation
through the Go API and CLI.
- [Internal package inventory](docs/internal/overview.md): implemented internal
components and their responsibilities.
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency

64
check.go Normal file
View File

@@ -0,0 +1,64 @@
package releasekit
import (
"context"
"io"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
)
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
var ErrInvalidVersion = candidate.ErrInvalidVersion
// Candidate identifies one committed local source revision selected for
// validation.
type Candidate struct {
RepositoryDir string
Commit string
Version string
}
// ReleaseRequest contains the local inputs used by Check. 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
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.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
var validate func(context.Context, candidate.Candidate) error
if request.Validate != nil {
validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.Validate(ctx, Candidate{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
}
}
checked, err := candidate.Check(ctx, candidate.Request{
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
Validate: validate,
Output: request.Output,
})
if err != nil {
return Candidate{}, err
}
return Candidate{
RepositoryDir: checked.RepositoryDir,
Commit: checked.Commit,
Version: checked.Version,
}, nil
}

View File

@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
@@ -15,26 +16,49 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
const Usage = "usage: releasekit version"
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION"
type Command string
const VersionCommand Command = "version"
const (
VersionCommand Command = "version"
CheckAction Command = "check"
)
// Invocation contains parsed command settings owned by the CLI adapter.
type Invocation struct {
Command Command
Command Command
Repository string
ConfigPath string
Version string
}
// Parse accepts only commands that have been implemented by the executable.
func Parse(args []string) (Invocation, error) {
if len(args) == 1 && args[0] == string(VersionCommand) {
return Invocation{Command: VersionCommand}, nil
}
if len(args) == 0 {
return Invocation{}, errors.New("a command is required")
}
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
switch args[0] {
case string(VersionCommand):
if len(args) != 1 {
return Invocation{}, errors.New("version takes no arguments")
}
return Invocation{Command: VersionCommand}, nil
case string(CheckAction):
flags := flag.NewFlagSet("check", flag.ContinueOnError)
flags.SetOutput(io.Discard)
repository := flags.String("repo", ".", "repository directory")
configPath := flags.String("config", "", "profile path")
if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err
}
if flags.NArg() != 1 {
return Invocation{}, errors.New("check requires one version argument")
}
return Invocation{Command: CheckAction, Repository: *repository, ConfigPath: *configPath, Version: flags.Arg(0)}, nil
default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
}
}
// CheckCommand contains one direct child-process invocation from a profile.
@@ -101,12 +125,19 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
// RunChecks runs configured argument vectors in repoDir. Each command receives
// its own environment overlay and inherits the remaining process environment.
func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, stdout, stderr io.Writer) error {
func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error {
for i, check := range checks {
environment := make(map[string]string, len(check.Env)+len(ownedEnv))
for key, value := range check.Env {
environment[key] = value
}
for key, value := range ownedEnv {
environment[key] = value
}
if err := process.Run(ctx, process.Invocation{
Args: check.Args,
Dir: repoDir,
Env: check.Env,
Env: environment,
Stdout: stdout,
Stderr: stderr,
}); err != nil {

View File

@@ -19,6 +19,16 @@ func TestParse(t *testing.T) {
if invocation.Command != VersionCommand {
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
}
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
if err != nil {
t.Fatal(err)
}
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
t.Fatalf("Parse(check) = %#v", check)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version")
}
if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") {
t.Fatalf("Parse(nil) error = %v, want missing-command error", err)
}
@@ -139,12 +149,16 @@ func TestRunChecksUsesConfiguredArgumentsEnvironmentAndDirectory(t *testing.T) {
var stdout, stderr bytes.Buffer
err := RunChecks(context.Background(), workdir, []CheckCommand{{
Args: []string{helperExecutable(t), "-test.run=TestConfiguredCommandHelper", "--", "argument with spaces"},
Env: map[string]string{"RELEASEKIT_CONFIGURED_HELPER": "1", "RELEASEKIT_CONFIGURED_VALUE": "selected value"},
}}, &stdout, &stderr)
Env: map[string]string{
"RELEASEKIT_CONFIGURED_HELPER": "1",
"RELEASEKIT_CONFIGURED_VALUE": "selected value",
"RELEASEKIT_VERSION": "profile value",
},
}}, map[string]string{"RELEASEKIT_VERSION": "captured version", "RELEASEKIT_CANDIDATE": "captured commit"}, &stdout, &stderr)
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("%s|argument with spaces|selected value", workdir)
want := fmt.Sprintf("%s|argument with spaces|selected value|captured version|captured commit", workdir)
if stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
@@ -158,7 +172,7 @@ func TestConfiguredCommandHelper(t *testing.T) {
if err != nil {
os.Exit(9)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s", workdir, os.Args[len(os.Args)-1], os.Getenv("RELEASEKIT_CONFIGURED_VALUE"))
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s", workdir, os.Args[len(os.Args)-1], os.Getenv("RELEASEKIT_CONFIGURED_VALUE"), os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE"))
os.Exit(0)
}

View File

@@ -1,19 +1,27 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
code := run(ctx, os.Args[1:], os.Stdout, os.Stderr)
stop()
os.Exit(code)
}
func run(args []string, stdout, stderr io.Writer) int {
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
invocation, err := cli.Parse(args)
if err != nil {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
@@ -24,6 +32,38 @@ func run(args []string, stdout, stderr io.Writer) int {
case cli.VersionCommand:
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
return 0
case cli.CheckAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Output: stderr,
}
if len(settings.Checks) != 0 {
checks := settings.Checks
request.Validate = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit,
}, stderr, stderr)
}
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed: %v\n", err)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit)
return 0
}
return 2
}

View File

@@ -2,15 +2,22 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestVersionCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
if status := run([]string{"version"}, &stdout, &stderr); status != 0 {
if status := run(context.Background(), []string{"version"}, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d, want 0; stderr = %q", status, stderr.String())
}
if want := fmt.Sprintf("releasekit %s\n", buildinfo.Version()); stdout.String() != want {
@@ -23,13 +30,113 @@ func TestVersionCommand(t *testing.T) {
func TestUnknownCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
if status := run([]string{"publish"}, &stdout, &stderr); status != 2 {
if status := run(context.Background(), []string{"publish"}, &stdout, &stderr); status != 2 {
t.Fatalf("run() status = %d, want 2", status)
}
if stdout.Len() != 0 {
t.Fatalf("unknown command wrote to stdout: %q", stdout.String())
}
if want := "unknown command \"publish\"\nusage: releasekit version\n"; stderr.String() != want {
if want := "unknown command \"publish\"\n" + cli.Usage + "\n"; stderr.String() != want {
t.Fatalf("usage output = %q, want %q", stderr.String(), want)
}
}
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
repository := newCLIRepository(t)
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json")
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Checks: []cli.CheckCommand{{
Args: []string{executable, "-test.run=TestCandidateCheckHelper", "--"},
Env: map[string]string{
"RELEASEKIT_CHECK_HELPER": "1",
"RELEASEKIT_VERSION": "profile version",
"RELEASEKIT_CANDIDATE": "profile commit",
},
}},
}
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(profilePath, contents, 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
args := []string{"check", "--repo", repository, "--config", profilePath, "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())
}
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
if want := "v1.2.3|" + commit; stderr.String() != want {
t.Fatalf("configured check received %q, want captured candidate %q", stderr.String(), want)
}
if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
}
func TestCandidateCheckHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" {
return
}
fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE"))
os.Exit(0)
}
func newCLIRepository(t *testing.T) string {
t.Helper()
repository := filepath.Join(t.TempDir(), "candidate repository")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "init", "--initial-branch=main")
cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test")
cliGitRun(t, repository, "config", "user.email", "releasekit@example.test")
cliGitRun(t, repository, "config", "commit.gpgsign", "false")
write := func(name, value 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(value), 0o600); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n")
write("candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "candidate")
return repository
}
func cliGitRun(t *testing.T, repository string, args ...string) {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("%v: %v\n%s", commandArgs, err, output)
}
}
func cliGitOutput(t *testing.T, repository string, args ...string) string {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.Output()
if err != nil {
t.Fatalf("%v: %v", commandArgs, err)
}
return strings.TrimSpace(string(output))
}

View File

@@ -1,13 +1,30 @@
# Command line
ReleaseKit currently provides one command:
ReleaseKit currently provides two commands.
Print the executable version:
```sh
go run ./cmd/releasekit version
```
It prints `releasekit <version>` followed by a newline and exits with status 0.
This prints `releasekit <version>` followed by a newline and exits with status 0.
The version comes from [`buildinfo.Version`](consumers/runtime-version.md).
Any other argument sequence prints an error message followed by
`usage: releasekit version` to standard error and exits with status 2.
Check a local candidate:
```sh
go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
```
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile
defaults to `<repository>/releasekit.json`. A successful command prints the
captured local commit and version. It checks the committed release note, local
Git state, and configured validation, but does not inspect remote availability
or publish a tag.
Both commands write errors and diagnostic output to standard error. `check`
returns status 0 after local validation, 1 after a candidate validation failure,
and 2 for usage, profile, or invalid-version errors. Unsupported argument
sequences print usage to standard error.

View File

@@ -19,9 +19,11 @@ 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. Profile
loading and child-command adaptation are implemented; no CLI operation consumes
the profile yet.
delivery fields will be added when those operations are implemented. The `check`
command uses `product`, `remote`, and `checks`. 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.
See the [profile example](../examples/releasekit.json). The
[CLI contract](cli.md) lists the currently available command.

34
docs/consumers/check.md Normal file
View File

@@ -0,0 +1,34 @@
# Local candidate check
Use `releasekit.Check` to verify a candidate against the current local
repository state:
```go
result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project",
Product: "Example",
Version: "v1.2.3",
Validate: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir)
},
})
if err != nil {
return err
}
fmt.Println(result.Version, result.Commit)
```
The repository must have a clean index and worktree on `main`, and the selected
version must be stable `vMAJOR.MINOR.PATCH`. The committed
`docs/releases/<version>.md` note must have a first Markdown H1 containing the
product name and exact version as complete title elements. No local tag with
that version may already exist.
The callback receives the captured repository, commit, and version. ReleaseKit
calls it once. When omitted, ReleaseKit runs the offline Go test command. The
check revalidates the branch, commit, and cleanliness after validation.
A successful check is evidence about the local candidate only. It does not
check whether a remote branch or tag is available and does not create a tag.
The [`check` command](../cli.md) reads the product and optional validation
commands from `releasekit.json`.

View File

@@ -49,9 +49,9 @@ root with:
go run ./internal/apicheck
```
See the [internal package inventory](internal/overview.md) and
[implementation plan](roadmap/implementation.md) for current implementation
progress.
See the [candidate check guide](consumers/check.md), [internal package
inventory](internal/overview.md), and [implementation plan](roadmap/implementation.md)
for current implementation progress.
For prose changes, review the diff and check local links, fragment destinations,
provenance, template residue, and consistency with current repository contents.

View File

@@ -14,6 +14,9 @@ Implemented internal packages:
direction. Run it with `go run ./internal/apicheck` from the repository root.
- `internal/process` runs cancellable child commands with per-child environment
overlays.
- `internal/candidate` captures and validates one clean local candidate, checks
its committed release note and local tag state, runs validation once, and
rechecks mutable repository state.
- `cmd/releasekit/internal/cli` parses implemented commands, loads strict JSON
profiles, and adapts configured checks to the process runner.

View File

@@ -538,7 +538,7 @@ internal package documentation.
## Stage 5: Capture and validate one local candidate
Status: Pending. Depends on Stage 4.
Status: Complete. Depends on Stage 4.
1. Implement stable-version syntax, clean `main` and index/worktree inspection,
captured HEAD, profile snapshot/note loading, title identity, and unused local
@@ -558,6 +558,13 @@ HEAD, callback failure, and a callback changing HEAD or the worktree. Count a
successful callback once. No test needs a publication service, and `Check`
creates no tag or remote ref.
Evidence: `internal/candidate` validates stable versions and committed note
identity, captures a clean `main` commit, runs either the offline default or the
configured callback once, and rechecks local mutable state. The public
`releasekit.Check` API and `releasekit check` command provide the local
diagnostic. Temporary Git repositories cover the acceptance cases; offline
`go test -count=1 ./...` and `go run ./internal/apicheck` pass.
## Stage 6: Resolve and inspect the authoritative endpoint
Status: Pending. Depends on Stage 5.

256
internal/candidate/check.go Normal file
View File

@@ -0,0 +1,256 @@
// Package candidate captures and validates a committed local release candidate.
package candidate
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
"sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
var ErrInvalidVersion = errors.New("invalid stable version")
type Candidate struct {
RepositoryDir string
Commit string
Version string
}
type Request struct {
RepositoryDir string
Product string
Version string
Validate func(context.Context, Candidate) error
Output io.Writer
}
func Check(ctx context.Context, request Request) (Candidate, error) {
if !validStableVersion(request.Version) {
return Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
}
if strings.TrimSpace(request.Product) == "" {
return Candidate{}, errors.New("product is required")
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
if err != nil {
return Candidate{}, fmt.Errorf("resolve repository directory: %w", err)
}
state, err := inspectLocalState(ctx, repositoryDir)
if err != nil {
return Candidate{}, err
}
if state.branch != "main" {
return Candidate{}, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
}
if state.status != "" {
return Candidate{}, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
}
path := "docs/releases/" + request.Version + ".md"
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
if err != nil {
return Candidate{}, fmt.Errorf("read committed release note %q: %w", path, err)
}
if err := validateNote(note, request.Product, request.Version); err != nil {
return Candidate{}, fmt.Errorf("release note %q: %w", path, err)
}
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err)
} else if exists {
return Candidate{}, fmt.Errorf("local tag %q already exists", request.Version)
}
captured := Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version}
if request.Validate == nil {
err = runDefaultValidation(ctx, captured.RepositoryDir, request.Output)
} else {
err = request.Validate(ctx, captured)
}
if err != nil {
return Candidate{}, fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
}
current, err := inspectLocalState(ctx, repositoryDir)
if err != nil {
return Candidate{}, fmt.Errorf("recheck local candidate state: %w", err)
}
if current.branch != "main" {
return Candidate{}, fmt.Errorf("release candidate branch changed during validation to %q", current.branch)
}
if current.commit != captured.Commit {
return Candidate{}, fmt.Errorf("HEAD changed during validation from %s to %s", captured.Commit, current.commit)
}
if current.status != "" {
return Candidate{}, fmt.Errorf("worktree or index changed during validation:\n%s", current.status)
}
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil {
return Candidate{}, fmt.Errorf("recheck local tag %q: %w", request.Version, err)
} else if exists {
return Candidate{}, fmt.Errorf("local tag %q appeared during validation", request.Version)
}
return captured, nil
}
type localState struct {
branch string
commit string
status string
}
func inspectLocalState(ctx context.Context, repositoryDir string) (localState, error) {
branchOutput, err := gitOutput(ctx, repositoryDir, "branch", "--show-current")
if err != nil {
return localState{}, fmt.Errorf("inspect current branch: %w", err)
}
commitOutput, err := gitOutput(ctx, repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return localState{}, fmt.Errorf("capture HEAD commit: %w", err)
}
statusOutput, err := gitOutput(ctx, repositoryDir, "status", "--porcelain=v1", "--untracked-files=all")
if err != nil {
return localState{}, fmt.Errorf("inspect worktree and index: %w", err)
}
return localState{
branch: strings.TrimSpace(string(branchOutput)),
commit: strings.TrimSpace(string(commitOutput)),
status: strings.TrimSpace(string(statusOutput)),
}, nil
}
func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byte, error) {
var stdout, stderr bytes.Buffer
commandArgs := append([]string{"git"}, args...)
err := process.Run(ctx, process.Invocation{
Args: commandArgs,
Dir: repositoryDir,
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
message := strings.TrimSpace(stderr.String())
if message != "" {
return nil, fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), message, err)
}
return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return stdout.Bytes(), nil
}
func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, error) {
var stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + version},
Dir: repositoryDir,
Stderr: &stderr,
})
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
message := strings.TrimSpace(stderr.String())
if message != "" {
return false, fmt.Errorf("%s: %w", message, err)
}
return false, err
}
func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
environment := map[string]string{
"GOWORK": "off",
"GOPROXY": "off",
"GONOPROXY": "none",
"GOSUMDB": "off",
"GOTOOLCHAIN": "local",
}
var writer io.Writer = output
if output != nil {
writer = &synchronizedWriter{writer: output}
}
return process.Run(ctx, process.Invocation{
Args: []string{"go", "test", "-mod=readonly", "-count=1", "./..."},
Dir: repositoryDir,
Env: environment,
Stdout: writer,
Stderr: writer,
})
}
type synchronizedWriter struct {
mu sync.Mutex
writer io.Writer
}
func (w *synchronizedWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.writer.Write(p)
}
func validStableVersion(version string) bool {
if !strings.HasPrefix(version, "v") {
return false
}
components := strings.Split(version[1:], ".")
if len(components) != 3 {
return false
}
for _, component := range components {
if component == "" || len(component) > 1 && component[0] == '0' {
return false
}
for i := 0; i < len(component); i++ {
if component[i] < '0' || component[i] > '9' {
return false
}
}
}
return true
}
func validateNote(note []byte, product, version string) error {
line := string(note)
if firstLine, _, ok := strings.Cut(line, "\n"); ok {
line = firstLine
}
line = strings.TrimSuffix(line, "\r")
if !strings.HasPrefix(line, "# ") {
return errors.New("first line must be a level-one Markdown heading")
}
elements := strings.Fields(strings.TrimPrefix(line, "# "))
productElements := strings.Fields(product)
if !containsSequence(elements, productElements) || !containsSequence(elements, []string{version}) {
return fmt.Errorf("first heading must contain product %q and exact version %q as title elements", product, version)
}
return nil
}
func containsSequence(elements, sequence []string) bool {
if len(sequence) == 0 || len(sequence) > len(elements) {
return false
}
for start := 0; start+len(sequence) <= len(elements); start++ {
match := true
for i, element := range sequence {
if elements[start+i] != element {
match = false
break
}
}
if match {
return true
}
}
return false
}

View File

@@ -0,0 +1,321 @@
package candidate
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const testVersion = "v1.2.3"
func TestValidStableVersion(t *testing.T) {
for _, version := range []string{
"v0.0.0",
"v1.2.3",
"v123456789012345678901234567890.0.9",
} {
if !validStableVersion(version) {
t.Errorf("validStableVersion(%q) = false", version)
}
}
for _, version := range []string{
"",
"1.2.3",
"V1.2.3",
"v01.2.3",
"v1.02.3",
"v1.2.03",
"v1.2",
"v1.2.3-alpha",
"v1..3",
"v+1.2.3",
} {
if validStableVersion(version) {
t.Errorf("validStableVersion(%q) = true", version)
}
}
}
func TestCheckCapturesCandidateAndCallsValidationOnce(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
calls := 0
var seen Candidate
candidate, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validate: func(_ context.Context, captured Candidate) error {
calls++
seen = captured
return nil
},
})
if err != nil {
t.Fatal(err)
}
commit := gitOutputForTest(t, repository, "rev-parse", "HEAD")
if calls != 1 {
t.Fatalf("validation callback called %d times, want 1", calls)
}
if seen != candidate {
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
}
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion {
t.Fatalf("Check() candidate = %#v", candidate)
}
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
t.Fatalf("Check created local tags: %q", tags)
}
if refs := gitOutputForTest(t, repository, "for-each-ref", "--format=%(refname)", "refs/remotes"); refs != "" {
t.Fatalf("Check created remote-tracking refs: %q", refs)
}
}
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Output: &output,
})
if err != nil {
t.Fatalf("Check() with default validation: %v\n%s", err, output.String())
}
}
func TestCheckRejectsInvalidVersionsBeforeGitAccess(t *testing.T) {
for _, version := range []string{"v01.2.3", "v1.2.3-alpha", "1.2.3"} {
t.Run(version, func(t *testing.T) {
_, err := Check(context.Background(), Request{Version: version})
if !errors.Is(err, ErrInvalidVersion) {
t.Fatalf("Check() error = %v, want ErrInvalidVersion", err)
}
})
}
}
func TestCheckRequiresMatchingCommittedNote(t *testing.T) {
tests := []struct {
name string
note string
}{
{name: "missing note"},
{name: "wrong product", note: "# Other Product v1.2.3\n"},
{name: "wrong version", note: "# Example Product v1.2.4\n"},
{name: "embedded product text", note: "# Example Products v1.2.3\n"},
{name: "not a level one heading", note: "## Example Product v1.2.3\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, test.note)
called := false
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validate: func(context.Context, Candidate) error {
called = true
return nil
},
})
if err == nil {
t.Fatal("Check() succeeded with an invalid committed note")
}
if called {
t.Fatal("validation ran before the committed note passed")
}
})
}
}
func TestCheckRejectsDirtyTrackedAndUntrackedState(t *testing.T) {
for _, test := range []struct {
name string
path string
value string
}{
{name: "tracked", path: "tracked.txt", value: "changed\n"},
{name: "untracked", path: "new file.txt", value: "untracked\n"},
} {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
if err := os.WriteFile(filepath.Join(repository, test.path), []byte(test.value), 0o600); err != nil {
t.Fatal(err)
}
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "must be clean") {
t.Fatalf("Check() error = %v, want dirty state error", err)
}
})
}
}
func TestCheckRequiresMainBranchAndUnusedTag(t *testing.T) {
t.Run("detached HEAD", func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
gitRun(t, repository, "checkout", "--detach", "HEAD")
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "must be on main") {
t.Fatalf("Check() error = %v, want branch error", err)
}
})
t.Run("existing local tag", func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
gitRun(t, repository, "tag", testVersion)
_, err := Check(context.Background(), validRequest(repository))
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("Check() error = %v, want existing-tag error", err)
}
})
}
func TestCheckStopsOnCallbackFailure(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
want := errors.New("selected check failed")
calls := 0
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validate: func(context.Context, Candidate) error {
calls++
return want
},
})
if !errors.Is(err, want) {
t.Fatalf("Check() error = %v, want wrapped callback error", err)
}
if calls != 1 {
t.Fatalf("validation callback called %d times, want 1", calls)
}
}
func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
tests := []struct {
name string
validate func(*testing.T, string)
want string
}{
{
name: "HEAD changes",
validate: func(t *testing.T, repository string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "add", "later.txt")
gitRun(t, repository, "-c", "commit.gpgsign=false", "commit", "-m", "advance HEAD")
},
want: "HEAD changed during validation",
},
{
name: "worktree changes",
validate: func(t *testing.T, repository string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later\n"), 0o600); err != nil {
t.Fatal(err)
}
},
want: "changed during validation",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validate: func(context.Context, Candidate) error {
test.validate(t, repository)
return nil
},
})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Check() error = %v, want text %q", err, test.want)
}
})
}
}
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validate: func(context.Context, Candidate) error {
gitRun(t, repository, "tag", testVersion)
return nil
},
})
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
t.Fatalf("Check() error = %v, want tag recheck error", err)
}
}
func validRequest(repository string) Request {
return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validate: func(context.Context, Candidate) error { return nil }}
}
func newRepository(t *testing.T, note string) string {
t.Helper()
repository := filepath.Join(t.TempDir(), "repository with spaces")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "init", "--initial-branch=main")
gitRun(t, repository, "config", "user.name", "ReleaseKit Test")
gitRun(t, repository, "config", "user.email", "releasekit@example.test")
gitRun(t, repository, "config", "commit.gpgsign", "false")
writeTestFile(t, repository, "go.mod", "module example.com/candidate\n\ngo 1.25.0\n")
writeTestFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeTestFile(t, repository, "tracked.txt", "tracked\n")
if note != "" {
writeTestFile(t, repository, "docs/releases/"+testVersion+".md", note)
}
gitRun(t, repository, "add", "--all")
gitRun(t, repository, "commit", "-m", "candidate")
return repository
}
func writeTestFile(t *testing.T, repository, 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)
}
}
func gitRun(t *testing.T, repository string, args ...string) {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("%v: %v\n%s", commandArgs, err, output)
}
}
func gitOutputForTest(t *testing.T, repository string, args ...string) string {
t.Helper()
commandArgs := append([]string{"git"}, args...)
command := exec.Command(commandArgs[0], commandArgs[1:]...)
command.Dir = repository
output, err := command.Output()
if err != nil {
t.Fatalf("%v: %v", commandArgs, err)
}
return strings.TrimSpace(string(output))
}