Make product checks additive
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
43
check.go
43
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
15
docs/cli.md
15
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 `<repository>/releasekit.json`. A successful command prints the
|
||||
captured local commit and version. It checks the committed release note, local
|
||||
Git state, and configured validation, but does not inspect remote availability
|
||||
or publish a tag.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
41
docs/consumers/publish.md
Normal file
41
docs/consumers/publish.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
publish.go
25
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,
|
||||
|
||||
155
publish_test.go
155
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", ".")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user