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
```
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

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"} {
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/`,

View File

@@ -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",

View File

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

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")
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 {

View File

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

View File

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

View File

@@ -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", ".")

View File

@@ -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",