Align release publication policy and runbook

This commit is contained in:
2026-09-28 01:50:26 +00:00
parent 65502ce5b4
commit ce91f04974
9 changed files with 291 additions and 325 deletions

View File

@@ -78,8 +78,9 @@ go test ./internal/config -run '^TestExamplesLoadAndValidate$'
The documentation check verifies local Markdown links and the dependency graph The documentation check verifies local Markdown links and the dependency graph
of the Woodpecker workflows. The configuration check loads every maintained of the Woodpecker workflows. The configuration check loads every maintained
pipeline and session example. Tag CI reuses this validation path before its pipeline and session example. Tag CI independently revalidates the tagged source
asynchronous asset publication; the maintainer release boundary is documented before asynchronous asset delivery; it is not a second synchronous release
prerequisite. The normal maintainer entry point is the single publication command
in the [Release Procedure](release.md). in the [Release Procedure](release.md).
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled

View File

@@ -3,7 +3,7 @@
## Standard Repository Policies ## Standard Repository Policies
Material: `docs/policy/agents.md`, `architecture.md`, `documentation.md`, Material: `docs/policy/agents.md`, `architecture.md`, `documentation.md`,
`testing.md`, `meta.md`, and `oss.md`; root `AGENTS.md` and the policy reading `testing.md`, `release.md`, `meta.md`, and `oss.md`; root `AGENTS.md` and the policy reading
guide in `docs/development.md`, adapted for this repository. guide in `docs/development.md`, adapted for this repository.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw

View File

@@ -213,6 +213,6 @@ Common ownership of projects is neither required nor a blanket exception.
Apply the formatting convention prospectively. Do not rewrite published tags, Apply the formatting convention prospectively. Do not rewrite published tags,
notes, binaries, or checksums to retrofit it. Address actual omissions deliberately notes, binaries, or checksums to retrofit it. Address actual omissions deliberately
under the applicable terms and the [release correction policy](release.md#immutability-and-correction). under the applicable terms and the [release correction policy](release.md#immutability-and-failure-recovery).
Ordinary attribution maintenance and release links are preparation work, not new Ordinary attribution maintenance and release links are preparation work, not new
release approval gates. release approval gates.

View File

@@ -1,54 +1,60 @@
# Release Policy # Release Policy
**Base Policy Version:** 1.0 **Base Policy Version:** 1.4
**Last Updated**: 2026-09-25 **Last Updated**: 2026-09-28
**Upstream Source**: https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md
## Purpose ## Purpose
This policy defines the minimum requirements for selecting, validating, This policy defines a proportionate workflow for validating and publishing an
publishing, verifying, and correcting releases. Its goal is to make every intended source revision under a fresh version. Releases should require little
release intentional, reproducible from an exact source revision, documented routine intervention, avoid repeated work, and preserve published contents.
for consumers, and safe from retrospective replacement.
The repository-specific commands that implement these requirements belong in The repository-specific commands belong in `docs/release.md`. Historical release
`docs/release.md`. Historical release summaries belong in `docs/releases/`. summaries belong in `docs/releases/`. The procedure must agree with the adopted
Neither document may weaken this policy, including its explicit repository policy and link to it rather than introduce additional release requirements.
exceptions.
## Release Model ## Release Model And Completion
A release consists of: A source release identifies one exact committed source revision through an
immutable version tag and a version-matched, checked-in release note. The
primary release branch is `main` unless the adopted policy specifies otherwise.
The procedure must name the authoritative publication remote.
- one exact source commit reachable from the repository's primary branch; A successful push of the intended tag, with its source commit available on the
- one immutable version tag identifying that commit; authoritative primary branch, completes the release. The source commit may be
- one version-matched, checked-in release note; and pushed before the tag or published together with it through an atomic push.
- every artifact, image, package, registry entry, or other deliverable the The procedure must choose and document the supported approach.
repository declares for that release.
The repository's primary branch is `main` unless an adopted policy documents Release commands must finish at successful publication. They must not wait for
an exception. A release candidate must be committed and pushed to that branch or poll CI, require successful CI status, inspect downloadable artifacts, or
before its release tag is published. check consumer installation or Go module availability as completion gates.
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.
The source tag is the authoritative release identity. Published deliverables Repositories may publish source only. Additional binaries, images, packages,
must be traceable to the tagged source and must agree with its version. A checksums, or other declared deliverables may be published asynchronously from
repository may publish source only. When it publishes additional deliverables, the tagged source. The procedure must identify those deliverables and the
its release procedure must enumerate them and define how each is built, responsibility of their automation. Published deliverables must be traceable
identified, published, and verified. to that source and identify the same version.
Release automation may validate or publish a release only when its exact Track source-release completion and artifact delivery separately. Report delivery
responsibility is documented. Automation must not silently change the source failures and optional investigations under artifact-delivery status.
candidate, move a published tag, replace a published deliverable, or repair a
release in place.
## Versioning And Compatibility ## Versioning And Compatibility
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with 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 no leading zeroes. Prereleases require an explicit adopted policy defining
remote or publication channel before publication begins. their format and use.
Unless an adopted policy explicitly supports and defines prereleases, The selected version must be unused locally and on the authoritative remote.
prerelease versions are not permitted. Do not require queries to every configured remote or publication service.
A version known to have already been published must not be reused, even if the
normal guards do not inspect that channel. Adding a distribution channel
requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`: For releases at or after `v1.0.0`:
@@ -61,161 +67,154 @@ configuration, data, integration, or compatibility contract when the release
note explains the impact and required consumer action. A patch release must note explains the impact and required consumer action. A patch release must
not intentionally break those contracts within its minor line. not intentionally break those contracts within its minor line.
A repository with additional compatibility promises must define them in its Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. A release note may explain adopted policy or canonical contract documentation. Release notes explain
a compatibility change, but it must not redefine the current compatibility changes; they do not redefine current compatibility contracts.
contract.
## Release Documentation ## Release Documentation And Preparation
Every release made under this policy must include Every new release must include `docs/releases/<version>.md` in the tagged
`docs/releases/<version>.md` in the tagged source tree. The filename must use source tree. Use the complete version tag as the filename and begin with a
the complete version tag, and the document must begin with a title containing title containing the product name and that exact version.
the product name and that exact version.
Every new release note must contain these sections: 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.
```markdown Keep exact current contracts in their canonical documents and update affected
# <Product> vMAJOR.MINOR.PATCH documentation with the behavior change. Link to those documents from the note
where useful. If annotated tags are used, derive their summary from the
checked-in note.
This release ... Include the release footer defined by the [OSS policy](oss.md#discoverability)
in each new release document and preserve it on the hosted download page. The
release procedure defines the concrete URLs and publication mechanics. Prepare
these links with the release note; they do not add a network check or approval
gate.
## Summary Handle version selection, release-note content, and retirement of completed
roadmaps during preparation. Preserve planned and deferred work and repair
affected links when retiring roadmaps. Reuse ordinary change review for
unintended files, credentials, and private data. Scope release-content review
to the tagged source.
## Compatibility ## Candidate Validation
## Upgrade Provide one publication command that validates the final committed candidate,
checks publication state, and publishes the intended refs. A validation-only
mode or command may be provided for diagnosis, but must not be a required step
before publication repeats the same checks.
## Changes Run the complete deterministic, offline default test suite once for the final
``` candidate, following the [testing policy](testing.md). For repositories that
publish builds, verify the required build targets. Select those targets for
the application's actual support commitments; native testing on each platform
and access to particular hardware are not default release prerequisites.
Use an explicit statement such as "No action required" when a section has no Additional gates must address a concrete risk that the existing checks do not
substantive content. adequately cover. Do not automatically require linting, race testing, module
hygiene audits, documentation audits, credential scans, example validation, or
expanded platform matrices solely because they are possible. Retain such
checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement.
Release notes are immutable historical summaries. They should link to current Release tests must not require real credentials, mutable application services,
canonical documentation for exact behavior. Every affected canonical document paid APIs, provider probes, or inference calls. The procedure may describe dependency
must be updated in the same candidate; a release note must not substitute for preparation separately from offline validation.
current documentation or defer required documentation changes.
## Candidate Preparation And Validation Avoid duplicate validation across release phases. Successful checks for the
same final candidate need not be repeated solely to satisfy another phase;
any reuse must clearly apply to that exact candidate. Persistent validation
caches are not required. If the candidate changes, validate the new candidate.
Cheap publication-state checks may be repeated immediately before pushing
because local or remote state may have changed during validation.
Each repository must provide one documented release-check command or script Automate mechanical guards. Human review gates are strongly disfavored: add one
that exercises the complete set of automated source-candidate gates. The only through an explicit project-specific policy decision explaining the
repository-specific procedure must state all additional manual gates. concrete risk and why ordinary change review or automation is insufficient.
Do not add routine confirmations for checks the command can perform itself.
The required gates are: ## Narratio Candidate Checks
- dependency or module hygiene appropriate to the language and ecosystem; Narratio's candidate checker adds the following checks to the one full,
- the complete deterministic, offline default test suite; race-enabled offline suite:
- static analysis and linting used by the repository;
- formatting and whitespace checks; - `go mod tidy -diff` detects an incomplete checked-in module graph without
- validation of maintained examples, configuration, schemas, generated changing source.
sources, and documentation commands where applicable; - Tracked Go formatting and diff whitespace checks catch inexpensive source
- review of changed local documentation links; hygiene defects before publication.
- removal of obsolete roadmap documents under `docs/roadmap/` that describe - Race instrumentation adds distinct coverage for concurrent I/O,
completed work included in the release; and cancellation, and shared state.
- review for unintended files, generated output, credentials, private data, - `go vet ./...` detects static issues not established by runtime tests.
and unrelated changes. - The asset builder verifies the declared Linux amd64, Linux arm64, and
best-effort macOS arm64 binaries and checks the embedded version on the host.
The checker uses `GOWORK=off` and readonly module mode for tests, vet, and
builds. Versioned module replacements are allowed; filesystem replacements
are not release inputs. Workspace and vendor files are not prohibited solely
by their presence.
## Publication Guards ## Publication Guards
Immediately before publication, the maintainer or release automation must Automate the checks needed to publish the intended source under a fresh version:
fetch the current primary branch and tags from the authoritative remote and
verify all of the following:
- the version has the required format and is not already published; - the version has the adopted format and is unused locally and on the
- the current branch is the primary release branch; authoritative remote;
- the worktree and index are clean; - the worktree and index are clean on the primary release branch;
- the current commit is the recorded, validated candidate; - the source being published is the exact candidate that passed validation,
- the authoritative remote branch points to that same commit; including its matching release note;
- the matching release note exists in that commit and has the required title - the remote primary branch already identifies that candidate, or an atomic
and sections; source/tag push will advance it to that candidate without discarding remote
- all required approvals and automated gates succeeded for that commit; and history; and
- no local or remote tag already uses the selected version. - the tag identifies the validated candidate and only the intended refs are
published.
Create the version tag against the guarded commit and verify its target before Fetch the authoritative branch and tag state as needed for these checks.
publishing it. Publish only the exact intended ref and deliverables. Broad Do not force branch or tag updates to bypass a failed guard. Publish explicit
operations such as `git push --tags` are discouraged because they can publish refs rather than all local tags. Do not require CI success, publication-service
unreviewed refs unrelated to the release. availability checks, or a separate manual confirmation for each guard.
The repository-specific procedure must make these guards executable or give ## Immutability And Failure Recovery
copyable commands for performing them.
## Immutability And Correction Published version tags and published contents are immutable. Do not move,
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.
Published version tags and published deliverables are immutable. Do not move, If validation fails before publication, correct and commit the candidate and
overwrite, recreate, or delete a published tag. Do not replace an artifact, rerun the publication command. If a push fails or its outcome is ambiguous,
image, package, checksum, signature, provenance statement, or release note inspect the authoritative remote before retrying. Remove an unpublished local
under an already published version. 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.
If validation fails before publication, correct the candidate, rerun the Transient network, authentication, CI, or consumer-availability failures do not
complete gates, and repeat the publication guards. An unpublished local tag by themselves require a new version. Asynchronous delivery may be retried from
may be removed after confirming that it was never published or used by a the same immutable tag. Missing artifacts may be published later, provided
release channel. existing published contents are not replaced and the additions remain consistent
with the tagged source and any already-published checksum manifest. If that
cannot be achieved, publish a new version.
If publication or post-publication verification reveals a defect, leave the A defect requiring changes to published source or contents requires a corrected
published release intact. Correct the defect on the primary branch, choose a commit, a new semantically appropriate version, a matching release note, and
new patch version or other semantically appropriate new version, write a new the release procedure again. Leave the original release intact. A channel's
matching release note, and perform the complete release procedure again. yank, deprecation, or advisory mechanism may warn consumers away from a defective
release without replacing its contents or reusing its version.
A distribution channel's supported yank, deprecation, or advisory mechanism
may warn consumers away from a defective release, but it must not replace that
release's contents or reuse its version.
## Repository Release Procedure ## Repository Release Procedure
Every adopting repository must maintain `docs/release.md` as the canonical Maintain `docs/release.md` as a short, executable maintainer runbook. It must
maintainer runbook for implementing this policy. It must define, in execution identify:
order:
1. maintainer prerequisites and required access; 1. prerequisites, primary branch, and authoritative remote;
2. version selection and compatibility review; 2. version and release-note preparation;
3. release-note creation; 3. one publication command with its selected local checks and automated guards;
4. candidate preparation and the complete validation command; 4. declared deliverables, required build targets, and automation responsibilities;
5. candidate commit, push, and exact commit recording; 5. the successful-push completion point and separately reported delivery status;
6. publication guards; and
7. exact tag and deliverable publication commands; 6. failure handling, ambiguous-push recovery, and asynchronous delivery retries.
8. authoritative remote and consumer-facing verification; and
9. failure handling and correction.
The procedure should use copyable, fail-fast commands where practical. It must Use copyable commands where practical. Keep optional diagnostics and consumer
name the primary branch, authoritative remote, tag form, published verification clearly optional. The procedure must not turn preparation work,
deliverables, supported build targets, release automation responsibilities, ordinary change review, or post-publication investigation into additional
and consumer verification path. It must link to this policy instead of release gates.
restating or redefining its requirements.
## Policy Exceptions
### Tag-push completion and asynchronous deliverables
Narratio's automated release responsibility ends when the exact intended tag is
successfully pushed to `origin`. It must not wait for, poll, or check CI/Gitea
results, download assets, or verify their checksums as a completion condition.
Binary and checksum publication is asynchronous; consumer-facing inspection is
optional follow-up, not a gate on the source release.
This deliberately qualifies the base policy's complete-deliverable and
post-publication verification requirements. CI reliability is outside the
release command's scope; an unavailable publication service must not cause an
automated releaser to wait indefinitely or claim to repair a published version.
Compensating controls are complete local candidate validation (including supported
cross-builds and a host version check), exact source/tag guards, immutable source
tags, asynchronous build validation, and non-overwriting asset publication.
Successful tag publication is not evidence that binary assets are available.
Optional inspection and corrective-release procedures belong in
[the release procedure](../release.md).
### Authoritative publication scope
Narratio's synchronous publication guards inspect the authoritative `origin`
remote and its `main` branch, not every configured Git remote or a Gitea release
entry. The command does not add release-service or registry availability checks.
This qualifies the base policy's all-channel unused-version requirement.
Other remotes are not Narratio release authorities, and Gitea asset publication
is asynchronous. The compensating controls are local and origin tag uniqueness,
exact candidate matching, and the publisher's non-overwrite settings. Any known
already-published version or conflicting deliverable remains ineligible for
reuse: choose a new version rather than bypassing guards. A future additional
distribution channel requires deliberate policy/procedure updates.

View File

@@ -1,167 +1,114 @@
# Releasing Narratio # Releasing Narratio
This is the maintainer runbook for the [Release Policy](policy/release.md). This is the maintainer runbook for the [Release Policy](policy/release.md).
Its [explicit exceptions](policy/release.md#policy-exceptions) preserve the The source release completes when the intended tag is successfully pushed to
successful tag push as the automated responsibility boundary. Binary publication `origin`; binary delivery is a separate asynchronous status.
is asynchronous; later consumer-facing inspection is optional.
## Prerequisites and publication scope ## Prepare the candidate
Use a checkout on `main` with Git authentication permitting fetches and pushes Use `main` and the authoritative Git remote `origin`. Install Git, a POSIX
to `origin`, the authoritative Git remote. Install Git, a POSIX shell, standard shell, standard Unix build tools, and Go 1.26. Have the selected Go modules
Unix build tools, and the Go toolchain below. Make required modules available available before offline validation. Git access must permit fetch and tag push.
before offline validation. No model/provider credentials are needed. Gitea Application-provider credentials are not required; Gitea delivery credentials
publication credentials belong to CI's secret store, not the local command. belong to CI.
Synchronous guards inspect local tags and `origin`, not every configured remote Choose an unused stable `vMAJOR.MINOR.PATCH` under the
or Gitea release entry. Do not select a version known to have been published in [compatibility rules](policy/release.md#versioning-and-compatibility). Narratio
any channel, even if these guards cannot see it. is past v1.0.0, so intentional CLI or configuration breaks require a major
release. Update affected canonical documentation with the change. Write the
matching `docs/releases/vMAJOR.MINOR.PATCH.md` with a short summary and any
relevant compatibility impact or upgrade action; choose sections to suit the
release.
## Go prerequisite End that checked-in note with these three links, replacing the version in
each URL with the intended tag. The tag need not exist remotely yet. Check that
`COPYRIGHT`, `LICENSE`, and `docs/oss/attribution.md` are present in the
candidate. The tag CI reuses the checked-in note as the hosted release body.
Release preparation requires the maintained Go 1.26 baseline. Tag CI currently ```markdown
uses the explicitly pinned `golang:1.26.7` image for validation and asset builds. [COPYRIGHT](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/narratio/src/tag/vMAJOR.MINOR.PATCH/docs/oss/attribution.md)
That asynchronous work does not change the tag-push completion boundary. ```
## Choose a version and write its note Retire completed roadmaps included in the release while preserving planned or
deferred work and repairing affected links. Review the change through the
Select an unused stable version under the ordinary development workflow for unintended files, credentials, private data,
[versioning rules](policy/release.md#versioning-and-compatibility). Narratio is and documentation accuracy. Commit and push the candidate to `origin/main`
past v1.0.0; intentional CLI/configuration breaks require a major release. before tagging; record its exact commit if needed:
Create `docs/releases/vMAJOR.MINOR.PATCH.md` using the
[required note structure](policy/release.md#release-documentation).
Describe CLI, configuration, artifact, integration, and operating-contract
changes, and the required operator action. Link to current canonical
documentation for exact behavior.
## Prepare and record the candidate
Before committing, perform these manual reviews in addition to automated checks:
- Confirm compatibility/version selection and update affected canonical
documentation and maintained examples.
- Remove completed roadmap documents covering work included in this release;
retain pending work. Their history remains in Git. Do not delete historical
release notes.
- Review the diff, staged changes, and untracked files for credentials, private
data, generated output, unintended files, and unrelated changes.
- Review changed documentation links and behavior-bearing commands. Automated
link checks supplement rather than replace review.
Commit the note and candidate changes, then push that commit to `main` through
the ordinary development workflow. Record the exact candidate:
```sh ```sh
git rev-parse HEAD git rev-parse HEAD
``` ```
Do not create a tag until `origin/main` points to that same commit. If the ## Publish
candidate changes, repeat the reviews and validation.
## Validate the candidate From a clean `main` checkout at the pushed candidate, run one command:
From the repository root, run:
```sh ```sh
scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH scripts/release.sh vMAJOR.MINOR.PATCH
``` ```
The script also works when invoked by its absolute path from another directory. It fetches `origin/main` and tags; requires local `HEAD` to equal
It validates the version and matching note, module hygiene, formatting, `origin/main`; checks the committed note and unused version; runs the
whitespace, uncached tests, race tests, static checks, documentation, examples, candidate checker **once**; then repeats cheap mutable-state guards before
and three cross-build assets. It uses `GOWORK=off`, does not contact application creating an unsigned lightweight tag at the recorded commit. It verifies the
services, CI, or Gitea, and does not create tags or modify tracked source. tag target and pushes only
Fix failures on `main`, commit, push normally, and rerun validation. `refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH`. It does not
commit, push `main`, force updates, or publish all tags.
The macOS cross-build proves compilation, not native runtime behavior. The checker runs `go mod tidy -diff`, tracked Go formatting and diff whitespace
checks, one `go test -mod=readonly -race -count=1 ./...` full suite, and
`go vet -mod=readonly ./...`. Its asset builder cross-builds Linux amd64,
Linux arm64, and best-effort macOS arm64, then checks the embedded version
on a host binary. The macOS cross-build proves compilation, not native
runtime behavior. Owned Go commands run with `GOWORK=off`; tests, vet, and
builds use readonly module mode. The deterministic suite includes documentation
and maintained-example checks. The command does not contact application
services or Gitea and does not wait for CI.
## Declared deliverables and automation A successful tag push is the source-release completion point. The command
prints the tag and commit and exits without a separate network read-back.
For optional diagnosis before publication, run
`scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH`; publication will
validate the candidate again, so this is not a required preparation step.
The source release is the exact version tag at the validated commit. ## Artifact delivery
The tag-triggered `.woodpecker/release.yml` validates the tagged source,
runs `scripts/build-release-assets.sh`, and publishes these Gitea assets: Tag-triggered `.woodpecker/release.yml` independently validates the tagged
source, builds the same three targets, and publishes these Gitea downloads:
- `narratio-vMAJOR.MINOR.PATCH-linux-amd64` - `narratio-vMAJOR.MINOR.PATCH-linux-amd64`
- `narratio-vMAJOR.MINOR.PATCH-linux-arm64` - `narratio-vMAJOR.MINOR.PATCH-linux-arm64`
- `narratio-vMAJOR.MINOR.PATCH-darwin-arm64` (best effort) - `narratio-vMAJOR.MINOR.PATCH-darwin-arm64` (best effort)
- `SHA256SUMS` - `SHA256SUMS`
The builder embeds the version, disables ambient Go workspaces, and checks a The builder embeds the tag version and checks host `version` output. CI
host binary's `version` output. The release publisher uses the matching generates checksums and uses non-overwriting publication settings. It does
checked-in note and generates SHA-256 checksums. Existing files are skipped and not select versions, change the source, or move tags. No image or package
overwrite is disabled. No image or package-registry deliverable is declared. registry output is declared. Tag publication is not proof that these
CI does not choose versions, modify source candidates, or move tags. asynchronous downloads are available.
## Publish the tag ## Failure and recovery
From a clean checkout on `main` whose `HEAD` equals `origin/main`, run: If validation fails before tagging, correct and commit the candidate, push
it to `main`, and rerun the publication command. If the tag push reports
failure, its outcome may be ambiguous: keep the local tag and inspect the
exact `refs/tags/vMAJOR.MINOR.PATCH` at `origin` before declaring success
or retrying. A reachable matching remote tag at the recorded commit establishes
publication; a confirmed absent tag permits investigation of the transport
failure. If `origin` cannot be inspected, publication status is unknown.
Do not move, overwrite, or automatically retry the tag. Remove a local tag
only after confirming it was never published or used by a release channel.
```sh A transient CI or delivery failure can be retried from the same immutable
scripts/release.sh vMAJOR.MINOR.PATCH tag. Missing assets may be added later if they match the tagged source and
``` any published checksum manifest; do not overwrite existing binaries,
checksums, or release-note contents. A source/content defect, or delivery
that cannot be corrected without replacement, requires a new suitable
version and matching note. Leave the original release intact.
The command fetches and checks `origin/main`, re-runs candidate validation, Optional consumer inspection may compare the tag's remote commit with the
then fetches and checks again before creating an explicitly unsigned lightweight recorded candidate, check the Woodpecker delivery job and hosted Gitea note,
tag for the originally recorded commit. It refuses dirty, divergent, changed, download the assets into an empty directory, run `sha256sum --check
or already-tagged candidates. It verifies the local tag target and pushes only: SHA256SUMS` (or `shasum -a 256 --check SHA256SUMS` on macOS), and run
the matching host binary's `version` command. This is separate delivery
```text observation, never a source-release gate.
refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH
```
It never commits changes, pushes `main`, force-pushes, moves tags, or pushes all
tags. Successful publication of that ref completes the command; it prints the
tag and commit and returns without waiting for CI, querying Gitea, downloading
assets, or checking checksums.
## Failure handling
Before tag creation, correct the candidate on `main` and repeat validation.
If a push fails after local tag creation, the local tag is retained for inspection.
Do not retry blindly or assume the remote received nothing: a connection failure
can leave publication uncertain. Inspect the exact remote ref before taking any
local-tag cleanup action. This is recovery from a failed command, not a
post-success publication gate.
After publication, follow the
[immutability and correction rules](policy/release.md#immutability-and-correction).
Correct defects or failed asynchronous publication with a new semantically
appropriate version, matching note, and the complete procedure. Never replace
existing tagged assets in place.
## Optional asynchronous inspection
A human may later inspect the tag-triggered Woodpecker job and Gitea release.
This is optional follow-up, never part of the automated releaser's completion
criteria. Do not wait for or poll CI/Gitea from the release command.
For optional source verification, substitute the released version below and
compare the remote target to the recorded candidate hash:
```sh
git ls-remote --tags origin refs/tags/vMAJOR.MINOR.PATCH
git rev-parse 'refs/tags/vMAJOR.MINOR.PATCH^{commit}'
```
Download the three named binaries and `SHA256SUMS` from the matching Gitea release
into an empty inspection directory. In that directory, verify on Linux:
```sh
sha256sum --check SHA256SUMS
```
On macOS, use `shasum -a 256 --check SHA256SUMS`. After successful verification,
make the matching host binary executable and run its version command, for example
on Linux amd64 (replace the version in both commands):
```sh
chmod +x ./narratio-vMAJOR.MINOR.PATCH-linux-amd64
./narratio-vMAJOR.MINOR.PATCH-linux-amd64 version
```
Expected output is `narratio vMAJOR.MINOR.PATCH`. Use only the matching architecture
on other supported hosts. Checksums establish consistency with the downloaded
checksum file, not a new signature or independent authenticity guarantee.
Missing assets are an asynchronous publication problem, not grounds to move or
reuse the successfully published source tag.

View File

@@ -1,22 +1,11 @@
# Release Notes # Release Notes
This directory contains immutable historical release notes for Narratio. This directory contains immutable historical release notes for Narratio.
Future notes are created with the matching stable version and use this minimum Future notes use the matching stable version and begin with
structure: `# Narratio vMAJOR.MINOR.PATCH`. Include a short summary and relevant
compatibility or upgrade guidance; choose sections suited to the release.
```markdown End each new note with the three immutable-tag notice links specified in the
# Narratio vMAJOR.MINOR.PATCH [release procedure](../release.md).
This release ...
## Summary
## Compatibility
## Upgrade
## Changes
```
See the [release procedure](../release.md) for creating a candidate and tag. See the [release procedure](../release.md) for creating a candidate and tag.
When asynchronous publication succeeds, the corresponding Gitea release is the When asynchronous publication succeeds, the corresponding Gitea release is the

View File

@@ -260,7 +260,18 @@ recursive real full-suite execution from within checker test fixtures.
## Stage 5: Align publication behavior, policy, and operator procedure ## Stage 5: Align publication behavior, policy, and operator procedure
Status: Pending. Status: Complete (2026-09-28).
Verification: Adopted pinned release policy v1.4 with only metadata and the
selected Narratio validation rationale added to common wording. Publication
reports failed/uncertain tag pushes without asserting nonpublication; offline
bare-remote tests cover one checker call, ignored files, retained local tag,
and the existing exact-ref/branch/candidate guards. The runbook now has one
normal publication command, immutable-tag footer URLs, separate asynchronous
delivery status, and ambiguity/recovery guidance. Updated attribution and
release navigation without changing historical notes. `go test
./internal/releasecheck ./internal/doccheck`, shell syntax checks, and
`git diff --check` passed. No tag, push, or release ran.
Read pinned release v1.4, current docs/policy/release.md and docs/release.md, Read pinned release v1.4, current docs/policy/release.md and docs/release.md,
scripts/release.sh, and internal/releasecheck/publication_test.go. scripts/release.sh, and internal/releasecheck/publication_test.go.

View File

@@ -48,6 +48,7 @@ func TestReleaseCommandRejectsUnpublishedOrExistingCandidates(t *testing.T) {
fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_CHECKER_MODE=fail") fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_CHECKER_MODE=fail")
fixture.mustFail(t, "release candidate validation failed") fixture.mustFail(t, "release candidate validation failed")
fixture.requireNoTag(t) fixture.requireNoTag(t)
assertCheckerCalls(t, fixture, 1)
}) })
t.Run("local tag exists", func(t *testing.T) { t.Run("local tag exists", func(t *testing.T) {
fixture := newReleaseFixture(t) fixture := newReleaseFixture(t)
@@ -90,6 +91,8 @@ git push -q origin main
func TestReleaseCommandPushesOnlyTheRecordedLightweightTag(t *testing.T) { func TestReleaseCommandPushesOnlyTheRecordedLightweightTag(t *testing.T) {
fixture := newReleaseFixture(t) fixture := newReleaseFixture(t)
writeReleaseFixtureFile(t, filepath.Join(fixture.worktree, ".git", "info", "exclude"), "ignored.tmp\n", 0o644)
writeReleaseFixtureFile(t, filepath.Join(fixture.worktree, "ignored.tmp"), "local-only\n", 0o644)
candidate := fixture.gitOutput("rev-parse", "HEAD^{commit}") candidate := fixture.gitOutput("rev-parse", "HEAD^{commit}")
output, err := fixture.run() output, err := fixture.run()
if err != nil { if err != nil {
@@ -113,13 +116,14 @@ func TestReleaseCommandPushesOnlyTheRecordedLightweightTag(t *testing.T) {
if got := strings.Fields(fixture.gitOutput("--git-dir", fixture.origin, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")); !slicesEqual(got, []string{"refs/heads/main", "refs/tags/v1.2.3"}) { if got := strings.Fields(fixture.gitOutput("--git-dir", fixture.origin, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")); !slicesEqual(got, []string{"refs/heads/main", "refs/tags/v1.2.3"}) {
t.Fatalf("remote refs = %#v", got) t.Fatalf("remote refs = %#v", got)
} }
assertCheckerCalls(t, fixture, 1)
} }
func TestReleaseCommandKeepsUnpublishedLocalTagAfterPushFailure(t *testing.T) { func TestReleaseCommandRetainsLocalTagAfterUnconfirmedPush(t *testing.T) {
fixture := newReleaseFixture(t) fixture := newReleaseFixture(t)
writeReleaseFixtureFile(t, filepath.Join(fixture.origin, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", 0o755) writeReleaseFixtureFile(t, filepath.Join(fixture.origin, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", 0o755)
output, err := fixture.run() output, err := fixture.run()
if err == nil || !strings.Contains(output, "local tag v1.2.3 was created but not published") { if err == nil || !strings.Contains(output, "tag push for v1.2.3 failed or is unconfirmed") || strings.Contains(output, "release: published") || strings.Contains(output, "not published") {
t.Fatalf("push failure = %v\n%s", err, output) t.Fatalf("push failure = %v\n%s", err, output)
} }
if got := fixture.gitOutput("cat-file", "-t", "refs/tags/v1.2.3"); got != "commit" { if got := fixture.gitOutput("cat-file", "-t", "refs/tags/v1.2.3"); got != "commit" {
@@ -128,11 +132,24 @@ func TestReleaseCommandKeepsUnpublishedLocalTagAfterPushFailure(t *testing.T) {
if output, err := runReleaseGit(fixture.worktree, "--git-dir", fixture.origin, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil { if output, err := runReleaseGit(fixture.worktree, "--git-dir", fixture.origin, "show-ref", "--verify", "refs/tags/v1.2.3"); err == nil {
t.Fatalf("remote tag exists after failed push: %s", output) t.Fatalf("remote tag exists after failed push: %s", output)
} }
assertCheckerCalls(t, fixture, 1)
}
func assertCheckerCalls(t *testing.T, fixture *releaseFixture, want int) {
t.Helper()
data, err := os.ReadFile(fixture.checkerLog)
if err != nil {
t.Fatal(err)
}
if got := len(strings.Fields(string(data))); got != want {
t.Fatalf("candidate checker called %d times, want %d: %q", got, want, data)
}
} }
type releaseFixture struct { type releaseFixture struct {
worktree string worktree string
origin string origin string
checkerLog string
extraEnv []string extraEnv []string
} }
@@ -154,7 +171,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
writeReleaseFixtureFile(t, filepath.Join(worktree, "docs", "releases", "v1.2.3.md"), "# Narratio v1.2.3\n", 0o644) writeReleaseFixtureFile(t, filepath.Join(worktree, "docs", "releases", "v1.2.3.md"), "# Narratio v1.2.3\n", 0o644)
writeReleaseFixtureFile(t, filepath.Join(worktree, "README.md"), "fixture\n", 0o644) writeReleaseFixtureFile(t, filepath.Join(worktree, "README.md"), "fixture\n", 0o644)
fixture := &releaseFixture{worktree: worktree, origin: origin} fixture := &releaseFixture{worktree: worktree, origin: origin, checkerLog: filepath.Join(root, "checker.log")}
fixture.git("init", "-qb", "main") fixture.git("init", "-qb", "main")
fixture.git("config", "user.email", "releasecheck@example.test") fixture.git("config", "user.email", "releasecheck@example.test")
fixture.git("config", "user.name", "Release Check") fixture.git("config", "user.name", "Release Check")
@@ -176,7 +193,8 @@ func (fixture *releaseFixture) runWith(args ...string) (string, error) {
commandArgs := append([]string{filepath.Join(fixture.worktree, "scripts", "release.sh")}, args...) commandArgs := append([]string{filepath.Join(fixture.worktree, "scripts", "release.sh")}, args...)
command := exec.Command("sh", commandArgs...) command := exec.Command("sh", commandArgs...)
command.Dir = fixture.worktree command.Dir = fixture.worktree
command.Env = append(os.Environ(), fixture.extraEnv...) command.Env = append(os.Environ(), "NARRATIO_RELEASE_CHECKER_LOG="+fixture.checkerLog)
command.Env = append(command.Env, fixture.extraEnv...)
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
return string(output), err return string(output), err
} }
@@ -238,6 +256,7 @@ func writeReleaseFixtureFile(t *testing.T, path, contents string, mode os.FileMo
const releaseCandidateStub = `#!/bin/sh const releaseCandidateStub = `#!/bin/sh
set -eu set -eu
printf 'called\n' >> "$NARRATIO_RELEASE_CHECKER_LOG"
case "${NARRATIO_RELEASE_CHECKER_MODE:-success}" in case "${NARRATIO_RELEASE_CHECKER_MODE:-success}" in
success) ;; success) ;;

View File

@@ -97,7 +97,7 @@ if [ "$(git rev-parse --verify "refs/tags/$release_version^{commit}")" != "$cand
fi fi
if ! git push origin "refs/tags/$release_version:refs/tags/$release_version"; then if ! git push origin "refs/tags/$release_version:refs/tags/$release_version"; then
printf 'release: local tag %s was created but not published; inspect it before retrying\n' "$release_version" >&2 printf 'release: tag push for %s failed or is unconfirmed; local tag retained; inspect origin before retrying\n' "$release_version" >&2
exit 1 exit 1
fi fi
printf 'release: published %s at %s\n' "$release_version" "$candidate_commit" printf 'release: published %s at %s\n' "$release_version" "$candidate_commit"