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

@@ -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))
}