From 617c24392312e060c1afdf101f91462e9569dd3c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 5 Oct 2026 17:47:50 +0000 Subject: [PATCH] Add local candidate validation --- README.md | 19 +- check.go | 64 ++++ cmd/releasekit/internal/cli/config.go | 49 +++- cmd/releasekit/internal/cli/config_test.go | 22 +- cmd/releasekit/main.go | 44 ++- cmd/releasekit/main_test.go | 113 +++++++- docs/cli.md | 25 +- docs/config.md | 8 +- docs/consumers/check.md | 34 +++ docs/development.md | 6 +- docs/internal/overview.md | 3 + docs/roadmap/implementation.md | 9 +- internal/candidate/check.go | 256 ++++++++++++++++ internal/candidate/check_test.go | 321 +++++++++++++++++++++ 14 files changed, 939 insertions(+), 34 deletions(-) create mode 100644 check.go create mode 100644 docs/consumers/check.md create mode 100644 internal/candidate/check.go create mode 100644 internal/candidate/check_test.go diff --git a/README.md b/README.md index 9897a7b..12bced9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/check.go b/check.go new file mode 100644 index 0000000..8a5799c --- /dev/null +++ b/check.go @@ -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 +} diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index a1ab84f..4321766 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -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 { diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index 5762df3..0921f5b 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -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) } diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 0932c55..2772421 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -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 } diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 468b613..338baf1 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -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)) +} diff --git a/docs/cli.md b/docs/cli.md index b65cbef..016bcd1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ` followed by a newline and exits with status 0. +This prints `releasekit ` 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 `/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. diff --git a/docs/config.md b/docs/config.md index 8f331be..0b2df91 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/docs/consumers/check.md b/docs/consumers/check.md new file mode 100644 index 0000000..8a8eac6 --- /dev/null +++ b/docs/consumers/check.md @@ -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/.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`. diff --git a/docs/development.md b/docs/development.md index 31c4dfa..898424f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index be5083d..b07c962 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1d05f69..1223fb1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/candidate/check.go b/internal/candidate/check.go new file mode 100644 index 0000000..ac14402 --- /dev/null +++ b/internal/candidate/check.go @@ -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 +} diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go new file mode 100644 index 0000000..bd4ccac --- /dev/null +++ b/internal/candidate/check_test.go @@ -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)) +}