# Release Procedure Upgrade Implementation Plan ## Purpose And Status This document is the executable implementation plan for [`release-procedure.md`](release-procedure.md). The feature roadmap owns the accepted user intent, policy choices, required outcome, and responsibility boundary. This plan translates that target into bounded implementation stages suitable for one `gpt-5.6-terra` coding prompt apiece. All stages are pending and must be implemented in numeric order. Each stage must leave its owned behavior correct, tested at an appropriate stable boundary, and ready for the next stage. This plan supersedes the completed pipeline-configuration implementation plan that previously occupied this path; that feature's implemented behavior is now owned by current canonical documentation and code. ## Settled Implementation Decisions The following decisions are final for this implementation: - The synchronous release boundary is a successful push of the exact selected tag ref to `origin`. Neither `scripts/release.sh` nor any instruction that defines successful release completion may poll Woodpecker, query build status, wait for a Gitea release, download assets, or verify asynchronous publication. - The supported command surfaces are exactly: `scripts/build-release-assets.sh VERSION OUTPUT_DIR`, `scripts/check-release-candidate.sh VERSION`, and `scripts/release.sh VERSION`. - All scripts are POSIX `sh`, use `set -eu`, derive the repository root from their own checked-in location, and use narrowly named variables. They do not rely on the caller's current directory and do not repurpose `HOME`, `CODEX_HOME`, or common system-option variables. - A private source-only `scripts/release-lib.sh` owns the stable semantic version parser and shared fatal-error/reporting helpers. It has no behavior merely from being sourced. Public scripts source it relative to their own location so version syntax and error conventions do not drift. - Stable versions match exactly `v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)`. Prerelease and build suffixes are rejected. - `scripts/build-release-assets.sh` accepts exactly two arguments. Its output path must be absolute, must not be `/` or the repository root, must not be a symlink or non-directory, and must either not exist or be an empty directory. The script creates the directory when absent, writes only its six named assets and any narrowly scoped temporary smoke binary, and never recursively clears a caller-supplied path. - Official targets are CGO-disabled Linux, Darwin, and Windows builds for both `amd64` and `arm64`. Filenames are `narratio-VERSION-GOOS-GOARCH`, with `.exe` only on Windows. Builds use `-trimpath`, `-s -w`, and `-X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=VERSION`. - The asset builder verifies `narratio version` on the current host. When the host is one of the six official targets it executes that official asset; otherwise it builds a separate temporary host smoke binary, executes it, and removes only that file. Expected output is exactly `narratio VERSION`. - `scripts/check-release-candidate.sh` is read-only with respect to tracked source and Git refs. It accepts exactly one version, validates the matching note, performs repository and Go-module hygiene checks, runs every required test/static/build/documentation/example check uncached where applicable, verifies formatting and whitespace, and calls the shared asset builder in a directory created with `mktemp -d`. Every Go command it owns runs with `GOWORK=off`. - The candidate checker deliberately does not require `main`, a clean checkout, synchronized remote refs, or an unused tag. This allows it to validate a candidate before commit and to run in tag CI's detached checkout. Those mutable publication guards belong only to `scripts/release.sh`. - `scripts/release.sh` accepts exactly one version, operates only against the remote named `origin` and branch named `main`, and never accepts bypass, force, alternate-remote, skip-check, or CI-wait flags. - The publication script uses a two-phase guard. It checks cheap local and upstream conditions before the potentially long candidate checker, then fetches and rechecks the clean checkout, exact `HEAD == origin/main`, and local/remote tag absence immediately before creating the tag. This prevents a long validation run from publishing after the candidate or upstream tip changed. - Release tags remain explicitly unsigned lightweight tags. The script uses `git -c tag.gpgSign=false tag VERSION COMMIT`, verifies that the ref type is `commit` and resolves to the recorded commit, and pushes only `refs/tags/VERSION:refs/tags/VERSION`. It never pushes `main` or uses `git push --tags`. - A failed push leaves the unpublished local tag intact for inspection. The script never silently deletes or retries it. A successful push prints the version and exact commit, returns zero immediately, and performs no later network or CI action. - Release-script behavioral tests use real temporary Git repositories and a local bare `origin`, with a fixture-local stub candidate checker where a full repository validation would add no confidence. They never touch the developer's actual refs or any network service. - Tag CI uses `golang:1.25.5` and `woodpeckerci/plugin-release:0.3.1`. Its steps are named `validate-release`, `build-release-assets`, and `publish-release` in that dependency order. - The release plugin explicitly sets title `Narratio ${CI_COMMIT_TAG}`, note `docs/releases/${CI_COMMIT_TAG}.md`, flattened SHA-256 checksums, `file-exists: skip`, `overwrite: false`, and `prerelease: false`. Skipping an existing remote release preserves idempotence and immutability; repairing a failed published tag remains a new-patch-release operation. - Release documentation describes optional later inspection but never makes CI success, Gitea release creation, checksum download, or host asset execution a required step after a successful tag push. - This feature does not create the next release note, tag a version, push `main`, or publish a release. Those actions occur only after this implementation sprint is complete and a separate release candidate is selected. ## Instructions For Every Stage Before changing files in each stage: 1. Read `docs/development.md`, all documents under `docs/policy/`, this plan, and the relevant portions of [`release-procedure.md`](release-procedure.md). 2. Inspect the current scripts, Woodpecker workflows, release notes, `internal/buildinfo`, `internal/app/version.go`, and focused repository checks relevant to the stage. Prefer the codebase knowledge graph for Go discovery and use text search for shell, YAML, and Markdown. 3. Confirm the worktree state and preserve unrelated changes. 4. Re-read the responsibility boundary before editing any publication command: successful upstream tag push ends the synchronous release process. During every stage: - Keep public shell interfaces and file ownership exactly as settled above. - Use the Go standard library and ordinary POSIX tools already available in the builder images; do not add a shell framework, Go dependency, release SaaS, or package-manager tool. - Keep tests deterministic, offline, and free of real credentials and remote infrastructure. Use `t.TempDir()` and local bare Git repositories for publication behavior. - Test consequential behavior at the narrowest stable boundary. Avoid tests that merely snapshot complete script text or duplicate every shell branch. - Make failures identify the responsible script, invalid value, or failed command without printing environment dumps or secret values. - Do not document future behavior outside `docs/roadmap/` until the stage that makes that behavior usable. Update canonical current-behavior documentation in the same stage that completes the release procedure. - Format changed Go files, run `sh -n` on changed shell files, use focused tests while iterating, and finish each stage with `git diff --check` plus the stage's exit criteria. - Do not create, delete, move, or push a real Narratio tag while implementing or testing this feature. ## Stage 1 — Shared Release Library And Asset Builder **Status: Completed** ### Goal Create the single safe implementation of version validation and official release-asset construction without changing Git refs, CI, or current release documentation. ### Required Work 1. Create `scripts/release-lib.sh` as a source-only POSIX library. Give its functions a Narratio-specific prefix. At minimum it must provide: - a fatal-error helper that prefixes errors with the calling release tool; - exact stable-semver validation; and - a helper for locating the repository root from a public script's absolute or relative path without depending on the caller's working directory. Keep the library private to the scripts; it is not a supported user-facing API. 2. Create executable `scripts/build-release-assets.sh` with the exact `VERSION OUTPUT_DIR` interface and output-directory contract in the settled decisions. Reject missing/extra arguments, malformed versions, relative or dangerously broad output paths, symlink/non-directory output, and a nonempty output directory before building anything. 3. Build the six official binaries in deterministic target order. Use the current command package and build-info linker symbol; do not duplicate or change application version semantics. 4. Verify exact host version output as settled above. Ensure a failed build or version mismatch is fatal and leaves no false success message. Temporary smoke output must be inside the validated staging directory and removed narrowly. 5. Add focused tests under a new `internal/releasecheck` test package. Protect the meaningful asset-builder risks without making the default Go suite compile all six targets repeatedly: - argument and semver rejection; - refusal of unsafe, symlink, non-directory, and nonempty destinations; - creation only beneath an accepted empty destination; and - exact naming/version behavior through one controlled host build or a command shim, whichever is simpler and remains behavior-focused. The operational six-target build is exercised directly in the stage exit criteria and later by candidate validation. ### Exit Criteria - `sh -n scripts/release-lib.sh scripts/build-release-assets.sh` succeeds. - Focused `internal/releasecheck` tests pass. - Running the asset builder with a synthetic stable version and a fresh temporary output directory produces exactly six official assets and the host version check succeeds. - Invalid and unsafe output examples fail before creating release assets. - `go test ./...`, `go vet ./...`, `go build ./...`, and `git diff --check` succeed. ## Stage 2 — Central Release-Candidate Checker **Status: Completed** ### Goal Create one read-only source-candidate validator that maintainers and tag CI can run identically, eliminating drift between documented preparation and tagged source validation. ### Required Work 1. Create executable `scripts/check-release-candidate.sh` with the exact one-version interface. Source `release-lib.sh`, resolve and enter the repository root, and validate the version before using it in a path. 2. Require `docs/releases/VERSION.md` to be nonempty, begin with the exact `# Narratio VERSION` heading, and contain exact `## Summary`, `## Compatibility`, `## Upgrade`, and `## Changes` headings. Do not parse prose or impose historical-note requirements on other tags. 3. Enforce repository/module hygiene: - exact Narratio module declaration; - no tracked `go.work` or `go.work.sum`; - no `vendor` directory; - no single-line or block `replace` declaration in `go.mod`; - `GOWORK=off go mod tidy -diff` leaves module files unchanged; - all tracked `*.go` files are `gofmt` clean; and - both working-tree and cached whitespace checks pass. 4. Run the complete Narratio candidate checks with `GOWORK=off`: - `go test -count=1 ./...`; - `go test -race -count=1 ./...`; - `go vet ./...`; - `go build ./...`; - `go test -count=1 ./internal/doccheck`; and - `go test -count=1 ./internal/config -run '^TestExamplesLoadAndValidate$'`. 5. Create one temporary directory with `mktemp -d`, install a trap that removes only that verified directory, and invoke `build-release-assets.sh` with the selected version. The candidate checker owns no independent target matrix, filenames, linker flags, or version smoke logic. 6. Extend `internal/releasecheck` with lean behavioral coverage for the candidate checker's guards. Use isolated fixture repositories and command shims where needed so default tests do not recursively run the complete Narratio suite. Cover malformed version/note, wrong module identity, disallowed workspace/vendor/replace state, propagation of a failing owned validation command, and evidence that owned Go commands receive `GOWORK=off`. 7. Ensure the checker itself does not inspect the active branch, fetch a remote, require a clean status, check tag availability, mutate refs, or contact Woodpecker/Gitea. Those are deliberately outside this script's contract. ### Exit Criteria - `sh -n scripts/release-lib.sh scripts/build-release-assets.sh scripts/check-release-candidate.sh` succeeds. - Focused `internal/releasecheck` tests pass, including failure propagation. - A disposable fixture with a compliant synthetic release note exercises the candidate checker successfully; malformed fixtures fail with actionable script-owned errors. - The repository-wide test, race, vet, build, documentation, example, formatting, module-tidiness, and whitespace checks all pass independently. - No Git ref, tracked file, or external service is mutated by the checker. ## Stage 3 — Guarded Tag Publication Command **Status: Completed** ### Goal Provide one safe, non-interactive command that publishes exactly one guarded lightweight tag and stops immediately when the upstream Git push succeeds. ### Required Work 1. Create executable `scripts/release.sh` with the exact one-version interface. Source the shared library and reject invalid arguments before running Git operations. 2. Implement the first cheap guard phase: - require branch `main`; - require an empty `git status --porcelain` result; - fetch `origin main --tags`; - record `HEAD^{commit}` as the immutable candidate for this attempt; - require it to equal `origin/main^{commit}`; - require the matching note to exist in that candidate; and - reject an existing local or exact upstream tag. 3. Invoke `scripts/check-release-candidate.sh VERSION` without suppressing its output or weakening any check. 4. Implement the second guard phase immediately after validation: - refetch `origin main --tags`; - require the checkout still to be clean and on `main`; - require `HEAD` and `origin/main` still to equal the originally recorded candidate commit; and - recheck exact local and upstream tag absence. Do not silently select a newer commit or restart validation. 5. Create the explicitly unsigned lightweight tag against the recorded commit, verify its object type and resolved commit, show a concise local summary, and push only its fully qualified tag ref to `origin`. 6. On push success, print one concise completion message containing the tag and commit and exit zero immediately. There must be no commands after the push that query CI, Gitea, a release API, assets, or checksums. Do not sleep or retry waiting for asynchronous state. 7. On failure before tag creation, leave refs unchanged. On failure after local tag creation, retain the local tag and return a clear instruction that it is unpublished and requires inspection. Never automatically delete, move, force-push, or reuse a tag. 8. Add behavioral tests in `internal/releasecheck` using temporary working repositories and local bare `origin` repositories. Put a successful stub candidate checker at the fixture's expected checked-in path so tests isolate tag policy rather than rerun compilation. Cover at least: - wrong branch and dirty checkout rejection; - local `HEAD` not equal to `origin/main`; - candidate-checker failure; - existing local and upstream tag rejection; - upstream `main` changing between the two guard phases; - successful creation of a lightweight remote tag pointing to the exact candidate commit; - push failure retaining the local tag; and - absence of any pushed branch or unrelated tag. Tests must never use the real repository's `origin` or network. ### Exit Criteria - All release scripts pass `sh -n`. - Focused publication tests pass and prove the exact remote-tag outcome and failure behavior. - A success-path test demonstrates that the release command returns as soon as the local bare-remote tag push succeeds; it performs no CI/Gitea follow-up. - `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, and `git diff --check` succeed. - No real Narratio tag or remote ref is created or changed. ## Stage 4 — Asynchronous Woodpecker Release Integration **Status: Completed** ### Goal Make tag CI consume the shared candidate and asset contracts while preserving strict validation-before-publication ordering and complete independence from the synchronous tag command. ### Required Work 1. Rewrite `.woodpecker/release.yml` around three explicit steps: - `validate-release` in `golang:1.25.5`, invoking `./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"`; - `build-release-assets` in `golang:1.25.5`, depending on `validate-release`, requiring a fresh absent/empty absolute `$PWD/dist`, and invoking the shared asset builder; and - `publish-release`, depending on `build-release-assets` and using `woodpeckerci/plugin-release:0.3.1`. Remove the redundant inline target matrix, validation command list, and separate cross-build step from the release workflow. 2. Configure publication exactly as settled: the existing release-token secret, `dist/narratio-*` files, explicit title and tagged note, flattened `SHA256SUMS`, `file-exists: skip`, no overwrite, and no prerelease. 3. Preserve the tag-only event trigger. Do not add a pipeline callback, status endpoint, wait command, or any coupling from `scripts/release.sh` to this workflow. 4. Update `internal/doccheck/doccheck_test.go` so the dependency assertion names `validate-release` and still proves transitively that publication cannot run after failed validation. Extend its minimal YAML model only as needed to protect consequential release invariants: - the validator calls the shared checker; - asset publication depends on validated shared construction; - the release plugin image is pinned; - title and note use the selected tag; and - overwrite/prerelease remain disabled. Avoid snapshotting the whole workflow or duplicating every plugin setting. 5. Confirm `.woodpecker/verify.yml` and `.woodpecker/shuffle.yml` remain normal push/PR and scheduled validation. Do not make either wait for or invoke a release operation. ### Exit Criteria - The Woodpecker YAML parses and the focused `internal/doccheck` workflow dependency/invariant tests pass. - The release workflow has one target matrix owner and one candidate-check owner, both under `scripts/`. - `publish-release` has a dependency path to `validate-release`; no failure in validation or asset building can publish. - Static inspection confirms `scripts/release.sh` has no reference to Woodpecker, Gitea release status, or CI polling. - `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`, documentation/example checks, and `git diff --check` succeed. ## Stage 5 — Canonical Release Documentation And Final Audit **Status: Completed** ### Goal Document the now-implemented maintainer workflow in one canonical location, make it discoverable, and perform a complete offline acceptance audit without creating or publishing a real release. ### Required Work 1. Create `docs/release.md` as the canonical current-behavior procedure. It must include: - stable SemVer selection for a post-`v1.0.0` project; - the exact future release-note structure; - candidate preparation and ordinary `main` push expectations; - direct use of `scripts/check-release-candidate.sh`; - direct use and complete safety contract of `scripts/release.sh`; - lightweight-tag immutability and correction through a new patch version; - the explicit statement that successful upstream tag push completes the release command; and - a clearly optional asynchronous inspection section, separated from the release steps and stating that a human may inspect CI/Gitea later but an automated releaser must not wait for it. Do not embed a second implementation of script guards as a long shell block; the scripts own volatile mechanics and the document owns the maintainer workflow and policy. 2. Update `docs/development.md` with a task-guide row pointing release work to `docs/release.md`. Keep its validation summary concise and link to the release procedure/checker rather than duplicating the candidate command list. 3. Update `docs/policy/documentation.md` to assign canonical ownership for the maintainer release procedure and version-matched historical notes. Preserve the rule that release notes link to, rather than replace, current contract documentation. 4. Update `docs/releases/README.md` to explain forward-looking note structure, retain its existing `v1.5.0` entry, and identify the Gitea release collection as the binary/checksum source when asynchronous publication succeeds. Do not backfill or rewrite historical notes. 5. Review other current documentation for claims that release completion waits on CI. Remove or link any conflicting duplication. Do not add release mechanics to operations, CLI, architecture, or integration documents unless they already own a directly affected current contract. 6. Mark `docs/roadmap/release-procedure.md` implemented only after scripts, tests, CI, and canonical documentation match its target. Keep implementation status in roadmap documents; current-behavior claims belong in `docs/release.md` and the policy/developer routing documents. 7. Perform a final behavior and safety audit: - inspect all scripts for unsafe path deletion, unresolved variables, accidental secret output, broad ref pushes, force options, and commands after the successful tag push; - run all release-script behavioral tests against temporary local remotes; - run the asset builder directly with a synthetic stable version and a fresh temporary output directory, then verify the six filenames and host version output; - validate candidate-checker success in its isolated fixture and every important failure class without creating a real note or tag; - confirm the release workflow reads the note from the tagged tree and cannot publish before validation; and - confirm no test or documentation instruction requires live CI, Gitea, credentials, or paid/external adapters. ### Exit Criteria - All local Markdown links and Woodpecker dependency checks pass. - `sh -n scripts/release-lib.sh scripts/build-release-assets.sh scripts/check-release-candidate.sh scripts/release.sh` succeeds. - Release-script tests and the direct six-target asset build pass without touching real refs or external services. - The full repository validation set from `docs/development.md` passes: - `go test ./...`; - `go test -race ./...`; - `go vet ./...`; - `go build ./...`; - `go test ./internal/doccheck`; and - `go test ./internal/config -run '^TestExamplesLoadAndValidate$'`. - `go mod tidy -diff`, tracked-file `gofmt` inspection, `git diff --check`, and `git diff --cached --check` succeed. - The repository contains no synthetic release note, local test tag, build asset, credential, temporary repository, or other generated test residue. - No real release is tagged or pushed during this implementation plan. ## Open Questions None. The feature roadmap and settled decisions above are sufficient to implement the release procedure without additional product or policy choices.