Add local candidate validation
This commit is contained in:
19
README.md
19
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
|
||||
|
||||
64
check.go
Normal file
64
check.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
25
docs/cli.md
25
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 <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.
|
||||
|
||||
@@ -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
34
docs/consumers/check.md
Normal 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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
256
internal/candidate/check.go
Normal 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
|
||||
}
|
||||
321
internal/candidate/check_test.go
Normal file
321
internal/candidate/check_test.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user