Add guarded single-command release publication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
# <Product> 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
|
||||
|
||||
|
||||
299
docs/release.md
299
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/<version>.md` for the selected version. Its first line
|
||||
must be `# ConfigKit <version>`. 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
|
||||
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)"
|
||||
fi
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
322
release_publish_test.go
Normal file
322
release_publish_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
130
scripts/release.sh
Executable file
130
scripts/release.sh
Executable file
@@ -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"
|
||||
Reference in New Issue
Block a user