From ce91f04974ea1ab5740cafac99bea04f1279cfb5 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 28 Sep 2026 01:50:26 +0000 Subject: [PATCH] Align release publication policy and runbook --- docs/development.md | 5 +- docs/oss/attribution.md | 2 +- docs/policy/oss.md | 2 +- docs/policy/release.md | 321 +++++++++++----------- docs/release.md | 217 ++++++--------- docs/releases/README.md | 21 +- docs/roadmap/implementation.md | 13 +- internal/releasecheck/publication_test.go | 33 ++- scripts/release.sh | 2 +- 9 files changed, 291 insertions(+), 325 deletions(-) diff --git a/docs/development.md b/docs/development.md index a6352fd..1f56da3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -78,8 +78,9 @@ go test ./internal/config -run '^TestExamplesLoadAndValidate$' The documentation check verifies local Markdown links and the dependency graph of the Woodpecker workflows. The configuration check loads every maintained -pipeline and session example. Tag CI reuses this validation path before its -asynchronous asset publication; the maintainer release boundary is documented +pipeline and session example. Tag CI independently revalidates the tagged source +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). Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled diff --git a/docs/oss/attribution.md b/docs/oss/attribution.md index 88a6e45..719bbd7 100644 --- a/docs/oss/attribution.md +++ b/docs/oss/attribution.md @@ -3,7 +3,7 @@ ## Standard Repository Policies 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. Copyright (C) 2026 Eric Rakestraw diff --git a/docs/policy/oss.md b/docs/policy/oss.md index 54442bf..42b9ee7 100644 --- a/docs/policy/oss.md +++ b/docs/policy/oss.md @@ -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, 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 release approval gates. diff --git a/docs/policy/release.md b/docs/policy/release.md index 29b826a..068ad6c 100644 --- a/docs/policy/release.md +++ b/docs/policy/release.md @@ -1,54 +1,60 @@ # 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 -This policy defines the minimum requirements for selecting, validating, -publishing, verifying, and correcting releases. Its goal is to make every -release intentional, reproducible from an exact source revision, documented -for consumers, and safe from retrospective replacement. +This policy defines a proportionate workflow for validating and publishing an +intended source revision under a fresh version. Releases should require little +routine intervention, avoid repeated work, and preserve published contents. -The repository-specific commands that implement these requirements belong in -`docs/release.md`. Historical release summaries belong in `docs/releases/`. -Neither document may weaken this policy, including its explicit repository -exceptions. +The repository-specific commands belong in `docs/release.md`. Historical release +summaries belong in `docs/releases/`. The procedure must agree with the adopted +policy and link to it rather than introduce additional release requirements. -## 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; -- one immutable version tag identifying that commit; -- one version-matched, checked-in release note; and -- every artifact, image, package, registry entry, or other deliverable the - repository declares for that release. +A successful push of the intended tag, with its source commit available on the +authoritative primary branch, completes the release. The source commit may be +pushed before the tag or published together with it through an atomic push. +The procedure must choose and document the supported approach. -The repository's primary branch is `main` unless an adopted policy documents -an exception. A release candidate must be committed and pushed to that branch -before its release tag is published. +Release commands must finish at successful publication. They must not wait for +or poll CI, require successful CI status, inspect downloadable artifacts, or +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 -must be traceable to the tagged source and must agree with its version. A -repository may publish source only. When it publishes additional deliverables, -its release procedure must enumerate them and define how each is built, -identified, published, and verified. +Repositories may publish source only. Additional binaries, images, packages, +checksums, or other declared deliverables may be published asynchronously from +the tagged source. The procedure must identify those deliverables and the +responsibility of their automation. Published deliverables must be traceable +to that source and identify the same version. -Release automation may validate or publish a release only when its exact -responsibility is documented. Automation must not silently change the source -candidate, move a published tag, replace a published deliverable, or repair a -release in place. +Track source-release completion and artifact delivery separately. Report delivery +failures and optional investigations under artifact-delivery status. ## 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. Prereleases require an explicit adopted policy defining +their format and use. -Unless an adopted policy explicitly supports and defines prereleases, -prerelease versions are not permitted. +The selected version must be unused locally and on the authoritative remote. +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`: @@ -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 not intentionally break those contracts within its minor line. -A repository with additional compatibility promises must define them in its -adopted policy or canonical contract documentation. A release note may explain -a compatibility change, but it must not redefine the current compatibility -contract. +Additional compatibility promises and deliberate exceptions belong in the +adopted policy or canonical contract documentation. Release notes explain +changes; they do not redefine current compatibility contracts. -## Release Documentation +## Release Documentation And Preparation -Every release made under this policy must include -`docs/releases/.md` in the tagged source tree. The filename must use -the complete version tag, and the document must begin with a title containing -the product name and that exact version. +Every new release must include `docs/releases/.md` in the tagged +source tree. Use the complete version tag as the filename and begin with a +title containing 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 -# vMAJOR.MINOR.PATCH +Keep exact current contracts in their canonical documents and update affected +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 -substantive content. +Additional gates must address a concrete risk that the existing checks do not +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 -canonical documentation for exact behavior. Every affected canonical document -must be updated in the same candidate; a release note must not substitute for -current documentation or defer required documentation changes. +Release tests must not require real credentials, mutable application services, +paid APIs, provider probes, or inference calls. The procedure may describe dependency +preparation separately from offline validation. -## 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 -that exercises the complete set of automated source-candidate gates. The -repository-specific procedure must state all additional manual gates. +Automate mechanical guards. Human review gates are strongly disfavored: add one +only through an explicit project-specific policy decision explaining the +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; -- the complete deterministic, offline default test suite; -- static analysis and linting used by the repository; -- formatting and whitespace checks; -- validation of maintained examples, configuration, schemas, generated - sources, and documentation commands where applicable; -- review of changed local documentation links; -- removal of obsolete roadmap documents under `docs/roadmap/` that describe - completed work included in the release; and -- review for unintended files, generated output, credentials, private data, - and unrelated changes. +Narratio's candidate checker adds the following checks to the one full, +race-enabled offline suite: + +- `go mod tidy -diff` detects an incomplete checked-in module graph without + changing source. +- Tracked Go formatting and diff whitespace checks catch inexpensive source + hygiene defects before publication. +- Race instrumentation adds distinct coverage for concurrent I/O, + cancellation, and shared state. +- `go vet ./...` detects static issues not established by runtime tests. +- 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 -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: +Automate the checks needed to publish the intended source under a fresh version: -- 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; -- all required approvals and automated gates succeeded for that commit; and -- no local or remote tag already uses the selected version. +- the version has the adopted format and is unused locally and on the + authoritative remote; +- the worktree and index are clean on the primary release branch; +- the source being published is the exact candidate that passed validation, + including its matching release note; +- the remote primary branch already identifies that candidate, or an atomic + source/tag push will advance it to that candidate without discarding remote + history; and +- 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 -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. +Fetch the authoritative branch and tag state as needed for these checks. +Do not force branch or tag updates to bypass a failed guard. Publish explicit +refs rather than all local tags. Do not require CI success, publication-service +availability checks, or a separate manual confirmation for each guard. -The repository-specific procedure must make these guards executable or give -copyable commands for performing them. +## Immutability And Failure Recovery -## 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, -overwrite, recreate, or delete a published tag. Do not replace an artifact, -image, package, checksum, signature, provenance statement, or release note -under an already published version. +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. -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. +Transient network, authentication, CI, or consumer-availability failures do not +by themselves require a new version. Asynchronous delivery may be retried from +the same immutable tag. Missing artifacts may be published later, provided +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 -published release intact. Correct the defect on the primary branch, choose a -new patch version or other semantically appropriate new version, write a new -matching release note, and perform the complete release procedure again. - -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. +A defect requiring changes to published source or contents requires a corrected +commit, a new semantically appropriate version, a matching release note, and +the release procedure again. Leave the original release intact. A channel's +yank, deprecation, or advisory mechanism may warn consumers away from a defective +release without replacing its contents or reusing 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: +Maintain `docs/release.md` as a short, executable maintainer runbook. It must +identify: -1. maintainer prerequisites and required access; -2. version selection and compatibility review; -3. release-note creation; -4. candidate preparation and the complete validation command; -5. candidate commit, push, and exact commit recording; -6. publication guards; -7. exact tag and deliverable publication commands; -8. authoritative remote and consumer-facing verification; and -9. failure handling and correction. +1. prerequisites, primary branch, and authoritative remote; +2. version and release-note preparation; +3. one publication command with its selected local checks and automated guards; +4. declared deliverables, required build targets, and automation responsibilities; +5. the successful-push completion point and separately reported delivery status; + and +6. failure handling, ambiguous-push recovery, and asynchronous delivery retries. -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 consumer verification path. It must link to this policy instead of -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. +Use copyable commands where practical. Keep optional diagnostics and consumer +verification clearly optional. The procedure must not turn preparation work, +ordinary change review, or post-publication investigation into additional +release gates. diff --git a/docs/release.md b/docs/release.md index afeff81..3ecd400 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,167 +1,114 @@ # Releasing Narratio This is the maintainer runbook for the [Release Policy](policy/release.md). -Its [explicit exceptions](policy/release.md#policy-exceptions) preserve the -successful tag push as the automated responsibility boundary. Binary publication -is asynchronous; later consumer-facing inspection is optional. +The source release completes when the intended tag is successfully pushed to +`origin`; binary delivery is a separate asynchronous status. -## Prerequisites and publication scope +## Prepare the candidate -Use a checkout on `main` with Git authentication permitting fetches and pushes -to `origin`, the authoritative Git remote. Install Git, a POSIX shell, standard -Unix build tools, and the Go toolchain below. Make required modules available -before offline validation. No model/provider credentials are needed. Gitea -publication credentials belong to CI's secret store, not the local command. +Use `main` and the authoritative Git remote `origin`. Install Git, a POSIX +shell, standard Unix build tools, and Go 1.26. Have the selected Go modules +available before offline validation. Git access must permit fetch and tag push. +Application-provider credentials are not required; Gitea delivery credentials +belong to CI. -Synchronous guards inspect local tags and `origin`, not every configured remote -or Gitea release entry. Do not select a version known to have been published in -any channel, even if these guards cannot see it. +Choose an unused stable `vMAJOR.MINOR.PATCH` under the +[compatibility rules](policy/release.md#versioning-and-compatibility). Narratio +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 -uses the explicitly pinned `golang:1.26.7` image for validation and asset builds. -That asynchronous work does not change the tag-push completion boundary. +```markdown +[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) +``` -## Choose a version and write its note - -Select an unused stable version under the -[versioning rules](policy/release.md#versioning-and-compatibility). Narratio is -past v1.0.0; intentional CLI/configuration breaks require a major release. - -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: +Retire completed roadmaps included in the release while preserving planned or +deferred work and repairing affected links. Review the change through the +ordinary development workflow for unintended files, credentials, private data, +and documentation accuracy. Commit and push the candidate to `origin/main` +before tagging; record its exact commit if needed: ```sh git rev-parse HEAD ``` -Do not create a tag until `origin/main` points to that same commit. If the -candidate changes, repeat the reviews and validation. +## Publish -## Validate the candidate - -From the repository root, run: +From a clean `main` checkout at the pushed candidate, run one command: ```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 validates the version and matching note, module hygiene, formatting, -whitespace, uncached tests, race tests, static checks, documentation, examples, -and three cross-build assets. It uses `GOWORK=off`, does not contact application -services, CI, or Gitea, and does not create tags or modify tracked source. -Fix failures on `main`, commit, push normally, and rerun validation. +It fetches `origin/main` and tags; requires local `HEAD` to equal +`origin/main`; checks the committed note and unused version; runs the +candidate checker **once**; then repeats cheap mutable-state guards before +creating an unsigned lightweight tag at the recorded commit. It verifies the +tag target and pushes only +`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. -The tag-triggered `.woodpecker/release.yml` validates the tagged source, -runs `scripts/build-release-assets.sh`, and publishes these Gitea assets: +## Artifact delivery + +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-arm64` - `narratio-vMAJOR.MINOR.PATCH-darwin-arm64` (best effort) - `SHA256SUMS` -The builder embeds the version, disables ambient Go workspaces, and checks a -host binary's `version` output. The release publisher uses the matching -checked-in note and generates SHA-256 checksums. Existing files are skipped and -overwrite is disabled. No image or package-registry deliverable is declared. -CI does not choose versions, modify source candidates, or move tags. +The builder embeds the tag version and checks host `version` output. CI +generates checksums and uses non-overwriting publication settings. It does +not select versions, change the source, or move tags. No image or package +registry output is declared. Tag publication is not proof that these +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 -scripts/release.sh vMAJOR.MINOR.PATCH -``` +A transient CI or delivery failure can be retried from the same immutable +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, -then fetches and checks again before creating an explicitly unsigned lightweight -tag for the originally recorded commit. It refuses dirty, divergent, changed, -or already-tagged candidates. It verifies the local tag target and pushes only: - -```text -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. +Optional consumer inspection may compare the tag's remote commit with the +recorded candidate, check the Woodpecker delivery job and hosted Gitea note, +download the assets into an empty directory, run `sha256sum --check +SHA256SUMS` (or `shasum -a 256 --check SHA256SUMS` on macOS), and run +the matching host binary's `version` command. This is separate delivery +observation, never a source-release gate. diff --git a/docs/releases/README.md b/docs/releases/README.md index f0ea822..9ff5316 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -1,22 +1,11 @@ # Release Notes This directory contains immutable historical release notes for Narratio. -Future notes are created with the matching stable version and use this minimum -structure: - -```markdown -# Narratio vMAJOR.MINOR.PATCH - -This release ... - -## Summary - -## Compatibility - -## Upgrade - -## Changes -``` +Future notes use the matching stable version and begin with +`# Narratio vMAJOR.MINOR.PATCH`. Include a short summary and relevant +compatibility or upgrade guidance; choose sections suited to the release. +End each new note with the three immutable-tag notice links specified in the +[release procedure](../release.md). See the [release procedure](../release.md) for creating a candidate and tag. When asynchronous publication succeeds, the corresponding Gitea release is the diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ee82b93..b6701a3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -260,7 +260,18 @@ recursive real full-suite execution from within checker test fixtures. ## 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, scripts/release.sh, and internal/releasecheck/publication_test.go. diff --git a/internal/releasecheck/publication_test.go b/internal/releasecheck/publication_test.go index 5a1b65b..febaa36 100644 --- a/internal/releasecheck/publication_test.go +++ b/internal/releasecheck/publication_test.go @@ -48,6 +48,7 @@ func TestReleaseCommandRejectsUnpublishedOrExistingCandidates(t *testing.T) { fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_CHECKER_MODE=fail") fixture.mustFail(t, "release candidate validation failed") fixture.requireNoTag(t) + assertCheckerCalls(t, fixture, 1) }) t.Run("local tag exists", func(t *testing.T) { fixture := newReleaseFixture(t) @@ -90,6 +91,8 @@ git push -q origin main func TestReleaseCommandPushesOnlyTheRecordedLightweightTag(t *testing.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}") output, err := fixture.run() 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"}) { t.Fatalf("remote refs = %#v", got) } + assertCheckerCalls(t, fixture, 1) } -func TestReleaseCommandKeepsUnpublishedLocalTagAfterPushFailure(t *testing.T) { +func TestReleaseCommandRetainsLocalTagAfterUnconfirmedPush(t *testing.T) { fixture := newReleaseFixture(t) writeReleaseFixtureFile(t, filepath.Join(fixture.origin, "hooks", "pre-receive"), "#!/bin/sh\nexit 1\n", 0o755) 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) } if got := fixture.gitOutput("cat-file", "-t", "refs/tags/v1.2.3"); got != "commit" { @@ -128,12 +132,25 @@ 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 { 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 { - worktree string - origin string - extraEnv []string + worktree string + origin string + checkerLog string + extraEnv []string } func newReleaseFixture(t *testing.T) *releaseFixture { @@ -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, "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("config", "user.email", "releasecheck@example.test") 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...) command := exec.Command("sh", commandArgs...) 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() return string(output), err } @@ -238,6 +256,7 @@ func writeReleaseFixtureFile(t *testing.T, path, contents string, mode os.FileMo const releaseCandidateStub = `#!/bin/sh set -eu +printf 'called\n' >> "$NARRATIO_RELEASE_CHECKER_LOG" case "${NARRATIO_RELEASE_CHECKER_MODE:-success}" in success) ;; diff --git a/scripts/release.sh b/scripts/release.sh index 927b7b2..52273ff 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -97,7 +97,7 @@ if [ "$(git rev-parse --verify "refs/tags/$release_version^{commit}")" != "$cand fi 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 fi printf 'release: published %s at %s\n' "$release_version" "$candidate_commit"