diff --git a/docs/development.md b/docs/development.md index 1bac297..9c5608f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -116,7 +116,7 @@ Use focused checks while iterating, with validation proportionate to the change under the testing policy. Documentation-only work normally needs the local-link check below and whitespace review; behavior-bearing examples and commands also need execution. Use `go test -race ./...` for ownership or concurrency changes. -The [release checker](release.md#4-validate-the-source-candidate) performs one +The [release checker](release.md#4-publish-the-committed-candidate) performs one minimum-toolchain offline race-suite run, one full vet pass, and Linux cross-builds using readonly module mode. It already covers examples, links, and package boundaries; do not additionally diff --git a/docs/policy/release.md b/docs/policy/release.md index d7eae83..52cefe8 100644 --- a/docs/policy/release.md +++ b/docs/policy/release.md @@ -6,10 +6,8 @@ **Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md) -ConfigKit retains its separately invoked checker and guarded -[release procedure](../release.md). A single publication command and review of -the current gates are future work under the -[release-workflow roadmap](../roadmap/release.md). +ConfigKit's [release procedure](../release.md) uses one publication command +with the selected local checker and explicit main-then-tag pushes. ## Purpose @@ -73,6 +71,10 @@ check consumer installation or Go module availability as completion gates. Optional remote tag read-back and fresh consumer retrieval do not delay release completion. +A failed or ambiguous push requires resolving the authoritative remote state +before declaring success or retrying; successful pushes do not require a +separate remote read-back gate. + ConfigKit publishes source only. If additional deliverables are deliberately adopted, they must be traceable to the tagged source, identify the same version, and have documented publication responsibilities and verification. @@ -85,8 +87,9 @@ release in place. ## Versioning And Compatibility Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with -no leading zeroes. Each version must be unused locally and in every configured -remote or publication channel before publication begins. +no leading zeroes. The selected version must be unused locally and on the +authoritative remote. A version known to have been published elsewhere must +not be reused. Unless an adopted policy explicitly supports and defines prereleases, prerelease versions are not permitted. @@ -114,24 +117,9 @@ Every release made under this policy must include the complete version tag, and the document must begin with a title containing the product name and that exact version. -Every new release note must contain these sections: - -```markdown -# vMAJOR.MINOR.PATCH - -This release ... - -## Summary - -## Compatibility - -## Upgrade - -## Changes -``` - -Use an explicit statement such as "No action required" when a section has no -substantive content. +Include a short summary and any relevant compatibility impact or upgrade +instructions. Choose sections suited to the release and keep the note +proportionate to the change. The annotated tag's message is the checked-in note. Release notes are immutable historical summaries. They should link to current canonical documentation for exact behavior. Every affected canonical document @@ -146,10 +134,11 @@ gate; historical release notes are unchanged. ## Candidate Preparation And Validation -ConfigKit provides one local release checker. Run it once against the final -committed candidate using one maintained patch of the minimum supported Go line -on Linux. It runs one full offline race suite, one full vet pass, and CGO-disabled -Linux amd64/arm64 builds. The race suite protects library behavior, ownership, +The publication command validates the final committed candidate once with +ConfigKit's local release checker, using one maintained patch of the minimum +supported Go line on Linux. The checker runs one full offline race suite, one +full vet pass, and CGO-disabled Linux amd64/arm64 builds. The race suite +protects library behavior, ownership, and concurrent use; it includes examples, documentation links, consumer fixtures, and package boundaries without separate runs. Full vet checks more than the subset normally run by tests, so test-time vet is disabled. The two cross-builds @@ -160,9 +149,9 @@ All Go checks use readonly module mode and an offline dependency cache. Formatting and whitespace remain ordinary development checks. Review module tidiness when dependencies change, rather than auditing it on every release. -Commit the release note and associated changes before release validation. Record -the exact validated commit; revalidate if the candidate changes. Do not require -both pre-commit and post-commit full checker runs for unchanged content. +Commit the release note and associated changes before release validation. The +publication command captures the exact candidate and rechecks it after the +validator. A changed candidate requires a fresh invocation and validation. Do not publish uncommitted or unvalidated content. Local check failures still block publication. Skipped test cases are reported, not separate human sign-off requirements. @@ -177,25 +166,25 @@ research belongs to maintenance or relevant changes, not every release. ## Publication Guards -Immediately before publication, the maintainer or release automation must -fetch the current primary branch and tags from the authoritative remote and -verify all of the following: +Publication requires authorization before invoking the command. The command +must verify all of the following: -- the version has the required format and is not already published; -- the current branch is the primary release branch; -- the worktree and index are clean; -- the current commit is the recorded, validated candidate; -- the authoritative remote branch points to that same commit; -- the matching release note exists in that commit and has the required title - and sections; -- release publication is authorized and the local automated gates succeeded for - that commit; and -- no local or remote tag already uses the selected version. +- the stable version has the required format and is unused locally and on the + authoritative remote; +- the current branch is `main`, and the worktree and index are clean; +- the committed source and matching release note identify the exact validated + candidate; +- the authoritative remote main can advance to that candidate without + discarding history; +- the local automated gates succeeded for that commit; and +- the annotated tag identifies that candidate and only the intended refs are + published. -Create the version tag against the guarded commit and verify its target before -publishing it. Publish only the exact intended ref and deliverables. Broad -operations such as `git push --tags` are discouraged because they can publish -unreviewed refs unrelated to the release. +Push the exact candidate to authoritative main, then create and verify the +annotated tag before pushing only its ref. Do not use `git push --tags` or +implicit follow-tags publication. A failed or ambiguous push receives one +authoritative-state check before success, rejection, or unknown status is +reported. The repository-specific procedure must make these guards executable or give copyable commands for performing them. @@ -207,10 +196,13 @@ overwrite, recreate, or delete a published tag, or replace a published artifact, checksum, signature, provenance statement, or release note under that version. The checked-in note at the immutable tag is that release's historical record. -If validation fails before publication, correct the candidate, rerun the -complete gates, and repeat the publication guards. An unpublished local tag -may be removed after confirming that it was never published or used by a -release channel. +If validation fails before publication, correct and commit the candidate and +rerun the publication command. If a push fails or its outcome is ambiguous, +inspect the authoritative remote before retrying. Remove an unpublished local +tag only after confirming it was never published or used by a release channel. +An unavailable remote leaves publication status unknown; do not overwrite a tag +to resolve uncertainty. A transient transport failure alone does not require +a new version. If publication or post-publication verification reveals a defect, leave the published release intact. Correct the defect on the primary branch, choose a @@ -223,25 +215,11 @@ release's contents or reuse its version. ## Repository Release Procedure -Every adopting repository must maintain `docs/release.md` as the canonical -maintainer runbook for implementing this policy. It must define, in execution -order: - -1. maintainer prerequisites and required access; -2. version selection and compatibility review; -3. release-note creation; -4. candidate preparation and commit; -5. one local validation pass, exact candidate recording, and push; -6. publication guards; -7. exact tag and deliverable publication commands; -8. completion on successful tag push and optional post-publication checks; and -9. failure handling and correction. - -The procedure should use copyable, fail-fast commands where practical. It must -name the primary branch, authoritative remote, tag form, published -deliverables, supported build targets, release automation responsibilities, -and optional consumer verification path. It must link to this policy instead of -restating or redefining its requirements. +Maintain `docs/release.md` as the executable maintainer runbook. It identifies +prerequisites, version and note preparation, the single publication command, +declared deliverables and build targets, successful-tag-push completion, and +partial or unknown-state recovery. Keep optional diagnostics and consumer +verification clearly optional. The procedure must agree with this policy. ## Policy Exceptions diff --git a/docs/release.md b/docs/release.md index 84f9a5d..756b01c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,39 +1,34 @@ # ConfigKit Release Procedure This is the maintainer runbook for ConfigKit's [release policy](policy/release.md). -ConfigKit publishes one source-only Go module: the immutable source tag and the -matching checked-in release note are the deliverables. There is no application -binary, container image, generated package, or separate registry publication. +ConfigKit publishes one source-only Go module. The immutable annotated tag and +matching checked-in release note are the deliverables; there is no binary, +container image, generated package, or separate registry publication. ## Release identity and support - Module: `gitea.maximumdirect.net/eric/ConfigKit` - Primary branch: `main` -- Authoritative remote: `origin`, expected to fetch from +- Authoritative remote: `origin`, with fetch and push destination `https://gitea.maximumdirect.net/eric/ConfigKit.git` -- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported -- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained - patch of that line; newer-toolchain testing is optional and non-gating -- Release-gated build targets: Linux on amd64 and arm64 -- Best-effort targets: macOS on amd64 and arm64, under the +- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases +- Minimum Go line: Go 1.25, validated with one maintained patch +- Release-gated targets: Linux amd64 and arm64 +- Best-effort targets: macOS amd64 and arm64, under the [platform support policy](policy/release.md#platform-support) -The local Linux checker includes one offline race suite, one full vet pass, and -CGO-disabled amd64/arm64 cross-builds. -Its output reports skipped filesystem cases; no separate evidence record or -human sign-off is required. Native macOS checks and Darwin cross-builds are -optional and do not gate publication. - -CI is never a release gate. Do not wait for it, poll its status, or delay reporting -completion because it is pending, unavailable, or failed. +The checker runs one offline race suite, one full vet pass, and CGO-disabled +builds for both Linux targets. It reports skipped filesystem cases. macOS checks +and newer-toolchain tests are optional. CI is never a readiness or completion +gate; do not wait for or poll it. ## 1. Prepare access and tools -The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer, -a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS -Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module -access through the normal credential manager before starting. Keep tokens, passwords, -SSH material, and environment dumps outside the repository and command transcript. +Use a clean checkout with Git, Bash 3.2 or newer, a maintained Go 1.25 patch, +and a native compiler for race tests. Configure private-host access through the +normal credential manager. Keep credentials and environment dumps outside the +repository and command transcript. + Verify access without displaying credentials: ```sh @@ -41,9 +36,9 @@ git remote get-url origin git ls-remote origin HEAD ``` -The remote URL must equal the authoritative URL above. Acquire the selected -toolchain and dependencies explicitly. For example, with the maintained patch -used when this procedure was simplified: +The fetch URL and all configured push destinations must resolve to the +authoritative URL above. The publication command checks this again. Acquire the +selected toolchain and dependencies explicitly before offline validation: ```sh export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14 @@ -51,205 +46,127 @@ GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download ``` -Keep toolchains maintained, but do not require latest-version discovery for each -release. The checker sets `GOPROXY=off` and fails if the cache is incomplete. -Toolchain and YAML-dependency update research belongs to periodic maintenance or -relevant dependency changes. No renewed approval of an unchanged pin is required. - -Use Bash with `set -euo pipefail` for the command blocks below so any failed gate -stops the sequence. Publication requires user authorization; the agent policy's -local-commit authorization is not permission to push or release. +A maintained patch may replace the example pin. No per-release latest-version +survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`. +Publication requires user authorization. Standing authorization for local +commits does not authorize a push or release. ## 2. Select the version and review compatibility -Choose an unused stable version and compare the candidate with the previous tag. -For the first release, review the whole public surface and current contracts. +Choose an unused stable version. Compare the candidate with the previous tag. +Review exported Go signatures and ownership, YAML and scalar semantics, +precedence, diagnostic codes, minimum Go version, and platform support. A +signature-preserving change may still affect consumers. Before `v1.0.0`, +intentional incompatible minor-release changes need a note explaining impact +and consumer action; patch releases remain compatible within their minor line. +Review the maintained consumer examples and migration guide. -```sh -release_version=v0.1.0 -printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' -if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi -if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then - printf 'remote tag already exists: %s\n' "$release_version" >&2 - exit 1 -else - test $? -eq 2 -fi -git log --oneline --decorate --no-merges -``` +When dependencies change, review parser behavior and migration impact as part +of that change. Module tidiness belongs to dependency-change review, not the +release checker. Do not repeat a dependency-version survey for every release. -Review exported Go signatures and ownership, YAML and scalar behavior, precedence, -diagnostic codes, minimum Go version, and platform support. Before `v1.0.0`, any -intentional incompatible minor-release change must be explained with consumer action; -patch releases must remain compatible within their minor line. Review the maintained -consumer examples and the migration guide even when signatures did not change. +## 3. Write and commit the release note -When dependencies change, review parser behavior and migration impact as part of -that change. Keep Compatibility accurate about the dependency pin, but do not -perform an external version survey for every patch release. +Create `docs/releases/.md` for the selected version. Its first line +must be `# ConfigKit `. Give a meaningful summary and include +compatibility impact or upgrade instructions when applicable. Link to current +contracts for exact behavior. Review the note and affected canonical documents +as ordinary preparation, then commit the complete candidate. -## 3. Write the release note - -Create `docs/releases/$release_version.md` before selecting the candidate. It must -begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`, -`Upgrade`, and `Changes` sections. Use “No action required” where appropriate. Link -to current canonical contracts instead of copying their definitions. Do not create a -release note until there is a real candidate and reviewed content. - -End each new release note with the footer required by the -[OSS policy](policy/oss.md#discoverability). For the selected version, use these -absolute Gitea URLs (replace every `VERSION` with the complete intended tag): +End every new note with these links, replacing `VERSION` with the complete tag: ```markdown [COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) ``` -Confirm the three target files exist in the candidate and the version is correct -during ordinary note review. The future tag need not resolve remotely yet; do -not add publication-time network checks. ConfigKit publishes source tags with -checked-in notes, not separate hosted download pages. If a hosted release page -is used later, reuse the checked-in note as its body and retain this footer. -Do not retrofit published notes or add separate downloadable license assets. +Confirm the three target files exist in the candidate and review the version in +the URLs. The future tag need not resolve remotely yet; this review adds no +network gate. ConfigKit uses checked-in notes without hosted release objects. +If a hosted page is used later, reuse the note and preserve its footer. Do not +rewrite historical notes or add downloadable license assets. -## 4. Validate the source candidate +## 4. Publish the committed candidate -Before release validation, finish compatibility review, inspect the diff and file -list for unintended content or credentials, retire completed roadmaps, repair -links, and review the release note. These are ordinary preparation tasks the -executing agent may perform, not separate human approvals or evidence records. -Ask the maintainer only for an unresolved product decision or missing authority. +Before publication, review compatibility, release-note accuracy, and the file +list for unintended content or credentials. Retire completed roadmaps as part of +release preparation, preserving unfinished and deferred work and repairing +links. These are ordinary preparation tasks, not separate human sign-offs. -Commit the candidate, staging only its intended changes, then run the checker -once against that clean commit. Do not run both everyday and release checkers to -repeat the same gates. +With the release explicitly authorized, run the one publication command. +The script resolves the repository root from its own path, so it may be invoked +from another directory by using its path there. Replace the sample version with +the selected version: ```sh -git status --short -# Stage the specific files belonging to the candidate, then review: -git diff --staged -git commit --message "Prepare ConfigKit $release_version" -test -z "$(git status --porcelain=v1 --untracked-files=all)" -candidate_commit=$(git rev-parse HEAD) -./scripts/release-check.sh -test "$(git rev-parse HEAD)" = "$candidate_commit" -test -z "$(git status --porcelain=v1 --untracked-files=all)" -printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)" +./scripts/release.sh v0.5.0 ``` -If the final candidate is already committed, omit the staging and commit commands; -do not create an empty commit. The checker runs one full offline race suite with -verbose skip reporting and test-time vet disabled, one full vet pass, and -CGO-disabled Linux amd64/arm64 builds. Go commands use readonly module mode. -The suite includes examples, consumer fixtures, documentation links, and package -boundaries; no separate reruns are required. Formatting and whitespace remain -ordinary development checks in `scripts/check.sh`; module tidiness is reviewed -when dependencies change. No additional linter is claimed. Sibling-consumer links -are checked when those checkouts exist and are otherwise inapplicable in an -isolated checkout. +The command requires `main`, a clean index/worktree, an unused version locally +and on the authoritative remote, and a matching note in the captured commit. +Ignored local files do not block it. It checks remote ancestry, runs +[`release-check.sh`](../scripts/release-check.sh) once against that commit, +rechecks candidate and version state, pushes only that commit to `main`, then +creates and pushes one annotated tag. The tag message is the checked-in note. +It never commits or repairs the checkout. -A changed candidate requires validation again. Do not reuse a recorded candidate -from an older revision. The record is written only after successful local checks. +A successful intended tag push completes publication. The command reports the +version and commit immediately. No CI, module retrieval, remote read-back, or +hosted release object is required. The checker may be run separately for +diagnosis, but it is not a prerequisite: a fresh publication invocation +validates its candidate once. -## 5. Prepare publication +## 5. Recover from a failed or uncertain push -The following guarded sequence pushes only `main` and the selected tag. Local -validation failures block it; CI does not. Keep the selected `release_version` -from step 2 in the same shell. If starting a new shell, set it explicitly again. +The command performs one bounded authoritative-state check after a failed push. +If the main push failed but authoritative main equals the candidate, it proceeds. +If the tag push failed but its exact tag object reached the remote, it reports +success. Otherwise it reports branch-only, conflicting, or unknown state and +retains any local tag. -## 6. Run publication guards - -Immediately before tagging, fetch the authoritative branch and tags, restore the -recorded variables if this is a new shell, and execute every guard: +Inspect exact refs before any retry. Set `release_version` to the attempted +version and compare main with the candidate printed or recorded from the run: ```sh -candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)") -git fetch --prune origin main -git fetch --tags origin -test "$(git branch --show-current)" = main -test -z "$(git status --porcelain=v1 --untracked-files=all)" -test "$(git rev-parse HEAD)" = "$candidate_commit" -test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git' -printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' -test -f "docs/releases/$release_version.md" -test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $release_version" -for heading in Summary Compatibility Upgrade Changes; do - grep -Fxq "## $heading" "docs/releases/$release_version.md" -done -if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi -if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then - printf 'remote tag already exists: %s\n' "$release_version" >&2 - exit 1 +release_version=v0.5.0 +authoritative=https://gitea.maximumdirect.net/eric/ConfigKit.git +git ls-remote --refs --exit-code "$authoritative" refs/heads/main +ref="refs/tags/$release_version" +if remote_tag=$(git ls-remote --refs --exit-code "$authoritative" "$ref"); then + printf 'remote tag: %s\n' "$remote_tag" else - test $? -eq 2 + result=$? + if [ "$result" -eq 2 ]; then + printf 'remote tag confirmed absent\n' + else + printf 'remote tag state unknown; do not reuse the version\n' >&2 + fi fi -git push origin HEAD:refs/heads/main -git fetch origin main -test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit" -test "$(git rev-parse HEAD)" = "$candidate_commit" -test -z "$(git status --porcelain=v1 --untracked-files=all)" +git rev-parse --verify "refs/tags/$release_version" ``` -The recorded local validation must apply to exactly `$candidate_commit`. No -separate human sign-off, CI status, or manual evidence document is required. - -## 7. Tag and publish the exact ref - -Create one annotated tag at the guarded commit, verify its peeled target, and push -only that ref. Do not use `git push --tags`. +A query failure is not proof that the tag is absent. Compare the remote tag +object ID with the local annotated tag object ID; its peeled commit alone is +insufficient to establish identical publication. A conflicting published tag +must not be moved or replaced. If remote main advanced but the tag was not +published, report that partial progress. Only after confirming the local tag +was never published or used by another channel may a maintainer remove it: ```sh -git tag --annotate "$release_version" --message "ConfigKit $release_version" "$candidate_commit" -test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit" -git push origin "refs/tags/$release_version:refs/tags/$release_version" +git tag -d "$release_version" ``` -A successful push of the intended tag completes the release. Report success now; -do not wait for CI, module propagation, or any post-publication check. No Gitea -release object or binary upload is required. +A subsequent invocation revalidates the candidate; a correctly published tag +needs no rerun. Do not roll back main or automatically delete a tag. Transport +or authentication failure alone does not require a new version. A defect in +published source requires a corrected commit, a new semantically appropriate +version and note, and the release procedure again. -## 8. Optional post-publication checks +## 6. Optional consumer verification Only when useful or requested, read back the remote tag or retrieve the exact -version into a new module without a local `replace` directive. These checks are -optional and non-gating; their failure or unavailability does not delay release -completion. Do not poll CI or wait for module propagation. Private-host credentials -must already be configured. Example direct-origin verification: - -```sh -remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}') -test "$remote_tag_commit" = "$candidate_commit" -consumer_dir=$(mktemp -d) -( - cd "$consumer_dir" - go mod init example.invalid/configkit-release-check - GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version" - cat > main.go <<'EOF' -package main - -import configkit "gitea.maximumdirect.net/eric/ConfigKit" - -func main() { - _ = configkit.NoSource() -} -EOF - gofmt -w main.go - go test ./... - go build ./... - go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit -) -rm -rf -- "$consumer_dir" -``` - -If performed, the final `go list` output should show the exact module path and -`$release_version`. Report optional verification separately from publication success. - -## 9. Handle failures without replacement - -Before publication, fix and commit the candidate, validate it, record the new -exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it -was never pushed or used by another release channel. - -After publication, never move, delete, or recreate the tag and never replace its -release note. Correct the defect on `main`, choose a new semantically appropriate -version, and repeat this procedure. A supported advisory or deprecation mechanism -may warn consumers about the old version but cannot change its contents. +version into a new module without a local `replace` directive. These checks +are optional and non-gating. Do not poll CI or wait for module propagation. +Private-host credentials must already be configured. For direct-origin +verification, use a temporary consumer module and check the exact module path +and version reported by `go list -m`. Report optional verification separately +from publication success. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6695983..78d04a4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -78,6 +78,14 @@ validation remain optional. ## Stage 2 — Implement single-command publication and recovery +**Completed 2026-09-28.** Added the executable single-command publisher, exact +candidate and destination guards, one checker invocation, explicit main-then-tag +pushes, note-derived annotated tags, and bounded failed-push state checks. +Disposable Git fixtures covered success, refusals, branch-only progress, +conflicting tags, and unknown outcomes without contacting the real remote. +Bash syntax checks, focused tests, the offline default suite, local links, and +whitespace review passed. + Add executable `scripts/release.sh`, calling the existing validator once rather than duplicating it. Replace `TestReleaseRunbookGuards` with tests of the actual script: prose headings cease to be an executable interface. Update the release diff --git a/release_check_test.go b/release_check_test.go index aa80b88..a199c22 100644 --- a/release_check_test.go +++ b/release_check_test.go @@ -9,120 +9,6 @@ import ( "testing" ) -// Exercise the copyable runbook against disposable Git repositories, not a -// publication service. The checker itself is covered separately. -func TestReleaseRunbookGuards(t *testing.T) { - document, err := os.ReadFile("docs/release.md") - if err != nil { - t.Fatal(err) - } - block := func(heading string) string { - t.Helper() - _, section, ok := strings.Cut(string(document), heading) - if !ok { - t.Fatalf("missing section %q", heading) - } - _, section, ok = strings.Cut(section, "```sh\n") - if !ok { - t.Fatalf("missing commands in %q", heading) - } - commands, _, ok := strings.Cut(section, "```") - if !ok { - t.Fatalf("unterminated commands in %q", heading) - } - return commands - } - prepare := block("## 4. Validate the source candidate") - publish := block("## 6. Run publication guards") + block("## 7. Tag and publish the exact ref") - for _, scenario := range []string{"success", "dirty", "changed-candidate", "remote-tag-exists", "failed-validation"} { - t.Run(scenario, func(t *testing.T) { - root := t.TempDir() - remote := filepath.Join(t.TempDir(), "remote.git") - run := func(program string, args ...string) string { - t.Helper() - cmd := exec.Command(program, args...) - cmd.Dir = root - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("%s %v: %v\n%s", program, args, err, out) - } - return strings.TrimSpace(string(out)) - } - run("git", "init", "-q", "-b", "main") - run("git", "init", "-q", "--bare", remote) - run("git", "config", "user.name", "Test") - run("git", "config", "user.email", "test@example.invalid") - run("git", "config", "commit.gpgsign", "false") - run("git", "config", "tag.gpgsign", "false") - const origin = "https://gitea.maximumdirect.net/eric/ConfigKit.git" - run("git", "remote", "add", "origin", remote) - // Substitute only the fixture's authoritative URL; execute the guards - // themselves unchanged. Disallow network transports as a backstop. - localPublish := strings.ReplaceAll(publish, origin, remote) - for _, dir := range []string{"docs/releases", "scripts"} { - if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { - t.Fatal(err) - } - } - write := func(path, content string) { - t.Helper() - if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0700); err != nil { - t.Fatal(err) - } - } - checker := "#!/bin/sh\nexit 0\n" - if scenario == "failed-validation" { - checker = "#!/bin/sh\nexit 37\n" - } - write("scripts/release-check.sh", checker) - write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n") - run("git", "add", ".") - bash := func(commands string) ([]byte, error) { - cmd := exec.Command("bash", "-c", "set -euo pipefail\nrelease_version=v0.9.0\n"+commands) - cmd.Dir = root - cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file") - return cmd.CombinedOutput() - } - out, err := bash(prepare) - if scenario == "failed-validation" { - if err == nil { - t.Fatal("failed validation accepted") - } - if _, err := os.Stat(filepath.Join(root, ".git/configkit-release-candidate")); !os.IsNotExist(err) { - t.Fatal("failed validation recorded a candidate") - } - } else if err != nil { - t.Fatalf("prepare: %v\n%s", err, out) - } - switch scenario { - case "dirty": - write("unexpected.txt", "uncommitted\n") - case "changed-candidate": - run("git", "commit", "--allow-empty", "-qm", "Changed candidate") - case "remote-tag-exists": - run("git", "tag", "v0.9.0") - run("git", "push", remote, "refs/tags/v0.9.0") - run("git", "tag", "-d", "v0.9.0") - } - // Seed the remote primary branch so fetch works before first publication. - run("git", "push", remote, "HEAD:refs/heads/main") - out, err = bash(localPublish) - if scenario == "success" { - if err != nil { - t.Fatalf("publish: %v\n%s", err, out) - } - if got := run("git", "--git-dir="+remote, "rev-parse", "v0.9.0^{}"); got != run("git", "rev-parse", "HEAD") { - t.Fatal("published wrong commit") - } - } else if err == nil { - t.Fatalf("publication accepted %s", scenario) - } else if scenario != "remote-tag-exists" && run("git", "--git-dir="+remote, "tag", "--list") != "" { - t.Fatal("failed guard published a tag") - } - }) - } -} - func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) { for _, scriptName := range []string{"check.sh"} { t.Run(scriptName, func(t *testing.T) { diff --git a/release_publish_test.go b/release_publish_test.go new file mode 100644 index 0000000..8794269 --- /dev/null +++ b/release_publish_test.go @@ -0,0 +1,322 @@ +package configkit + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type releaseFixture struct { + t *testing.T + root string + remote string + base string + candidate string + validationLog string + realGit string +} + +func newReleaseFixture(t *testing.T) *releaseFixture { + t.Helper() + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + f := &releaseFixture{ + t: t, + root: t.TempDir(), + remote: filepath.Join(t.TempDir(), "remote.git"), + validationLog: filepath.Join(t.TempDir(), "validator.log"), + realGit: realGit, + } + f.git("init", "-q", "-b", "main") + f.git("init", "-q", "--bare", f.remote) + f.git("config", "user.name", "Test") + f.git("config", "user.email", "test@example.invalid") + f.git("config", "commit.gpgsign", "false") + f.git("config", "tag.gpgsign", "false") + f.git("config", "push.followTags", "true") + f.git("remote", "add", "origin", f.remote) + f.write(".gitignore", "ignored.log\n", 0600) + f.write("README.md", "fixture\n", 0600) + f.git("add", ".") + f.git("commit", "-qm", "Initial source") + f.base = f.git("rev-parse", "HEAD") + f.git("push", f.remote, "HEAD:refs/heads/main") + + script, err := os.ReadFile("scripts/release.sh") + if err != nil { + t.Fatal(err) + } + // Only the fixture copy substitutes the authoritative endpoint. + local := strings.ReplaceAll(string(script), "https://gitea.maximumdirect.net/eric/ConfigKit.git", f.remote) + f.write("scripts/release.sh", local, 0700) + f.write("scripts/release-check.sh", `#!/bin/sh +printf 'validate\n' >> "$CONFIGKIT_VALIDATION_LOG" +case "${CONFIGKIT_VALIDATOR_ACTION-}" in + fail) exit 37 ;; + change) git commit --allow-empty -qm 'Changed during validation' ;; + dirty) printf 'changed\n' > unexpected.txt ;; + remote-tag) git --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" ;; +esac +`, 0700) + f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\nConfiguration loading fixes.\n\nCompatibility is unchanged.\n", 0600) + f.git("add", ".") + f.git("commit", "-qm", "Prepared source") + f.candidate = f.git("rev-parse", "HEAD") + return f +} + +func (f *releaseFixture) write(path, contents string, mode os.FileMode) { + f.t.Helper() + target := filepath.Join(f.root, path) + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(target, []byte(contents), mode); err != nil { + f.t.Fatal(err) + } +} + +func (f *releaseFixture) git(args ...string) string { + f.t.Helper() + cmd := exec.Command(f.realGit, args...) + cmd.Dir = f.root + cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + out, err := cmd.CombinedOutput() + if err != nil { + f.t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func (f *releaseFixture) gitResult(args ...string) (string, error) { + f.t.Helper() + cmd := exec.Command(f.realGit, args...) + cmd.Dir = f.root + cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func (f *releaseFixture) run(version, behavior, validatorAction string) (string, error) { + f.t.Helper() + bin := f.t.TempDir() + wrapper := `#!/bin/sh +if [ "${CONFIGKIT_GIT_BEHAVIOR-}" = main-unknown ] || [ "${CONFIGKIT_GIT_BEHAVIOR-}" = tag-unknown ]; then + if [ "${1-}" = ls-remote ] && [ -f "$CONFIGKIT_GIT_MARKER" ]; then exit 37; fi +fi +if [ "${1-}" = push ]; then + case "$*" in + *refs/heads/main*) + case "${CONFIGKIT_GIT_BEHAVIOR-}" in + main-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;; + main-reject) exit 37 ;; + main-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;; + esac ;; + *refs/tags/v0.9.0*) + case "${CONFIGKIT_GIT_BEHAVIOR-}" in + tag-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;; + tag-reject) exit 37 ;; + tag-conflict) + "$CONFIGKIT_REAL_GIT" --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" + exit 37 ;; + tag-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;; + esac ;; + esac +fi +exec "$CONFIGKIT_REAL_GIT" "$@" +` + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil { + f.t.Fatal(err) + } + nested := filepath.Join(f.root, "nested") + if err := os.Mkdir(nested, 0700); err != nil { + f.t.Fatal(err) + } + cmd := exec.Command("/bin/bash", filepath.Join(f.root, "scripts", "release.sh"), version) + cmd.Dir = nested + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "CONFIGKIT_VALIDATION_LOG="+f.validationLog, + "CONFIGKIT_VALIDATOR_ACTION="+validatorAction, + "CONFIGKIT_GIT_BEHAVIOR="+behavior, + "CONFIGKIT_GIT_MARKER="+filepath.Join(bin, "push-seen"), + "CONFIGKIT_REAL_GIT="+f.realGit, + "CONFIGKIT_REMOTE="+f.remote, + "CONFIGKIT_BASE="+f.base, + ) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (f *releaseFixture) validations() int { + f.t.Helper() + data, err := os.ReadFile(f.validationLog) + if os.IsNotExist(err) { + return 0 + } + if err != nil { + f.t.Fatal(err) + } + return strings.Count(string(data), "validate\n") +} + +func (f *releaseFixture) remoteRef(ref string) string { + f.t.Helper() + value, err := f.gitResult("--git-dir="+f.remote, "rev-parse", "--verify", ref) + if err != nil { + return "" + } + return value +} + +func TestReleasePublicationGuards(t *testing.T) { + for _, scenario := range []string{ + "success", "main-already-current", "ignored-file", "unrelated-tag", + "malformed-version", "wrong-branch", "wrong-destination", "multiple-destinations", + "dirty-worktree", "changed-candidate", "missing-note", "mismatched-note", + "local-tag", "remote-tag", "remote-tag-after-validation", + "dirty-after-validation", "remote-query-failure", + "failed-validation", "non-fast-forward", + } { + t.Run(scenario, func(t *testing.T) { + f := newReleaseFixture(t) + version := "v0.9.0" + validatorAction := "" + switch scenario { + case "main-already-current": + f.git("push", f.remote, "HEAD:refs/heads/main") + case "ignored-file": + f.write("ignored.log", "local only\n", 0600) + case "unrelated-tag": + f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base) + case "malformed-version": + version = "v00.9.0" + case "wrong-branch": + f.git("checkout", "-qb", "feature") + case "wrong-destination": + other := filepath.Join(t.TempDir(), "other.git") + f.git("init", "-q", "--bare", other) + f.git("remote", "set-url", "--push", "origin", other) + case "multiple-destinations": + f.git("remote", "set-url", "--push", "origin", f.remote) + f.git("remote", "set-url", "--add", "--push", "origin", filepath.Join(t.TempDir(), "other.git")) + case "dirty-worktree": + f.write("untracked.txt", "unexpected\n", 0600) + case "changed-candidate": + validatorAction = "change" + case "dirty-after-validation": + validatorAction = "dirty" + case "missing-note": + f.git("rm", "docs/releases/v0.9.0.md") + f.git("commit", "-qm", "Remove note") + case "mismatched-note": + f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.1\n", 0600) + f.git("add", ".") + f.git("commit", "-qm", "Mismatch note") + case "local-tag": + f.git("tag", "v0.9.0") + case "remote-tag": + f.git("tag", "v0.9.0") + f.git("push", f.remote, "refs/tags/v0.9.0") + f.git("tag", "-d", "v0.9.0") + case "remote-tag-after-validation": + validatorAction = "remote-tag" + case "remote-query-failure": + if err := os.Rename(f.remote, f.remote+".unavailable"); err != nil { + t.Fatal(err) + } + case "failed-validation": + validatorAction = "fail" + case "non-fast-forward": + f.git("checkout", "-qb", "other", f.base) + f.git("commit", "--allow-empty", "-qm", "Divergent source") + f.git("push", f.remote, "HEAD:refs/heads/main") + f.git("checkout", "main") + } + out, err := f.run(version, "", validatorAction) + success := scenario == "success" || scenario == "main-already-current" || + scenario == "ignored-file" || scenario == "unrelated-tag" + if success != (err == nil) { + t.Fatalf("unexpected publication result: %v\n%s", err, out) + } + wantChecks := 1 + switch scenario { + case "malformed-version", "wrong-branch", "wrong-destination", + "multiple-destinations", "dirty-worktree", "missing-note", + "mismatched-note", "local-tag", "remote-tag", "remote-query-failure", + "non-fast-forward": + wantChecks = 0 + } + if got := f.validations(); got != wantChecks { + t.Fatalf("validator ran %d times, want %d", got, wantChecks) + } + if success { + if got := f.remoteRef("refs/heads/main"); got != f.candidate { + t.Fatalf("published main %s, want %s", got, f.candidate) + } + if got := f.remoteRef("refs/tags/v0.9.0^{}"); got != f.candidate { + t.Fatalf("tag points to %s, want %s", got, f.candidate) + } + if f.remoteRef("refs/tags/v0.8.0") != "" { + t.Fatal("unrelated local tag was published") + } + tagData := f.git("--git-dir="+f.remote, "cat-file", "-p", "refs/tags/v0.9.0") + _, message, ok := strings.Cut(tagData, "\n\n") + if !ok || strings.TrimSpace(message) != strings.TrimSpace(f.git("show", f.candidate+":docs/releases/v0.9.0.md")) { + t.Fatal("annotated tag does not contain the checked-in note") + } + } else if scenario != "remote-tag" && scenario != "remote-tag-after-validation" && + f.remoteRef("refs/tags/v0.9.0") != "" { + t.Fatal("failed guard published a tag") + } + }) + } +} + +func TestReleasePublicationRecovery(t *testing.T) { + for _, scenario := range []string{ + "main-lost", "main-reject", "main-unknown", + "tag-lost", "tag-reject", "tag-conflict", "tag-unknown", + } { + t.Run(scenario, func(t *testing.T) { + f := newReleaseFixture(t) + out, err := f.run("v0.9.0", scenario, "") + success := scenario == "main-lost" || scenario == "tag-lost" + if success != (err == nil) { + t.Fatalf("unexpected publication result: %v\n%s", err, out) + } + if got := f.validations(); got != 1 { + t.Fatalf("validator ran %d times", got) + } + remoteMain := f.remoteRef("refs/heads/main") + remoteTag := f.remoteRef("refs/tags/v0.9.0") + localTag, localTagErr := f.gitResult("rev-parse", "--verify", "refs/tags/v0.9.0") + if localTagErr != nil { + localTag = "" + } + switch scenario { + case "main-lost", "tag-lost": + if remoteMain != f.candidate || remoteTag == "" || remoteTag != localTag { + t.Fatal("confirmed publication did not preserve exact refs") + } + case "main-reject", "main-unknown": + if remoteMain != f.base || remoteTag != "" || localTag != "" { + t.Fatal("failed main push created publication state") + } + case "tag-reject", "tag-unknown": + if remoteMain != f.candidate || remoteTag != "" || localTag == "" { + t.Fatal("failed tag push did not retain branch-only state") + } + case "tag-conflict": + if remoteMain != f.candidate || remoteTag != f.base || localTag == "" { + t.Fatal("conflicting remote tag was overwritten") + } + } + }) + } +} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..45785e6 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repository_dir" + +remote_url=https://gitea.maximumdirect.net/eric/ConfigKit.git + +fail() { + printf '%s\n' "$*" >&2 + exit 1 +} + +check_candidate() { + local current status + current=$(git branch --show-current) || fail "cannot read the current branch" + [[ "$current" == main ]] || fail "release requires the main branch" + status=$(git status --porcelain=v1 --untracked-files=all) || fail "cannot inspect the worktree" + [[ -z "$status" ]] || fail "release requires a clean index and worktree" + current=$(git rev-parse --verify HEAD) || fail "cannot identify the candidate" + [[ "$current" == "$candidate" ]] || fail "candidate changed; commit and validate it again" +} + +check_unused_version() { + if git show-ref --verify --quiet "refs/tags/$version"; then + fail "version already has a local tag" + fi + local oid result + if oid=$(read_remote_ref "refs/tags/$version"); then + fail "version already has an authoritative tag" + else + result=$? + ((result == 2)) || fail "cannot determine authoritative tag state; publication status is unknown" + fi +} + +read_remote_ref() { + local ref=$1 response result + if response=$(git ls-remote --refs --exit-code "$remote_url" "$ref" 2>/dev/null); then + [[ "$response" == *$'\t'"$ref" ]] || return 3 + printf '%s\n' "${response%%$'\t'*}" + else + result=$? + ((result == 2)) && return 2 + return 3 + fi +} + +if (($# != 1)); then + fail "usage: ./scripts/release.sh vMAJOR.MINOR.PATCH" +fi +version=$1 +[[ "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || + fail "release version must be a stable vMAJOR.MINOR.PATCH without leading zeroes" + +fetch_urls=$(git remote get-url --all origin 2>/dev/null) || fail "origin is unavailable" +push_urls=$(git remote get-url --push --all origin 2>/dev/null) || fail "origin push destination is unavailable" +[[ "$fetch_urls" == "$remote_url" && "$push_urls" == "$remote_url" ]] || + fail "origin must have exactly the authoritative fetch and push destination" + +candidate=$(git rev-parse --verify HEAD) || fail "cannot identify the candidate" +check_candidate +check_unused_version + +note_path="docs/releases/$version.md" +note_file=$(mktemp) +trap 'rm -f -- "$note_file"' EXIT +git show "$candidate:$note_path" >"$note_file" 2>/dev/null || + fail "matching release note is absent from the candidate" +IFS= read -r title <"$note_file" || [[ -n "$title" ]] || + fail "matching release note is empty" +[[ "$title" == "# ConfigKit $version" ]] || fail "release note title does not match the version" + +if remote_main=$(read_remote_ref refs/heads/main); then + : +else + result=$? + ((result == 2)) && fail "authoritative main is absent" + fail "cannot read authoritative main; publication status is unknown" +fi +git fetch --no-tags "$remote_url" refs/heads/main >/dev/null || + fail "cannot fetch authoritative main" +fetched_main=$(git rev-parse --verify FETCH_HEAD) || fail "cannot inspect fetched main" +git merge-base --is-ancestor "$fetched_main" "$candidate" || + fail "candidate cannot advance authoritative main without discarding history" + +./scripts/release-check.sh || fail "release validation failed; no refs were published" +check_candidate +check_unused_version + +if git push --no-follow-tags "$remote_url" "$candidate:refs/heads/main"; then + : +else + if observed_main=$(read_remote_ref refs/heads/main); then + [[ "$observed_main" == "$candidate" ]] || + fail "main push did not publish; authoritative main differs from the candidate" + else + result=$? + ((result == 2)) && fail "main push failed; authoritative main is absent" + fail "main push outcome is unknown; inspect authoritative main before retrying" + fi +fi + +check_candidate +git tag -a -F "$note_file" --cleanup=verbatim "$version" "$candidate" || + fail "could not create the local annotated tag" +tag_target=$(git rev-parse --verify "refs/tags/$version^{}") || + fail "cannot verify the local tag target" +[[ "$tag_target" == "$candidate" ]] || fail "local tag does not identify the candidate" +tag_type=$(git cat-file -t "refs/tags/$version") || + fail "cannot inspect the local tag" +[[ "$tag_type" == tag ]] || fail "release tag must be annotated" +tag_oid=$(git rev-parse --verify "refs/tags/$version") || + fail "cannot identify the annotated tag" + +if git push --no-follow-tags "$remote_url" "refs/tags/$version:refs/tags/$version"; then + : +else + if observed_tag=$(read_remote_ref "refs/tags/$version"); then + [[ "$observed_tag" == "$tag_oid" ]] || + fail "authoritative tag conflicts with the local tag; never replace it" + else + result=$? + ((result == 2)) && + fail "tag push failed; main is published, but the local tag is unpublished" + fail "tag push outcome is unknown; retain the local tag and inspect authoritative state" + fi +fi + +printf 'Published %s at %s\n' "$version" "$candidate"