Make product checks additive

This commit is contained in:
2026-10-06 17:15:46 +00:00
parent 08093124af
commit 3e9b5c1b99
23 changed files with 368 additions and 169 deletions

View File

@@ -22,8 +22,8 @@ The version command prints ReleaseKit's version:
go run ./cmd/releasekit version go run ./cmd/releasekit version
``` ```
The local candidate check validates a committed release note and runs the The local candidate check validates a committed release note, runs the standard
configured checks (or the default offline Go test command): offline Go test suite, and then runs any configured additional checks:
```sh ```sh
go run ./cmd/releasekit check v1.2.3 go run ./cmd/releasekit check v1.2.3
@@ -57,6 +57,8 @@ failure recovery.
operations and their failure behavior. operations and their failure behavior.
- [Candidate check guidance](docs/consumers/check.md): local candidate validation - [Candidate check guidance](docs/consumers/check.md): local candidate validation
through the Go API and CLI. 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 - [Maintainer release procedure](docs/release.md): ReleaseKit's source-only
publication workflow and recovery. publication workflow and recovery.
- [Internal package inventory](docs/internal/overview.md): implemented internal - [Internal package inventory](docs/internal/overview.md): implemented internal

View File

@@ -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"} { for _, source := range []string{"process environment", "Go environment file"} {
t.Run(source, func(t *testing.T) { t.Run(source, func(t *testing.T) {
unsetBuildEnv(t, "GOCACHE")
unsetBuildEnv(t, "GOAMD64") unsetBuildEnv(t, "GOAMD64")
unsetBuildEnv(t, "GOEXPERIMENT") unsetBuildEnv(t, "GOEXPERIMENT")
unsetBuildEnv(t, "GOFLAGS") unsetBuildEnv(t, "GOFLAGS")
@@ -444,7 +445,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
RepositoryDir: repository.work, RepositoryDir: repository.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++ validations++
return nil return nil
}, },
@@ -476,7 +477,7 @@ func TestPublishBuildFailurePreventsTagCreation(t *testing.T) {
RepositoryDir: repository.work, RepositoryDir: repository.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++ validations++
return nil 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)) 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, "go.mod", module)
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource) 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, "init", "--initial-branch=main")
buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test") buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test")
buildGit(t, repository, "config", "user.email", "releasekit-build@example.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, "go.mod", module)
writeFile(t, repository, ".gitignore", "dist/\n") writeFile(t, repository, ".gitignore", "dist/\n")
writeFile(t, repository, "cmd/app/main.go", mainSource) 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, "add", "--all")
gitRun(t, repository, "commit", "-m", "add buildable candidate") gitRun(t, repository, "commit", "-m", "add buildable candidate")
gitRun(t, repository, "config", "remote.origin.mirror", "false") gitRun(t, repository, "config", "remote.origin.mirror", "false")
@@ -637,7 +640,7 @@ func main(){fmt.Println("fixture",buildinfo.Version())}
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { os.Chmod(parent, 0700) }) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -23,28 +23,29 @@ type Candidate struct {
// local candidate. Remote is retained for publication operations; Check makes // local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim. // no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
Validate func(context.Context, Candidate) error // AdditionalChecks optionally runs product checks after the standard suite.
Build *BuildSpec // optional selected-target build after validation. AdditionalChecks func(context.Context, Candidate) error
Output io.Writer Build *BuildSpec // optional selected-target build after validation.
Output io.Writer
} }
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // 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 // and an unused local tag. It always runs the offline Go test suite with the
// with the available compatible local Go toolchain and the Go host target. // available compatible local Go toolchain and Go host target, then calls
// If Build selects targets, Check builds them after validation in temporary // AdditionalChecks when supplied. If Build selects targets, Check builds them
// storage outside the worktree, then rechecks branch, HEAD, worktree, index, // after validation in temporary storage outside the worktree, then rechecks
// and local tag state. Success establishes local candidate evidence only; it // branch, HEAD, worktree, index, and local tag state. Success establishes local
// does not establish remote availability. // candidate evidence only; it does not establish remote availability.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
var validate func(context.Context, candidate.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 { validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.Validate(ctx, Candidate{ return request.AdditionalChecks(ctx, Candidate{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit, Commit: captured.Commit,
Version: captured.Version, Version: captured.Version,
@@ -52,12 +53,12 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
} }
} }
checked, err := candidate.Check(ctx, candidate.Request{ checked, err := candidate.Check(ctx, candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validate: validate, AdditionalChecks: validate,
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output, Output: request.Output,
}) })
if err != nil { if err != nil {
return Candidate{}, err return Candidate{}, err

View File

@@ -130,7 +130,7 @@ func TestExampleProfileLoads(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("example settings = %#v", settings)
} }
} }

View File

@@ -50,7 +50,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
if len(settings.Checks) != 0 { if len(settings.Checks) != 0 {
checks := settings.Checks 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{ return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version, "RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit, "RELEASEKIT_CANDIDATE": candidate.Commit,

View File

@@ -50,21 +50,27 @@ func TestUnknownCommand(t *testing.T) {
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") 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() executable, err := os.Executable()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
settings := cli.Settings{ check := func(name string) cli.CheckCommand {
Product: "Example Product", return cli.CheckCommand{
Remote: "origin", Args: []string{executable, "-test.run=TestCandidateCheckHelper", "--", name},
Checks: []cli.CheckCommand{{
Args: []string{executable, "-test.run=TestCandidateCheckHelper", "--"},
Env: map[string]string{ Env: map[string]string{
"RELEASEKIT_CHECK_HELPER": "1", "RELEASEKIT_CHECK_HELPER": "1",
"RELEASEKIT_CHECK_NAME": name,
"RELEASEKIT_VERSION": "profile version", "RELEASEKIT_VERSION": "profile version",
"RELEASEKIT_CANDIDATE": "profile commit", "RELEASEKIT_CANDIDATE": "profile commit",
}, },
}}, }
}
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")},
} }
contents, err := json.Marshal(settings) contents, err := json.Marshal(settings)
if err != nil { 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()) t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
} }
commit := cliGitOutput(t, repository, "rev-parse", "HEAD") commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
if want := "v1.2.3|" + commit; stderr.String() != want { if got, err := os.ReadFile(orderPath); err != nil || string(got) != "first\nsecond\n" {
t.Fatalf("configured check received %q, want captured candidate %q", stderr.String(), want) 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 { 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) t.Fatalf("stdout = %q, want %q", stdout.String(), want)
@@ -93,14 +102,6 @@ func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Remote: "origin", 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) contents, err := json.Marshal(settings)
if err != nil { if err != nil {
@@ -207,6 +208,22 @@ func TestCandidateCheckHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" { if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" {
return 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")) fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE"))
os.Exit(0) os.Exit(0)
} }

View File

@@ -12,6 +12,9 @@ import (
) )
func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { 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" { if runtime.GOOS == "windows" {
t.Skip("POSIX release entry point") t.Skip("POSIX release entry point")
} }
@@ -26,6 +29,19 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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")) wrapper, err := os.ReadFile(filepath.Join(root, "scripts/release"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -36,6 +52,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
version string version string
}{{"success", 0, "v1.2.3"}, {"invalid", 2, "invalid"}, {"rejected", 1, "v1.2.3"}, {"unknown", 3, "v1.2.3"}} { }{{"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.Run(mode.name, func(t *testing.T) {
t.Setenv("RELEASEKIT_SKIP_WRAPPER_TEST", "1")
repository := filepath.Join(t.TempDir(), "source") repository := filepath.Join(t.TempDir(), "source")
clone := exec.Command(realGit, "clone", "--local", "--no-hardlinks", root, repository) clone := exec.Command(realGit, "clone", "--local", "--no-hardlinks", root, repository)
if output, err := clone.CombinedOutput(); err != nil { 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 { if err := os.WriteFile(filepath.Join(repository, "cmd/releasekit/main.go"), controller, 0600); err != nil {
t.Fatal(err) 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 { if err := os.MkdirAll(filepath.Join(repository, "docs/releases"), 0700); err != nil {
t.Fatal(err) 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 { 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) 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) content, _ := json.Marshal(profile)
if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), content, 0600); err != nil { if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), content, 0600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -80,7 +111,9 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
if mode.name == "unknown" { if mode.name == "unknown" {
bin := t.TempDir() bin := t.TempDir()
marker := filepath.Join(bin, "push-started") marker := filepath.Join(bin, "push-started")
armMarker := filepath.Join(bin, "arm-git-shim")
shim := `#!/bin/sh shim := `#!/bin/sh
if [ -f "$RELEASEKIT_TEST_ARM_GIT_SHIM" ]; then
for arg do for arg do
if [ "$arg" = push ]; then if [ "$arg" = push ]; then
"$RELEASEKIT_TEST_REAL_GIT" "$@" || exit 1 "$RELEASEKIT_TEST_REAL_GIT" "$@" || exit 1
@@ -89,7 +122,8 @@ for arg do
exit 1 exit 1
fi fi
done 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 for arg do if [ "$arg" = ls-remote ]; then exit 128; fi; done
fi fi
exec "$RELEASEKIT_TEST_REAL_GIT" "$@" 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_REAL_GIT", realGit)
t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker) t.Setenv("RELEASEKIT_TEST_PUSH_MARKER", marker)
t.Setenv("RELEASEKIT_TEST_MOVED_ENDPOINT", fetch) 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 := exec.Command("./scripts/release", mode.version)
command.Dir = repository command.Dir = repository

View File

@@ -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 version. The repository defaults to the current directory, and the profile
defaults to `<repository>/releasekit.json`. A successful command prints the defaults to `<repository>/releasekit.json`. A successful command prints the
captured local commit and version. It checks the committed release note, local captured local commit and version. It checks the committed release note, local
Git state, and configured validation, but does not inspect remote availability Git state, runs the standard offline Go test suite, and then runs any configured
or publish a tag. 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 `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, 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 go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION
``` ```
`publish` reads the same product, remote, and validation settings as `check`. `publish` reads the same product, remote, and additional-check settings as
It validates the candidate once, checks the authoritative remote, creates an `check`. It checks the authoritative remote and validates the candidate: the
annotated local tag, and pushes only its exact tag object. It prints the standard offline Go test suite runs first, followed by configured checks in
confirmed version, candidate commit, and tag object on success. Validation 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 progress and failed publication state, including the captured publication
endpoint for recovery, are written to standard error. A signal endpoint for recovery, are written to standard error. A signal
cancels the active operation; if it arrives during a push, the library's cancels the active operation; if it arrives during a push, the library's

View File

@@ -10,7 +10,7 @@ The current profile fields are:
| --- | --- | --- | | --- | --- | --- |
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `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. | | `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. | | `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 `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
passes its value explicitly to the library operation. passes its value explicitly to the library operation.
The `check` and `publish` commands use `product`, `remote`, and `checks`, and The `check` and `publish` commands always run
build selected targets after the single validation callback. If `checks` is `go test -mod=readonly -count=1 ./...` with Go module and toolchain network
omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module access disabled. They then run each configured `checks` command once, in order,
and toolchain network access disabled. If checks are configured, they replace as an additional product check. Selected-target builds run after both. Do not
that default and run in order. The `build` command uses the selected build repeat the standard suite in `checks`; doing so runs it twice. The `build`
settings and writes binaries to `dist` unless `--output` selects another path. 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 The `deliver` command uses the delivery destination and token, then either
builds selected tagged-source targets into temporary storage or loads verified builds selected tagged-source targets into temporary storage or loads verified
assets from the directory selected by `--assets`. An absent build object or an assets from the directory selected by `--assets`. An absent build object or an

View File

@@ -70,9 +70,9 @@ silently adding a binary check.
The [`build` command](../cli.md) reads the same build specification from the The [`build` command](../cli.md) reads the same build specification from the
profile and writes a private receipt alongside its artifacts. `check` and profile and writes a private receipt alongside its artifacts. `check` and
`publish` run selected builds after their one validation callback using `publish` run selected builds after the standard suite and any additional
temporary output outside the worktree; those candidate checks do not write a checks using temporary output outside the worktree. Candidate checks do not
receipt or leave build files in the repository. write a receipt or leave build files in the repository.
For delivery from a tagged checkout, `deliver` builds selected tagged-source For delivery from a tagged checkout, `deliver` builds selected tagged-source
targets into temporary storage by default. Use `--assets DIR` to deliver a targets into temporary storage by default. Use `--assets DIR` to deliver a

View File

@@ -8,7 +8,7 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", 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) 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 product name and exact version as complete title elements. No local tag with
that version may already exist. that version may already exist.
The callback receives the captured repository, commit, and version. ReleaseKit ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available
calls it once. When omitted, ReleaseKit runs compatible local Go toolchain, then calls `AdditionalChecks` once when supplied.
`go test -mod=readonly -count=1 ./...` with the available compatible local Go It does not download a different toolchain automatically. The standard suite
toolchain; it does not download a different toolchain automatically. The suite
uses the Go host target, disables workspace and module network access, and uses the Go host target, disables workspace and module network access, and
ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective
cache and native cgo/C-tool settings are preserved, including values from Go's 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 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. 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`. commands from `releasekit.json`.
## Publishing the source tag ## 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 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 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 contain the selected version tag. A successful result includes the endpoint and
tag object ID. Publication states distinguish an unattempted push, a tag that 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 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 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 authoritative tag. An exact tag object match confirms success. An absent tag is

41
docs/consumers/publish.md Normal file
View File

@@ -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.

View File

@@ -52,7 +52,8 @@ root with:
go run ./internal/apicheck 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 [internal package inventory](internal/overview.md) for implemented behavior
and capability ownership. The [product roadmap](roadmap/releasekit.md) records and capability ownership. The [product roadmap](roadmap/releasekit.md) records
remaining adoption and deployment work. remaining adoption and deployment work.

View File

@@ -15,6 +15,7 @@ two Go facades and a command-line interface:
See the [CLI contract](../cli.md), [configuration reference](../config.md), See the [CLI contract](../cli.md), [configuration reference](../config.md),
[candidate check guide](../consumers/check.md), [candidate check guide](../consumers/check.md),
[source publication guide](../consumers/publish.md),
[build guide](../consumers/build.md), [build guide](../consumers/build.md),
[delivery guide](../consumers/delivery.md), and [delivery guide](../consumers/delivery.md), and
[runtime version guide](../consumers/runtime-version.md) for task-oriented use. [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 | | Capability | Implementation owner | Existing evidence |
| --- | --- | --- | | --- | --- | --- |
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | 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` | | 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` | | 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` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |

View File

@@ -54,9 +54,10 @@ Then run the single publication command from the repository root:
./scripts/release vX.Y.Z ./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 `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 authoritative remote state, creates a local annotated tag from the captured
candidate and checked-in note, rechecks the tag and remote guards, and pushes 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 only that tag object. It prints the published version, commit, and tag object

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 callback. One CLI profile test covers ordered extra commands. Keep callback
unit coverage and end-to-end suite-count coverage at distinct boundaries. 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 ## Stage 4 — Consolidate artifact identity and declaration rules
**Covers:** artifact portion of F8. **Primary owners:** `internal/build/`, **Covers:** artifact portion of F8. **Primary owners:** `internal/build/`,

View File

@@ -1,14 +1,6 @@
{ {
"product": "Example", "product": "Example",
"remote": "origin", "remote": "origin",
"checks": [
{
"args": ["go", "test", "-count=1", "./..."],
"env": {
"RELEASE_MODE": "candidate"
}
}
],
"delivery": { "delivery": {
"base_url": "https://gitea.example", "base_url": "https://gitea.example",
"owner": "example", "owner": "example",

View File

@@ -26,12 +26,12 @@ type Candidate struct {
} }
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Validate func(context.Context, Candidate) error AdditionalChecks func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate) error
Output io.Writer Output io.Writer
} }
func Check(ctx context.Context, request Request) (Candidate, error) { 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 return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil
} }
// Validate runs the complete selected validation once for the captured // Validate runs the standard offline Go suite, followed by AdditionalChecks
// candidate. A supplied callback replaces the default offline Go validation. // when supplied, for the captured candidate.
func Validate(ctx context.Context, request Request, captured Candidate) error { func Validate(ctx context.Context, request Request, captured Candidate) error {
var err error if err := runDefaultValidation(ctx, captured.RepositoryDir, request.Output); err != nil {
if request.Validate == nil {
err = runDefaultValidation(ctx, captured.RepositoryDir, request.Output)
} else {
err = request.Validate(ctx, captured)
}
if err != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) 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 return nil
} }

View File

@@ -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") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
calls := 0 calls := 0
var seen Candidate var seen Candidate
@@ -49,7 +49,7 @@ func TestCheckCapturesCandidateAndCallsValidationOnce(t *testing.T) {
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validate: func(_ context.Context, captured Candidate) error { AdditionalChecks: func(_ context.Context, captured Candidate) error {
calls++ calls++
seen = captured seen = captured
return nil return nil
@@ -174,7 +174,7 @@ func TestCheckRequiresMatchingCommittedNote(t *testing.T) {
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validate: func(context.Context, Candidate) error { AdditionalChecks: func(context.Context, Candidate) error {
called = true called = true
return nil 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") repository := newRepository(t, "# Example Product v1.2.3\n")
want := errors.New("selected check failed") want := errors.New("selected check failed")
calls := 0 calls := 0
@@ -238,7 +238,7 @@ func TestCheckStopsOnCallbackFailure(t *testing.T) {
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validate: func(context.Context, Candidate) error { AdditionalChecks: func(context.Context, Candidate) error {
calls++ calls++
return want return want
}, },
@@ -287,7 +287,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validate: func(context.Context, Candidate) error { AdditionalChecks: func(context.Context, Candidate) error {
test.validate(t, repository) test.validate(t, repository)
return nil return nil
}, },
@@ -305,7 +305,7 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validate: func(context.Context, Candidate) error { AdditionalChecks: func(context.Context, Candidate) error {
gitRun(t, repository, "tag", testVersion) gitRun(t, repository, "tag", testVersion)
return nil return nil
}, },
@@ -316,7 +316,7 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
} }
func validRequest(repository string) Request { 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 { func newRepository(t *testing.T, note string) string {

View File

@@ -36,13 +36,13 @@ const (
const recoveryTimeout = 30 * time.Second const recoveryTimeout = 30 * time.Second
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
Validate func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error
Output io.Writer Output io.Writer
} }
type Result struct { type Result struct {
@@ -55,7 +55,8 @@ type Result struct {
} }
// Publish validates one captured candidate and pushes only its annotated tag // 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, // object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error. // while an unreadable result remains Unknown and preserves the push error.
func Publish(ctx context.Context, request Request) (Result, 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" remoteName = "origin"
} }
checks := candidate.Request{ checks := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validate: request.Validate, AdditionalChecks: request.AdditionalChecks,
Output: request.Output, Output: request.Output,
} }
captured, err := candidate.Capture(ctx, checks) captured, err := candidate.Capture(ctx, checks)
if err != nil { if err != nil {

View File

@@ -28,7 +28,7 @@ type PublicationPhase string
const ( const (
// PublicationPreflight identifies local and remote candidate inspection. // PublicationPreflight identifies local and remote candidate inspection.
PublicationPreflight PublicationPhase = PublicationPhase(publish.Preflight) 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) PublicationValidation PublicationPhase = PublicationPhase(publish.Validation)
// PublicationTagCreation identifies creation and inspection of the local annotated tag. // PublicationTagCreation identifies creation and inspection of the local annotated tag.
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation) PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
@@ -50,8 +50,9 @@ type PublicationResult struct {
State PublicationState // publication outcome for this invocation State PublicationState // publication outcome for this invocation
} }
// Publish validates the captured candidate, optionally builds its selected // Publish always runs the offline Go test suite for the captured candidate,
// targets in temporary storage outside the worktree, and pushes its exact // 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 // annotated tag object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and // failures are not_attempted, failed authoritative guards are conflict, and
// errors after local tag creation but before push are not_published. A failed // 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. // by the successful push or exact remote object match.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
var validate func(context.Context, candidate.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 { validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.Validate(ctx, Candidate{ return request.AdditionalChecks(ctx, Candidate{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit, Commit: captured.Commit,
Version: captured.Version, Version: captured.Version,
@@ -71,13 +72,13 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
} }
} }
result, err := publish.Publish(ctx, publish.Request{ result, err := publish.Publish(ctx, publish.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Remote: request.Remote, Remote: request.Remote,
Validate: validate, AdditionalChecks: validate,
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output), AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output, Output: request.Output,
}) })
return PublicationResult{ return PublicationResult{
Version: result.Version, Version: result.Version,

View File

@@ -21,6 +21,8 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass") wrapper := installGitWrapper(t, repo, "pass")
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) 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") refsBefore := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
commit := repo.commit commit := repo.commit
var validations int var validations int
@@ -28,8 +30,12 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(_ context.Context, candidate releasekit.Candidate) error { AdditionalChecks: func(_ context.Context, candidate releasekit.Candidate) error {
validations++ 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" { if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" {
t.Fatalf("callback candidate = %#v", candidate) t.Fatalf("callback candidate = %#v", candidate)
} }
@@ -42,6 +48,9 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
if validations != 1 { if validations != 1 {
t.Fatalf("validation calls = %d, want 1", validations) 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 { if queries := countLines(t, wrapper.queryLog); queries != 3 {
t.Fatalf("authoritative tag query count = %d, want 3 guards and no success read-back", queries) 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) { func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
wrapper := installGitWrapper(t, repo, "pass") wrapper := installGitWrapper(t, repo, "pass")
@@ -93,10 +165,10 @@ func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil { if err == nil {
t.Fatal("Publish() succeeded despite rejected push hook") t.Fatal("Publish() succeeded despite rejected push hook")
@@ -130,7 +202,7 @@ func TestPublishLostAcknowledgmentConfirmsExactRemoteObject(t *testing.T) {
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { AdditionalChecks: func(context.Context, releasekit.Candidate) error {
go func() { go func() {
defer close(watcherDone) defer close(watcherDone)
for { for {
@@ -173,10 +245,10 @@ func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
@@ -216,10 +288,10 @@ func TestPublishConflictingRemoteTagIsNotReportedAsAbsent(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation { if err == nil || result.State != releasekit.PublicationConflict || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
@@ -239,10 +311,10 @@ func TestPublishStopsWhenLocalTagChangesBeforePush(t *testing.T) {
wrapper := installGitWrapperWithMutation(t, repo, conflictingObject) wrapper := installGitWrapperWithMutation(t, repo, conflictingObject)
t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", wrapper.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation { if err == nil || result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationTagCreation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
@@ -270,10 +342,10 @@ func TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError(t *
t.Fatal(err) t.Fatal(err)
} }
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
@@ -303,10 +375,10 @@ func TestPublishUninterpretableReconciliationRemainsUnknown(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{ result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { return nil }, AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
}) })
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
t.Fatalf("result = %#v, error = %v", result, err) t.Fatalf("result = %#v, error = %v", result, err)
@@ -330,7 +402,7 @@ func TestPublishRemoteConflictStopsBeforeValidation(t *testing.T) {
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validate: func(context.Context, releasekit.Candidate) error { AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++ validations++
return nil return nil
}, },
@@ -349,7 +421,7 @@ func TestPublishRemoteChangeDuringValidationPreventsLocalTag(t *testing.T) {
RepositoryDir: repo.work, RepositoryDir: repo.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", 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) gitRun(t, repo.bare, "update-ref", "refs/heads/main", repo.unrelatedCommit)
return nil return nil
}, },
@@ -493,6 +565,33 @@ func newPublishFixture(t *testing.T) publishFixture {
gitRun(t, base, "init", "-b", "main", work) gitRun(t, base, "init", "-b", "main", work)
gitRun(t, work, "config", "user.name", "ReleaseKit Test") gitRun(t, work, "config", "user.name", "ReleaseKit Test")
gitRun(t, work, "config", "user.email", "releasekit@example.invalid") 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, "docs/releases/v1.2.3.md", releaseNote)
writeFile(t, work, "source.txt", "candidate") writeFile(t, work, "source.txt", "candidate")
gitRun(t, work, "add", ".") gitRun(t, work, "add", ".")

View File

@@ -1,16 +1,6 @@
{ {
"product": "ReleaseKit", "product": "ReleaseKit",
"remote": "origin", "remote": "origin",
"checks": [
{
"args": ["go", "test", "-mod=readonly", "-count=1", "./..."],
"env": {
"GOTOOLCHAIN": "local",
"GOPROXY": "off",
"GOSUMDB": "off"
}
}
],
"delivery": { "delivery": {
"base_url": "https://gitea.maximumdirect.net", "base_url": "https://gitea.maximumdirect.net",
"owner": "eric", "owner": "eric",