diff --git a/README.md b/README.md index f04ef90..9959110 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ The version command prints ReleaseKit's version: 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): +The local candidate check validates a committed release note, runs the standard +offline Go test suite, and then runs any configured additional checks: ```sh go run ./cmd/releasekit check v1.2.3 @@ -57,6 +57,8 @@ failure recovery. operations and their failure behavior. - [Candidate check guidance](docs/consumers/check.md): local candidate validation through the Go API and CLI. +- [Source publication guidance](docs/consumers/publish.md): validate and publish + the exact annotated source tag through the Go API. - [Maintainer release procedure](docs/release.md): ReleaseKit's source-only publication workflow and recovery. - [Internal package inventory](docs/internal/overview.md): implemented internal diff --git a/build_test.go b/build_test.go index 0d228ea..7f395a6 100644 --- a/build_test.go +++ b/build_test.go @@ -143,6 +143,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt for _, source := range []string{"process environment", "Go environment file"} { t.Run(source, func(t *testing.T) { + unsetBuildEnv(t, "GOCACHE") unsetBuildEnv(t, "GOAMD64") unsetBuildEnv(t, "GOEXPERIMENT") unsetBuildEnv(t, "GOFLAGS") @@ -444,7 +445,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt RepositoryDir: repository.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { + AdditionalChecks: func(context.Context, releasekit.Candidate) error { validations++ return nil }, @@ -476,7 +477,7 @@ func TestPublishBuildFailurePreventsTagCreation(t *testing.T) { RepositoryDir: repository.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { + AdditionalChecks: func(context.Context, releasekit.Candidate) error { validations++ return nil }, @@ -514,6 +515,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string { module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) writeBuildFixture(t, repository, "go.mod", module) writeBuildFixture(t, repository, "cmd/app/main.go", mainSource) + writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n") buildGit(t, repository, "init", "--initial-branch=main") buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test") buildGit(t, repository, "config", "user.email", "releasekit-build@example.test") @@ -532,6 +534,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) { writeFile(t, repository, "go.mod", module) writeFile(t, repository, ".gitignore", "dist/\n") writeFile(t, repository, "cmd/app/main.go", mainSource) + writeFile(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n") gitRun(t, repository, "add", "--all") gitRun(t, repository, "commit", "-m", "add buildable candidate") gitRun(t, repository, "config", "remote.origin.mirror", "false") @@ -637,7 +640,7 @@ func main(){fmt.Println("fixture",buildinfo.Version())} t.Fatal(err) } t.Cleanup(func() { os.Chmod(parent, 0700) }) - _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{RepositoryDir: moved, Product: "Fixture", Version: "v1.2.3", Validate: func(context.Context, releasekit.Candidate) error { return nil }, Build: &releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}}) + _, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{RepositoryDir: moved, Product: "Fixture", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, Build: &releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}}) if err != nil { t.Fatal(err) } diff --git a/check.go b/check.go index 7ff7d85..d50b86c 100644 --- a/check.go +++ b/check.go @@ -23,28 +23,29 @@ type Candidate struct { // local candidate. Remote is retained for publication operations; Check makes // no remote availability claim. type ReleaseRequest struct { - RepositoryDir string - Product string - Version string - Remote string - Validate func(context.Context, Candidate) error - Build *BuildSpec // optional selected-target build after validation. - Output io.Writer + RepositoryDir string + Product string + Version string + Remote string + // AdditionalChecks optionally runs product checks after the standard suite. + AdditionalChecks func(context.Context, Candidate) error + Build *BuildSpec // optional selected-target build after validation. + Output io.Writer } // Check validates one local, committed release candidate. It requires a clean // worktree and index on main, a committed note matching Product and Version, -// and an unused local tag. If Validate is nil, the offline Go test suite runs -// with the available compatible local Go toolchain and the Go host target. -// If Build selects targets, Check builds them after validation in temporary -// storage outside the worktree, then rechecks branch, HEAD, worktree, index, -// and local tag state. Success establishes local candidate evidence only; it -// does not establish remote availability. +// and an unused local tag. It always runs the offline Go test suite with the +// available compatible local Go toolchain and Go host target, then calls +// AdditionalChecks when supplied. If Build selects targets, Check builds them +// after validation in temporary storage outside the worktree, then rechecks +// branch, HEAD, worktree, index, and local tag state. Success establishes local +// candidate evidence only; it does not establish remote availability. func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { var validate func(context.Context, candidate.Candidate) error - if request.Validate != nil { + if request.AdditionalChecks != nil { validate = func(ctx context.Context, captured candidate.Candidate) error { - return request.Validate(ctx, Candidate{ + return request.AdditionalChecks(ctx, Candidate{ RepositoryDir: captured.RepositoryDir, Commit: captured.Commit, Version: captured.Version, @@ -52,12 +53,12 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { } } checked, err := candidate.Check(ctx, candidate.Request{ - RepositoryDir: request.RepositoryDir, - Product: request.Product, - Version: request.Version, - Validate: validate, - AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), - Output: request.Output, + RepositoryDir: request.RepositoryDir, + Product: request.Product, + Version: request.Version, + AdditionalChecks: validate, + AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), + Output: request.Output, }) if err != nil { return Candidate{}, err diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index fd9aeff..78ef582 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -130,7 +130,7 @@ func TestExampleProfileLoads(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.Product != "Example" || settings.Remote != "origin" || len(settings.Checks) != 1 { + if settings.Product != "Example" || settings.Remote != "origin" || len(settings.Checks) != 0 { t.Fatalf("example settings = %#v", settings) } } diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index d669f28..8e0b491 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -50,7 +50,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { } if len(settings.Checks) != 0 { checks := settings.Checks - request.Validate = func(ctx context.Context, candidate releasekit.Candidate) error { + request.AdditionalChecks = 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, diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index 0fa9bfb..ede66bd 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -50,21 +50,27 @@ func TestUnknownCommand(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { repository := newCLIRepository(t) profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") + orderPath := filepath.Join(t.TempDir(), "check-order") + t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath) 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", "--"}, + check := func(name string) cli.CheckCommand { + return cli.CheckCommand{ + Args: []string{executable, "-test.run=TestCandidateCheckHelper", "--", name}, Env: map[string]string{ "RELEASEKIT_CHECK_HELPER": "1", + "RELEASEKIT_CHECK_NAME": name, "RELEASEKIT_VERSION": "profile version", "RELEASEKIT_CANDIDATE": "profile commit", }, - }}, + } + } + settings := cli.Settings{ + Product: "Example Product", + Remote: "origin", + Checks: []cli.CheckCommand{check("first"), check("second")}, } contents, err := json.Marshal(settings) if err != nil { @@ -80,8 +86,11 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { 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 got, err := os.ReadFile(orderPath); err != nil || string(got) != "first\nsecond\n" { + t.Fatalf("configured check order = %q, error = %v", got, err) + } + if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want { + t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String()) } 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) @@ -93,14 +102,6 @@ func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { settings := cli.Settings{ Product: "Example Product", Remote: "origin", - Checks: []cli.CheckCommand{{ - Args: []string{"go", "test", "-mod=readonly", "-count=1", "./..."}, - Env: map[string]string{ - "GOTOOLCHAIN": "local", - "GOPROXY": "off", - "GOSUMDB": "off", - }, - }}, } contents, err := json.Marshal(settings) if err != nil { @@ -207,6 +208,22 @@ func TestCandidateCheckHelper(t *testing.T) { if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" { return } + if orderPath := os.Getenv("RELEASEKIT_CHECK_ORDER_FILE"); orderPath != "" { + file, err := os.OpenFile(orderPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(9) + } + if _, err := fmt.Fprintln(file, os.Getenv("RELEASEKIT_CHECK_NAME")); err != nil { + _ = file.Close() + fmt.Fprintln(os.Stderr, err) + os.Exit(9) + } + if err := file.Close(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(9) + } + } fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE")) os.Exit(0) } diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index 2f23831..afddb20 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -12,6 +12,9 @@ import ( ) func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { + if os.Getenv("RELEASEKIT_SKIP_WRAPPER_TEST") == "1" { + t.Skip("release wrapper is already running candidate validation") + } if runtime.GOOS == "windows" { t.Skip("POSIX release entry point") } @@ -26,6 +29,19 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { if err != nil { t.Fatal(err) } + updatedSources := map[string][]byte{} + for _, path := range []string{ + "check.go", "publish.go", "internal/candidate/check.go", "internal/publish/publish.go", + "build_test.go", "publish_test.go", "internal/candidate/check_test.go", + "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go", + "cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json", + } { + contents, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + updatedSources[path] = contents + } wrapper, err := os.ReadFile(filepath.Join(root, "scripts/release")) if err != nil { t.Fatal(err) @@ -36,6 +52,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { version string }{{"success", 0, "v1.2.3"}, {"invalid", 2, "invalid"}, {"rejected", 1, "v1.2.3"}, {"unknown", 3, "v1.2.3"}} { t.Run(mode.name, func(t *testing.T) { + t.Setenv("RELEASEKIT_SKIP_WRAPPER_TEST", "1") repository := filepath.Join(t.TempDir(), "source") clone := exec.Command(realGit, "clone", "--local", "--no-hardlinks", root, repository) if output, err := clone.CombinedOutput(); err != nil { @@ -51,13 +68,27 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { if err := os.WriteFile(filepath.Join(repository, "cmd/releasekit/main.go"), controller, 0600); err != nil { t.Fatal(err) } + for path, contents := range updatedSources { + if err := os.WriteFile(filepath.Join(repository, filepath.FromSlash(path)), contents, 0600); err != nil { + t.Fatalf("write current source %s: %v", path, err) + } + } if err := os.MkdirAll(filepath.Join(repository, "docs/releases"), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(repository, "docs/releases/v1.2.3.md"), []byte("# ReleaseKit v1.2.3\n"), 0600); err != nil { t.Fatal(err) } - profile := map[string]any{"product": "ReleaseKit", "checks": []map[string]any{{"args": []string{"sh", "-c", "true"}}}} + profile := map[string]any{ + "product": "ReleaseKit", + "checks": []map[string]any{{"args": []string{"sh", "-c", "if [ -n \"$RELEASEKIT_TEST_ARM_GIT_SHIM\" ]; then : > \"$RELEASEKIT_TEST_ARM_GIT_SHIM\"; fi"}}}, + "delivery": map[string]any{ + "base_url": "https://gitea.maximumdirect.net", + "owner": "eric", + "repository": "ReleaseKit", + "token_env": "RELEASEKIT_GITEA_TOKEN", + }, + } content, _ := json.Marshal(profile) if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), content, 0600); err != nil { t.Fatal(err) @@ -80,7 +111,9 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { if mode.name == "unknown" { bin := t.TempDir() marker := filepath.Join(bin, "push-started") + armMarker := filepath.Join(bin, "arm-git-shim") shim := `#!/bin/sh +if [ -f "$RELEASEKIT_TEST_ARM_GIT_SHIM" ]; then for arg do if [ "$arg" = push ]; then "$RELEASEKIT_TEST_REAL_GIT" "$@" || exit 1 @@ -89,7 +122,8 @@ for arg do exit 1 fi done -if [ -f "$RELEASEKIT_TEST_PUSH_MARKER" ]; then +fi +if [ -f "$RELEASEKIT_TEST_ARM_GIT_SHIM" ] && [ -f "$RELEASEKIT_TEST_PUSH_MARKER" ]; then for arg do if [ "$arg" = ls-remote ]; then exit 128; fi; done fi exec "$RELEASEKIT_TEST_REAL_GIT" "$@" @@ -101,6 +135,8 @@ exec "$RELEASEKIT_TEST_REAL_GIT" "$@" t.Setenv("RELEASEKIT_TEST_REAL_GIT", realGit) t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker) t.Setenv("RELEASEKIT_TEST_MOVED_ENDPOINT", fetch) + t.Setenv("RELEASEKIT_TEST_ARM_GIT_SHIM", armMarker) + t.Setenv("RELEASEKIT_SKIP_WRAPPER_TEST", "1") } command := exec.Command("./scripts/release", mode.version) command.Dir = repository diff --git a/docs/cli.md b/docs/cli.md index 1df377c..a769ee4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,8 +24,9 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION 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. +Git state, runs the standard offline Go test suite, and then runs any configured +additional checks in profile order. It does not inspect remote availability or +publish a tag. `check` writes errors and diagnostics to standard error. It returns status 0 after local validation, 1 after a candidate validation failure, and 2 for usage, @@ -38,10 +39,12 @@ Publish a source tag: go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION ``` -`publish` reads the same product, remote, and validation settings as `check`. -It validates the candidate once, checks the authoritative remote, creates an -annotated local tag, and pushes only its exact tag object. It prints the -confirmed version, candidate commit, and tag object on success. Validation +`publish` reads the same product, remote, and additional-check settings as +`check`. It checks the authoritative remote and validates the candidate: the +standard offline Go test suite runs first, followed by configured checks in +profile order. It then creates an annotated local tag and pushes only its exact +tag object. It prints the confirmed version, candidate commit, and tag object +on success. Validation progress and failed publication state, including the captured publication endpoint for recovery, are written to standard error. A signal cancels the active operation; if it arrives during a push, the library's diff --git a/docs/config.md b/docs/config.md index 6acae64..64e3d84 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,7 +10,7 @@ The current profile fields are: | --- | --- | --- | | `product` | Yes | Product name used by release operations. | | `remote` | No | Selected Git remote; defaults to `origin`. | -| `checks` | No | Ordered child commands, each with an argument vector and optional environment map. | +| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | | `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. | @@ -32,12 +32,13 @@ environment variable containing the Gitea token; it defaults to `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and passes its value explicitly to the library operation. -The `check` and `publish` commands use `product`, `remote`, and `checks`, and -build selected targets after the single validation callback. If `checks` is -omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module -and toolchain network access disabled. If checks are configured, they replace -that default and run in order. The `build` command uses the selected build -settings and writes binaries to `dist` unless `--output` selects another path. +The `check` and `publish` commands always run +`go test -mod=readonly -count=1 ./...` with Go module and toolchain network +access disabled. They then run each configured `checks` command once, in order, +as an additional product check. Selected-target builds run after both. Do not +repeat the standard suite in `checks`; doing so runs it twice. The `build` +command uses the selected build settings and writes binaries to `dist` unless +`--output` selects another path. The `deliver` command uses the delivery destination and token, then either builds selected tagged-source targets into temporary storage or loads verified assets from the directory selected by `--assets`. An absent build object or an diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 509d4f0..54d4d65 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -70,9 +70,9 @@ silently adding a binary check. The [`build` command](../cli.md) reads the same build specification from the profile and writes a private receipt alongside its artifacts. `check` and -`publish` run selected builds after their one validation callback using -temporary output outside the worktree; those candidate checks do not write a -receipt or leave build files in the repository. +`publish` run selected builds after the standard suite and any additional +checks using temporary output outside the worktree. Candidate checks do not +write a receipt or leave build files in the repository. For delivery from a tagged checkout, `deliver` builds selected tagged-source targets into temporary storage by default. Use `--assets DIR` to deliver a diff --git a/docs/consumers/check.md b/docs/consumers/check.md index e4ec132..6b581df 100644 --- a/docs/consumers/check.md +++ b/docs/consumers/check.md @@ -8,7 +8,7 @@ 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 { + AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { return runProjectChecks(ctx, candidate.RepositoryDir) }, }) @@ -24,10 +24,9 @@ version must be stable `vMAJOR.MINOR.PATCH`. The committed 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 -`go test -mod=readonly -count=1 ./...` with the available compatible local Go -toolchain; it does not download a different toolchain automatically. The suite +ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available +compatible local Go toolchain, then calls `AdditionalChecks` once when supplied. +It does not download a different toolchain automatically. The standard suite uses the Go host target, disables workspace and module network access, and ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective cache and native cgo/C-tool settings are preserved, including values from Go's @@ -36,18 +35,20 @@ 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 +The [`check` command](../cli.md) reads the product and optional additional commands from `releasekit.json`. ## Publishing the source tag -`releasekit.Publish` uses the same request and performs candidate validation, +`releasekit.Publish` uses the same request and validation order: the standard +suite runs first, followed by `AdditionalChecks` when supplied. It then performs authoritative remote checks, annotated-tag creation, and an exact tag push. The selected remote must publish `main` at the captured commit and must not already contain the selected version tag. A successful result includes the endpoint and tag object ID. Publication states distinguish an unattempted push, a tag that was created locally but not pushed, a successful push, a guard conflict, and an -unresolved push error. +unresolved push error. See the [publication guide](publish.md) for the complete +workflow. When a push reports an error, ReleaseKit makes one bounded query for the authoritative tag. An exact tag object match confirms success. An absent tag is diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md new file mode 100644 index 0000000..93b1560 --- /dev/null +++ b/docs/consumers/publish.md @@ -0,0 +1,41 @@ +# Source tag publication + +Use `releasekit.Publish` to validate a committed candidate and push its exact +annotated source tag: + +```go +result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ + RepositoryDir: "/work/project", + Product: "Example", + Version: "v1.2.3", + AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { + return runProjectChecks(ctx, candidate.RepositoryDir) + }, +}) +if err != nil { + return err +} +fmt.Println(result.Version, result.CandidateCommit, result.TagObject) +``` + +Publication captures a clean candidate on `main`, validates its committed +release note, and checks the selected remote before running candidate checks. +It always runs `go test -mod=readonly -count=1 ./...` with the available +compatible local Go toolchain. It then calls `AdditionalChecks` once when +supplied. The standard suite cannot be replaced; move product-specific behavior +into `AdditionalChecks` and remove repeated standard-suite commands from custom +checks. A failed suite or additional check prevents tag creation, and wrapped +errors preserve their causes. + +If `Build` selects targets, ReleaseKit verifies them after the standard suite +and additional checks, before creating the tag. The operation then rechecks the +candidate and remote guards, creates an annotated tag for the captured commit, +and pushes only that tag object. + +A successful result means Git confirmed the push or an exact authoritative tag +query confirmed it after a push error. The local tag is retained after a failed +push. `PublicationState` distinguishes a known failure, conflict, confirmed +publication, and unresolved outcome. Resolve an unknown result against the +captured endpoint before retrying; see the [maintainer procedure](../release.md) +for the recovery steps. The [CLI contract](../cli.md) documents command output +and exit statuses. diff --git a/docs/development.md b/docs/development.md index 3163933..2314812 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,7 +52,8 @@ root with: go run ./internal/apicheck ``` -See the [candidate check guide](consumers/check.md), [build consumer guide](consumers/build.md), +See the [candidate check guide](consumers/check.md), [source publication guide](consumers/publish.md), +[build consumer guide](consumers/build.md), and [internal package inventory](internal/overview.md) for implemented behavior and capability ownership. The [product roadmap](roadmap/releasekit.md) records remaining adoption and deployment work. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 5dbafec..ec39cc3 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -15,6 +15,7 @@ two Go facades and a command-line interface: See the [CLI contract](../cli.md), [configuration reference](../config.md), [candidate check guide](../consumers/check.md), +[source publication guide](../consumers/publish.md), [build guide](../consumers/build.md), [delivery guide](../consumers/delivery.md), and [runtime version guide](../consumers/runtime-version.md) for task-oriented use. @@ -62,7 +63,7 @@ not a separate release matrix: | Capability | Implementation owner | Existing evidence | | --- | --- | --- | | Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | -| Candidate capture, committed note, one validation, and rechecks | `releasekit.Check`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsValidationOnce`, `TestCheckRechecksHeadAndWorktreeAfterCallback`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate` | +| Candidate capture, standard suite, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | diff --git a/docs/release.md b/docs/release.md index 45ccd05..25fcb19 100644 --- a/docs/release.md +++ b/docs/release.md @@ -54,9 +54,10 @@ Then run the single publication command from the repository root: ./scripts/release vX.Y.Z ``` -The committed `releasekit.json` selects the offline default test suite: +The command always runs the offline default test suite, `go test -mod=readonly -count=1 ./...`, with toolchain and module network -access disabled for validation. The command validates once, checks the +access disabled. Any profile `checks` run afterward as additional product +checks; they should not repeat the standard suite. The command checks the authoritative remote state, creates a local annotated tag from the captured candidate and checked-in note, rechecks the tag and remote guards, and pushes only that tag object. It prints the published version, commit, and tag object diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6649f04..c40a2ae 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: Stages 1 and 2 complete; Stage 3 not started. +Status: Stages 1–3 complete; Stage 4 not started. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -201,6 +201,15 @@ its captured candidate. Reuse existing mutation/recheck tests with the new callback. One CLI profile test covers ordered extra commands. Keep callback unit coverage and end-to-end suite-count coverage at distinct boundaries. +**Completed:** `AdditionalChecks` now runs after the standard offline suite in +the public check and publish flows; selected builds remain after both. The CLI +keeps ordered JSON `checks` as additional commands, and the bundled profiles no +longer repeat the standard suite. Facade tests prove suite failure stops checks +and publication, additional-check errors retain identity, and successful +publication runs both once for its captured candidate. CLI coverage verifies +ordered checks. The full offline suite, API boundary check, and documentation +whitespace checks pass. + ## Stage 4 — Consolidate artifact identity and declaration rules **Covers:** artifact portion of F8. **Primary owners:** `internal/build/`, diff --git a/examples/releasekit.json b/examples/releasekit.json index e4217a1..bbdf66c 100644 --- a/examples/releasekit.json +++ b/examples/releasekit.json @@ -1,14 +1,6 @@ { "product": "Example", "remote": "origin", - "checks": [ - { - "args": ["go", "test", "-count=1", "./..."], - "env": { - "RELEASE_MODE": "candidate" - } - } - ], "delivery": { "base_url": "https://gitea.example", "owner": "example", diff --git a/internal/candidate/check.go b/internal/candidate/check.go index c90c890..9079d88 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -26,12 +26,12 @@ type Candidate struct { } type Request struct { - RepositoryDir string - Product string - Version string - Validate func(context.Context, Candidate) error - AfterValidate func(context.Context, Candidate) error - Output io.Writer + RepositoryDir string + Product string + Version string + AdditionalChecks func(context.Context, Candidate) error + AfterValidate func(context.Context, Candidate) error + Output io.Writer } func Check(ctx context.Context, request Request) (Candidate, error) { @@ -95,18 +95,17 @@ func Capture(ctx context.Context, request Request) (Candidate, error) { return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil } -// Validate runs the complete selected validation once for the captured -// candidate. A supplied callback replaces the default offline Go validation. +// Validate runs the standard offline Go suite, followed by AdditionalChecks +// when supplied, for the captured candidate. func Validate(ctx context.Context, request Request, captured Candidate) error { - var err error - if request.Validate == nil { - err = runDefaultValidation(ctx, captured.RepositoryDir, request.Output) - } else { - err = request.Validate(ctx, captured) - } - if err != nil { + if err := runDefaultValidation(ctx, captured.RepositoryDir, request.Output); err != nil { return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) } + if request.AdditionalChecks != nil { + if err := request.AdditionalChecks(ctx, captured); err != nil { + return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) + } + } return nil } diff --git a/internal/candidate/check_test.go b/internal/candidate/check_test.go index 752126c..e49098c 100644 --- a/internal/candidate/check_test.go +++ b/internal/candidate/check_test.go @@ -41,7 +41,7 @@ func TestValidStableVersion(t *testing.T) { } } -func TestCheckCapturesCandidateAndCallsValidationOnce(t *testing.T) { +func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") calls := 0 var seen Candidate @@ -49,7 +49,7 @@ func TestCheckCapturesCandidateAndCallsValidationOnce(t *testing.T) { RepositoryDir: repository, Product: "Example Product", Version: testVersion, - Validate: func(_ context.Context, captured Candidate) error { + AdditionalChecks: func(_ context.Context, captured Candidate) error { calls++ seen = captured return nil @@ -174,7 +174,7 @@ func TestCheckRequiresMatchingCommittedNote(t *testing.T) { RepositoryDir: repository, Product: "Example Product", Version: testVersion, - Validate: func(context.Context, Candidate) error { + AdditionalChecks: func(context.Context, Candidate) error { called = true return nil }, @@ -230,7 +230,7 @@ func TestCheckRequiresMainBranchAndUnusedTag(t *testing.T) { }) } -func TestCheckStopsOnCallbackFailure(t *testing.T) { +func TestCheckStopsOnAdditionalCheckFailure(t *testing.T) { repository := newRepository(t, "# Example Product v1.2.3\n") want := errors.New("selected check failed") calls := 0 @@ -238,7 +238,7 @@ func TestCheckStopsOnCallbackFailure(t *testing.T) { RepositoryDir: repository, Product: "Example Product", Version: testVersion, - Validate: func(context.Context, Candidate) error { + AdditionalChecks: func(context.Context, Candidate) error { calls++ return want }, @@ -287,7 +287,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) { RepositoryDir: repository, Product: "Example Product", Version: testVersion, - Validate: func(context.Context, Candidate) error { + AdditionalChecks: func(context.Context, Candidate) error { test.validate(t, repository) return nil }, @@ -305,7 +305,7 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) { RepositoryDir: repository, Product: "Example Product", Version: testVersion, - Validate: func(context.Context, Candidate) error { + AdditionalChecks: func(context.Context, Candidate) error { gitRun(t, repository, "tag", testVersion) return nil }, @@ -316,7 +316,7 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) { } func validRequest(repository string) Request { - return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, Validate: func(context.Context, Candidate) error { return nil }} + return Request{RepositoryDir: repository, Product: "Example Product", Version: testVersion, AdditionalChecks: func(context.Context, Candidate) error { return nil }} } func newRepository(t *testing.T, note string) string { diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 751d81b..be365be 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -36,13 +36,13 @@ const ( const recoveryTimeout = 30 * time.Second type Request struct { - RepositoryDir string - Product string - Version string - Remote string - Validate func(context.Context, candidate.Candidate) error - AfterValidate func(context.Context, candidate.Candidate) error - Output io.Writer + RepositoryDir string + Product string + Version string + Remote string + AdditionalChecks func(context.Context, candidate.Candidate) error + AfterValidate func(context.Context, candidate.Candidate) error + Output io.Writer } type Result struct { @@ -55,7 +55,8 @@ type Result struct { } // Publish validates one captured candidate and pushes only its annotated tag -// object. A failed push receives one bounded authoritative tag query. Exact +// object. It runs the standard Go suite, then AdditionalChecks when supplied. +// A failed push receives one bounded authoritative tag query. Exact // object equality confirms publication; absence and conflict are classified, // while an unreadable result remains Unknown and preserves the push error. func Publish(ctx context.Context, request Request) (Result, error) { @@ -65,11 +66,11 @@ func Publish(ctx context.Context, request Request) (Result, error) { remoteName = "origin" } checks := candidate.Request{ - RepositoryDir: request.RepositoryDir, - Product: request.Product, - Version: request.Version, - Validate: request.Validate, - Output: request.Output, + RepositoryDir: request.RepositoryDir, + Product: request.Product, + Version: request.Version, + AdditionalChecks: request.AdditionalChecks, + Output: request.Output, } captured, err := candidate.Capture(ctx, checks) if err != nil { diff --git a/publish.go b/publish.go index 8a1c117..5d3ee3b 100644 --- a/publish.go +++ b/publish.go @@ -28,7 +28,7 @@ type PublicationPhase string const ( // PublicationPreflight identifies local and remote candidate inspection. PublicationPreflight PublicationPhase = PublicationPhase(publish.Preflight) - // PublicationValidation identifies the selected validation callback or default suite. + // PublicationValidation identifies the standard suite and additional checks. PublicationValidation PublicationPhase = PublicationPhase(publish.Validation) // PublicationTagCreation identifies creation and inspection of the local annotated tag. PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) @@ -50,8 +50,9 @@ type PublicationResult struct { State PublicationState // publication outcome for this invocation } -// Publish validates the captured candidate, optionally builds its selected -// targets in temporary storage outside the worktree, and pushes its exact +// Publish always runs the offline Go test suite for the captured candidate, +// then calls AdditionalChecks when supplied. It optionally builds selected +// targets in temporary storage outside the worktree and pushes the exact // annotated tag object to the selected remote. It returns a result with errors: preflight // failures are not_attempted, failed authoritative guards are conflict, and // errors after local tag creation but before push are not_published. A failed @@ -61,9 +62,9 @@ type PublicationResult struct { // by the successful push or exact remote object match. func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { var validate func(context.Context, candidate.Candidate) error - if request.Validate != nil { + if request.AdditionalChecks != nil { validate = func(ctx context.Context, captured candidate.Candidate) error { - return request.Validate(ctx, Candidate{ + return request.AdditionalChecks(ctx, Candidate{ RepositoryDir: captured.RepositoryDir, Commit: captured.Commit, Version: captured.Version, @@ -71,13 +72,13 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er } } result, err := publish.Publish(ctx, publish.Request{ - RepositoryDir: request.RepositoryDir, - Product: request.Product, - Version: request.Version, - Remote: request.Remote, - Validate: validate, - AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), - Output: request.Output, + RepositoryDir: request.RepositoryDir, + Product: request.Product, + Version: request.Version, + Remote: request.Remote, + AdditionalChecks: validate, + AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), + Output: request.Output, }) return PublicationResult{ Version: result.Version, diff --git a/publish_test.go b/publish_test.go index 20106da..64391cf 100644 --- a/publish_test.go +++ b/publish_test.go @@ -21,6 +21,8 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + suiteRuns := filepath.Join(t.TempDir(), "suite-runs") + t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags") commit := repo.commit var validations int @@ -28,8 +30,12 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(_ context.Context, candidate releasekit.Candidate) error { + AdditionalChecks: func(_ context.Context, candidate releasekit.Candidate) error { validations++ + runs, readErr := os.ReadFile(suiteRuns) + if readErr != nil || string(runs) != "ran\n" { + return fmt.Errorf("standard suite runs before additional checks: contents=%q error=%v", runs, readErr) + } if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" { t.Fatalf("callback candidate = %#v", candidate) } @@ -42,6 +48,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { if validations != 1 { t.Fatalf("validation calls = %d, want 1", validations) } + if runs, err := os.ReadFile(suiteRuns); err != nil || string(runs) != "ran\n" { + t.Fatalf("standard suite runs = %q, error = %v; want one run", runs, err) + } if queries := countLines(t, wrapper.queryLog); queries != 3 { t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries) } @@ -84,6 +93,69 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) { } } +func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) { + repo := newPublishFixture(t) + t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") + var checks int + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + checks++ + return nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "fixture standard suite failure") { + t.Fatalf("Publish() error = %v, want fixture standard suite failure", err) + } + if checks != 0 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted { + t.Fatalf("failed standard suite result=%#v additional checks=%d", result, checks) + } + for _, command := range [][]string{ + {"git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, + {"git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, + } { + if _, err := exec.Command(command[0], command[1:]...).CombinedOutput(); err == nil { + t.Fatalf("failed standard suite created release tag with %v", command) + } + } +} + +func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) { + repo := newPublishFixture(t) + suiteRuns := filepath.Join(t.TempDir(), "suite-runs") + t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns) + wantErr := errors.New("additional product check failed") + var checks int + result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { + checks++ + return wantErr + }, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("Publish() error = %v, want wrapped additional-check error", err) + } + if runs, readErr := os.ReadFile(suiteRuns); readErr != nil || string(runs) != "ran\n" { + t.Fatalf("standard suite runs = %q, error = %v; want one successful run", runs, readErr) + } + if checks != 1 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted { + t.Fatalf("additional-check failure result=%#v checks=%d", result, checks) + } + for _, command := range [][]string{ + {"git", "-C", repo.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, + {"git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3"}, + } { + if _, err := exec.Command(command[0], command[1:]...).CombinedOutput(); err == nil { + t.Fatalf("additional-check failure created release tag with %v", command) + } + } +} + func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) { repo := newPublishFixture(t) wrapper := installGitWrapper(t, repo, "pass") @@ -93,10 +165,10 @@ func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil { t.Fatal("Publish() succeeded despite rejected push hook") @@ -130,7 +202,7 @@ func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) { RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { + AdditionalChecks: func(context.Context, releasekit.Candidate) error { go func() { defer close(watcherDone) for { @@ -173,10 +245,10 @@ func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) @@ -216,10 +288,10 @@ func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) @@ -239,10 +311,10 @@ func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) { wrapper := installGitWrapperWithMutation(t, repo, conflictingObject) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation { t.Fatalf("result = %#v, error = %v", result, err) @@ -270,10 +342,10 @@ func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t * t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) @@ -303,10 +375,10 @@ func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) { t.Fatal(err) } result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ - RepositoryDir: repo.work, - Product: "Sample App", - Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { return nil }, + RepositoryDir: repo.work, + Product: "Sample App", + Version: "v1.2.3", + AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, }) if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { t.Fatalf("result = %#v, error = %v", result, err) @@ -330,7 +402,7 @@ func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) { RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { + AdditionalChecks: func(context.Context, releasekit.Candidate) error { validations++ return nil }, @@ -349,7 +421,7 @@ func TestPublishRemoteChangeDuringValidationPreventsLocalTag(t *testing.T) { RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", - Validate: func(context.Context, releasekit.Candidate) error { + AdditionalChecks: func(context.Context, releasekit.Candidate) error { gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit) return nil }, @@ -493,6 +565,33 @@ func newPublishFixture(t *testing.T) publishFixture { gitRun(t, base, "init", "-b", "main", work) gitRun(t, work, "config", "user.name", "ReleaseKit Test") gitRun(t, work, "config", "user.email", "releasekit@example.invalid") + writeFile(t, work, "go.mod", "module example.com/releasekit-publish-fixture\n\ngo 1.25.0\n") + writeFile(t, work, "fixture_test.go", `package fixture + +import ( + "os" + "testing" +) + +func TestFixture(t *testing.T) { + if path := os.Getenv("RELEASEKIT_FIXTURE_SUITE_RUNS"); path != "" { + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("ran\n"); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + } + if os.Getenv("RELEASEKIT_FAIL_FIXTURE_SUITE") == "1" { + t.Fatal("fixture standard suite failure") + } +} +`) writeFile(t, work, "docs/releases/v1.2.3.md", releaseNote) writeFile(t, work, "source.txt", "candidate") gitRun(t, work, "add", ".") diff --git a/releasekit.json b/releasekit.json index 4a5b701..d67d1c8 100644 --- a/releasekit.json +++ b/releasekit.json @@ -1,16 +1,6 @@ { "product": "ReleaseKit", "remote": "origin", - "checks": [ - { - "args": ["go", "test", "-mod=readonly", "-count=1", "./..."], - "env": { - "GOTOOLCHAIN": "local", - "GOPROXY": "off", - "GOSUMDB": "off" - } - } - ], "delivery": { "base_url": "https://gitea.maximumdirect.net", "owner": "eric",