Add source publication command and runbook

This commit is contained in:
2026-10-05 19:04:55 +00:00
parent 7481dd0fd2
commit c83313b3b9
15 changed files with 295 additions and 60 deletions

View File

@@ -2,12 +2,12 @@
ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, and `version` and `check`
commands are implemented. A publication command, builds, delivery, and release
automation remain to be implemented. Their selected
behavior and sequence are described in the
[product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md).
local candidate check, source-tag publication API, and `version`, `check`, and
`publish` commands are implemented. ReleaseKit's source-only maintainer
procedure is documented. Executable builds and delivery remain future work.
The [product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md) describe their selected
behavior and sequence.
Run the offline Go test suite from the repository root with:
@@ -28,25 +28,37 @@ configured checks (or the default offline Go test command):
go run ./cmd/releasekit check v1.2.3
```
Publish the source tag from a committed candidate with:
```sh
./scripts/release v1.2.3
```
See the [ReleaseKit maintainer procedure](docs/release.md) for preparation and
failure recovery.
## Documentation
- [ReleaseKit proposal](docs/roadmap/releasekit.md): proposed publication, builds,
version injection, and delivery, with the initial implementation plan.
- [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading.
- [CLI contract](docs/cli.md): the `version` and local `check` commands.
- [CLI contract](docs/cli.md): the `version`, local `check`, and source
`publish` commands.
- [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications
read their embedded version through `buildinfo`.
- [Candidate check guidance](docs/consumers/check.md): local candidate validation
through the Go API and CLI.
- [Maintainer release procedure](docs/release.md): ReleaseKit's source-only
publication workflow and recovery.
- [Internal package inventory](docs/internal/overview.md): implemented internal
components and their responsibilities.
- [Architecture policy](docs/policy/architecture.md): responsibility and dependency
boundaries for development.
- [ADR 0001](docs/adr/0001-public-facade.md): the accepted public facade decision.
- [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and pending
release bootstrap work.
- [Policy adoption roadmap](docs/roadmap/policy.md): completed adoption and the
current source-release path.
ReleaseKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).

View File

@@ -16,13 +16,14 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION"
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION"
type Command string
const (
VersionCommand Command = "version"
CheckAction Command = "check"
PublishAction Command = "publish"
)
// Invocation contains parsed command settings owned by the CLI adapter.
@@ -44,8 +45,9 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments")
}
return Invocation{Command: VersionCommand}, nil
case string(CheckAction):
flags := flag.NewFlagSet("check", flag.ContinueOnError)
case string(CheckAction), string(PublishAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
repository := flags.String("repo", ".", "repository directory")
configPath := flags.String("config", "", "profile path")
@@ -53,9 +55,9 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, err
}
if flags.NArg() != 1 {
return Invocation{}, errors.New("check requires one version argument")
return Invocation{}, fmt.Errorf("%s requires one version argument", action)
}
return Invocation{Command: CheckAction, Repository: *repository, ConfigPath: *configPath, Version: flags.Arg(0)}, nil
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, Version: flags.Arg(0)}, nil
default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
}

View File

@@ -26,14 +26,21 @@ func TestParse(t *testing.T) {
if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" {
t.Fatalf("Parse(check) = %#v", check)
}
publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"})
if err != nil {
t.Fatal(err)
}
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version")
}
if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") {
t.Fatalf("Parse(nil) error = %v, want missing-command error", err)
}
if _, err := Parse([]string{"publish"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("Parse(publish) error = %v, want unknown-command error", err)
if _, err := Parse([]string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("Parse(unknown) error = %v, want unknown-command error", err)
}
}

View File

@@ -32,7 +32,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
case cli.VersionCommand:
fmt.Fprintf(stdout, "releasekit %s\n", buildinfo.Version())
return 0
case cli.CheckAction:
case cli.CheckAction, cli.PublishAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
@@ -54,6 +54,22 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}, stderr, stderr)
}
}
if invocation.Command == cli.PublishAction {
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version)
result, err := releasekit.Publish(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, err)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.PublicationUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "published %s at %s (tag object %s)\n", result.Version, result.CandidateCommit, result.TagObject)
return 0
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed: %v\n", err)

View File

@@ -30,13 +30,13 @@ func TestVersionCommand(t *testing.T) {
func TestUnknownCommand(t *testing.T) {
var stdout, stderr bytes.Buffer
if status := run(context.Background(), []string{"publish"}, &stdout, &stderr); status != 2 {
if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 {
t.Fatalf("run() status = %d, want 2", status)
}
if stdout.Len() != 0 {
t.Fatalf("unknown command wrote to stdout: %q", stdout.String())
}
if want := "unknown command \"publish\"\n" + cli.Usage + "\n"; stderr.String() != want {
if want := "unknown command \"unknown\"\n" + cli.Usage + "\n"; stderr.String() != want {
t.Fatalf("usage output = %q, want %q", stderr.String(), want)
}
}
@@ -82,6 +82,52 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
}
}
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
repository := newCLIRepository(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 {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), contents, 0o600); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
var stdout, stderr bytes.Buffer
args := []string{"publish", "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
if want := fmt.Sprintf("published v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
}
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
t.Fatalf("remote tag object = %s, want %s", got, tagObject)
}
}
func TestCandidateCheckHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" {
return

View File

@@ -1,6 +1,6 @@
# Command line
ReleaseKit currently provides two commands.
ReleaseKit currently provides three commands.
Print the executable version:
@@ -24,7 +24,28 @@ captured local commit and version. It checks the committed release note, local
Git state, and configured validation, but does not inspect remote availability
or publish a tag.
Both commands write errors and diagnostic output to standard error. `check`
returns status 0 after local validation, 1 after a candidate validation failure,
and 2 for usage, profile, or invalid-version errors. Unsupported argument
sequences print usage to standard error.
`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,
profile, or invalid-version errors. Unsupported argument sequences print usage
to standard error.
Publish a source tag:
```sh
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
progress and failed publication state are written to standard error. A signal
cancels the active operation; if it arrives during a push, the library's
bounded reconciliation determines whether the exact tag was accepted.
The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version
errors, and 3 when the push outcome remains unknown. Unknown status must be
resolved against the authoritative remote before retrying. The
[maintainer procedure](release.md) selects the repository profile and gives
the source-release workflow.

View File

@@ -20,10 +20,12 @@ The overlay does not modify the ReleaseKit process environment.
Unknown fields are rejected so a setting cannot be silently ignored. Build and
delivery fields will be added when those operations are implemented. The `check`
command uses `product`, `remote`, and `checks`. If `checks` is omitted or empty,
it runs `go test -mod=readonly -count=1 ./...` with Go module and toolchain
network access disabled. If checks are configured, they replace that default and
run in order.
and `publish` commands use `product`, `remote`, and `checks`. If `checks` is
omitted or empty, it runs `go test -mod=readonly -count=1 ./...` with Go module
and toolchain network access disabled. If checks are configured, they replace
that default and run in order.
ReleaseKit's source-only publication profile is
[releasekit.json](../releasekit.json).
See the [profile example](../examples/releasekit.json). The
[CLI contract](cli.md) lists the currently available command.

View File

@@ -65,7 +65,9 @@ git diff --cached --check -- '*.md' COPYRIGHT
Verbatim license texts retain their supplied formatting. When changing those
texts, compare their bytes with the selected upstream source.
There is no publication command yet. Add working development commands here as
implementation lands. Add `docs/release.md` with the first working publication
command; the [policy adoption roadmap](roadmap/policy.md#4-define-releasekits-own-release-path)
records the remaining bootstrap work.
Run the source publication command from the candidate repository root with
`./scripts/release VERSION`. It uses the local Go toolchain and committed
`releasekit.json` profile; prepare module dependencies before offline candidate
validation. The [maintainer release procedure](release.md) owns preparation,
publication, and recovery. Executable artifacts and delivery remain outside
ReleaseKit's initial source-only release.

View File

@@ -18,10 +18,8 @@ policy and link to it rather than introduce additional release requirements.
This policy governs releases of ReleaseKit itself. Consumer release capabilities
are designed in the [product roadmap](../roadmap/releasekit.md) and acquire exact
public contracts when implemented. ReleaseKit currently has no publication
command. Create the maintainer runbook alongside the first working command;
the [policy adoption roadmap](../roadmap/policy.md#4-define-releasekits-own-release-path)
owns that pending work.
public contracts when implemented. ReleaseKit's source publication command and
maintainer runbook are implemented; no ReleaseKit release has been published.
## Release Model And Completion
@@ -33,8 +31,8 @@ The procedure must name the authoritative publication remote.
A successful push of the intended annotated tag, with its source commit already
published on authoritative `main`, completes the release. The remote branch must
identify the validated candidate before the command publishes only the exact
intended tag object. The runbook will identify the authoritative endpoint and
command when publication is implemented.
intended tag object. The [maintainer runbook](../release.md) identifies the
authoritative endpoint and command.
Release commands must finish at successful publication. They must not wait for
or poll CI, require successful CI status, inspect downloadable artifacts, or

93
docs/release.md Normal file
View File

@@ -0,0 +1,93 @@
# ReleaseKit release procedure
ReleaseKit's initial deliverable is source only. The authoritative repository
is `origin` at `https://gitea.maximumdirect.net/eric/ReleaseKit.git`, and the
release branch is `main`. A release completes when the intended annotated tag
is successfully pushed, or when one exact-object recovery query confirms that
the push succeeded. CI, hosted artifacts, and consumer installation are not
completion checks.
## Prepare a candidate
Use a local Go 1.25 toolchain and a clean clone. Start on `main`, choose an
unused stable `vMAJOR.MINOR.PATCH` version, and add
`docs/releases/<version>.md` with a first heading containing `ReleaseKit` and
the exact version. Summarize the change and any compatibility or upgrade impact.
End the note with the three license links at the immutable source tag:
```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/vX.Y.Z/docs/oss/attribution.md)
```
Replace every `vX.Y.Z` with the selected version. These are links to the same
release note's immutable source tree and also work when the note is used as a
hosted release body.
Commit the note and all candidate changes on `main`, then publish that commit
to `origin/main`:
```sh
git push origin main
```
ReleaseKit checks that the worktree is clean, the candidate is on `main`, the
authoritative branch identifies that commit, and the version is unused locally
and on `origin`.
## Prepare dependencies, then publish
Dependency preparation is separate from the offline candidate validation. Run
it from the captured candidate source before the release command:
```sh
GOTOOLCHAIN=local go mod download
```
Then run the single publication command from the repository root:
```sh
./scripts/release vX.Y.Z
```
The committed `releasekit.json` selects 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
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
on success. Exit status 0 means publication is confirmed; status 1 means a
known failure or conflict; status 2 means invalid input or profile; status 3
means the push outcome remains unknown and requires resolution.
The CLI runs from the source tree being published, so this command also works
for ReleaseKit's first release before any released ReleaseKit executable
exists. It uses the local Go toolchain. Preparation must finish before the
offline validation begins.
## Resolve failures
A failed push triggers one exact authoritative tag query. An exact tag-object
match confirms publication even when Git reported an error. A proven absent tag
is `not_published`; a different object is a conflict; an unreadable query is
`unknown`. The command retains its local tag and reports the state, phase,
candidate commit, and tag object when available. Do not retry an unknown result
until you resolve the authoritative ref:
```sh
git ls-remote --refs origin "refs/tags/vX.Y.Z"
```
Compare the returned object ID with the tag object reported by the command.
An exact match confirms publication. If the ref is absent, the version was not
published; only then may you deliberately remove the unpublished local tag
with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not
move, overwrite, or delete a tag that exists remotely. If a different object
uses the version, choose a new version after resolving the conflict.
If validation or a guard fails before the push, the local tag has not been
published. Correct and commit the candidate, publish `main`, and run the
command again. ReleaseKit does not publish executable artifacts or wait for
asynchronous delivery; any future deliverables require a separate policy and
procedure update.

View File

@@ -649,7 +649,7 @@ local tag before push prevent publication.
## Stage 9: Expose publication and document ReleaseKit bootstrap
Status: Pending. Depends on Stage 8.
Status: Complete. Depends on Stage 8.
1. Wire the complete public publisher into `releasekit publish`, with selected
progress output, final result, exit classification, and signal cancellation.
@@ -672,6 +672,15 @@ committed profile and returns the expected exit/output behavior. Runbook command
are exercised using a temporary repository configured like ReleaseKit. This
stage prepares self-publication and does not publish a real ReleaseKit release.
Evidence: `publish` runs the public publisher with the CLI signal context,
selected progress output, final commit and tag-object output, and separate exit
status for unresolved outcomes. A temporary committed profile runs the offline
test command and publishes to a local bare remote. ReleaseKit's root profile,
entry script, CLI and configuration guides, development guide, and maintainer
runbook describe source-only publication and exact-object recovery. The
runbook's dependency preparation is separate from offline validation. No real
ReleaseKit release was published.
## Stage 10: Build selected targets with shared version injection
Status: Pending. Depends on Stage 9.

View File

@@ -1,6 +1,8 @@
# Adopt the policy framework
Status: Policy adoption complete. Release publication implementation is pending.
Status: Policy adoption complete. Source publication and its maintainer runbook
are implemented; no ReleaseKit release has been published. Executable delivery
remains pending.
Date: 2026-10-05
@@ -130,20 +132,21 @@ policy has separate defaults for applications and libraries.
successful-tag-push completion rules.
- [x] Select source only as ReleaseKit's initial deliverable in the release
policy. Supporting downstream cross-builds does not declare ReleaseKit binaries.
- [ ] Add `docs/release.md` alongside the first working publication command,
- [x] Add `docs/release.md` alongside the first working publication command,
documenting real prerequisites, authoritative remote, branch, validation,
publication, and failure recovery.
- [x] Account for first-release bootstrap: the initial publication command must
run from the captured candidate source with dependencies prepared separately.
The first release uses that source execution path before a released ReleaseKit
tool exists. Document and verify the actual command when it is implemented.
tool exists. The maintainer procedure documents the command and the temporary
repository integration test exercises the same CLI workflow.
- [ ] Add executable delivery declarations and target checks when corresponding
deliverables exist. Keep source completion and asynchronous delivery status
separate. This task is deferred unless executable deliverables are selected.
The publication command, its automated guards and offline candidate validation,
and the executable maintainer runbook remain product implementation work. This
adoption does not claim an available release command or perform publication.
The publication command, its automated guards, offline candidate validation,
and executable maintainer runbook are implemented. This adoption does not claim
that a ReleaseKit version has been published.
## Validation and completion
@@ -165,14 +168,14 @@ approval, and CI polling are not additional adoption or release gates.
## Adoption result
The seven policies, root agent orientation, developer reading guide, documentation
ownership, project grant, attribution, and license texts are in place. The product
proposal lives at `docs/roadmap/releasekit.md`; `ideas.md` remains a navigation
pointer. Future capabilities retain their proposed status, and facade
implementation remains pending in the product roadmap.
ownership, project grant, attribution, license texts, source publication command,
and maintainer runbook are in place. The product proposal lives at
`docs/roadmap/releasekit.md`; `ideas.md` remains a navigation pointer. Executable
delivery remains pending, and no ReleaseKit release has been published.
Verification covers local file and fragment links, per-policy source revision and
base version, removed template adoption sections, attribution scope and pinned
notice destinations, verbatim license bytes, and prose whitespace. The preserved
ADR decision and relocated proposal are checked against their pre-adoption source.
Release automation and its runbook remain pending under stage 4 as permitted by
this roadmap's completion criteria.
ReleaseKit's source publication command and runbook are implemented; executable
delivery declarations remain pending under stage 4.

View File

@@ -1,9 +1,11 @@
# ReleaseKit: shared release process
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius,
Narratio, and Scriptorium on 2026-09-29. Product capabilities remain proposed.
The public facade architecture is accepted in
[ADR 0001](../adr/0001-public-facade.md), with implementation pending.
Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
current implemented boundary; builds, delivery, and broader integrations remain
proposed. The public facade architecture is accepted in
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
candidate validation, and source publication.
The [implementation plan](implementation.md) owns ordered stages, execution
instructions, acceptance checks, and progress toward this target state.
@@ -50,12 +52,11 @@ cleanup.
## Architecture
[ADR 0001](../adr/0001-public-facade.md) owns the accepted facade decision.
The [architecture policy](../policy/architecture.md) owns the adopted responsibility
and dependency rules, including the runtime version boundary. The facades remain
unimplemented.
The root `releasekit` package will provide the public facade for publication,
builds, and delivery. A separate public `buildinfo` package will provide the
lightweight facade for runtime version information.
The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate
validation and source publication; builds and delivery remain planned. The
separate public `buildinfo` package provides lightweight runtime version access.
Each facade owns its public named types and documented contracts. Public
operations, inputs, outcomes, errors, and callbacks express deliberately

14
releasekit.json Normal file
View File

@@ -0,0 +1,14 @@
{
"product": "ReleaseKit",
"remote": "origin",
"checks": [
{
"args": ["go", "test", "-mod=readonly", "-count=1", "./..."],
"env": {
"GOTOOLCHAIN": "local",
"GOPROXY": "off",
"GOSUMDB": "off"
}
}
]
}

9
scripts/release Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
printf '%s\n' 'usage: ./scripts/release VERSION' >&2
exit 2
fi
GOTOOLCHAIN=local exec go run ./cmd/releasekit publish "$1"