diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index a1ab7649..2feee9f7 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,3 +1,4 @@ +# Advisory validation after source publication, never a release-completion gate. when: - event: tag @@ -5,29 +6,4 @@ steps: - name: validate-release image: golang:1.25.5 commands: - - | - set -eu - - version="$CI_COMMIT_TAG" - release_note="docs/releases/$version.md" - - if ! printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null; then - printf '%s\n' "invalid release tag: $version" >&2 - exit 1 - fi - if [ ! -s "$release_note" ]; then - printf '%s\n' "missing release note: $release_note" >&2 - exit 1 - fi - if ! grep -F -x "# Notarius $version" "$release_note" >/dev/null; then - printf '%s\n' "release note heading does not match $version" >&2 - exit 1 - fi - for heading in '## Summary' '## Compatibility' '## Upgrade' '## Changes'; do - if ! grep -F -x "$heading" "$release_note" >/dev/null; then - printf '%s\n' "release note is missing heading: $heading" >&2 - exit 1 - fi - done - - ./scripts/check-release-source.sh "$version" + - ./scripts/check-release-source.sh "$CI_COMMIT_TAG" diff --git a/docs/development.md b/docs/development.md index 08ef5862..2d024443 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,7 +28,7 @@ implementing changes. It owns implementation discipline and local commit rules. | LLM clients, prompts, schemas, profiles, or scheduling | [LLM Runtime](internal/llm.md) | It documents the transport boundary and PromptKit integration. | | Output, cache, resume, or debug artifacts | [Run State Internals](internal/state.md), [Operations](operations.md), and [Configuration](config.md) | These separate implementation details, operator behavior, and configuration contracts. | | External input formats, artifact schemas, or durable output files | [Integration Contracts](integrations/) | Integration documents define external and durable data contracts. | -| Release preparation, tagging, publication, or verification | [Source Releases](release.md) and [Documentation Policy](policy/documentation.md) | The release procedure owns maintainer guards and immutable-tag recovery; the policy assigns release-note ownership. | +| Release preparation, tagging, publication, or optional diagnostics | [Release Policy](policy/release.md) and [Source Releases](release.md) | Policy owns release requirements and completion; the runbook supplies the publication command and recovery procedure. | | Proposed or unimplemented behavior | [Roadmap](roadmap/) | Future work belongs only in roadmap documentation until implemented. | For an existing subsystem, also inspect its focused tests and the package-local diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index c098c5b1..3f7d5454 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -357,8 +357,9 @@ best-effort development and compilation environment, while Windows is unsupported. Notarius distributes source releases only: an immutable source tag and its checked-in release note identify a release. The project does not publish executable binaries, archives, installers, container images, -checksums, signatures, or package-manager entries. Maintainer release commands -and tag guards belong to [Source Releases](../release.md). +checksums, signatures, or package-manager entries. [Release Policy](release.md) +owns publication requirements and completion; maintainer commands and guards +belong to [Source Releases](../release.md). ## Architectural Non-Goals diff --git a/docs/policy/documentation.md b/docs/policy/documentation.md index db255850..11ac9ca1 100644 --- a/docs/policy/documentation.md +++ b/docs/policy/documentation.md @@ -91,7 +91,8 @@ secret values. | CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, and exit codes. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, module implementation details. | | Configuration contract | `docs/config.md` | Discovery and precedence, file schema, fields, defaults, environment overrides, validation rules, and user-selectable module or validator keys. | Complete example files, CLI syntax, runtime state lifecycle, module implementation details. | | Operations | `docs/operations.md` | Runtime workflows, physical filesystem and state layout, output, cache, and debug handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical output schemas, implementation mechanics. | -| Source release procedure | `docs/release.md` | Maintainer release selection, candidate validation, tagging, publication guards, verification, and immutable-tag recovery. | Product installation summary, CLI version semantics, historical release summaries, CI implementation detail. | +| Release policy | `docs/policy/release.md` | Versioning, compatibility, integrity, immutability, proportionate gate selection, publication controls, and release completion. | Concrete commands, product installation, historical notes, and CI implementation detail. | +| Repository-specific release procedure | `docs/release.md` | Prerequisites, candidate preparation, publication command, automated guards, optional diagnostics, and failure recovery. | General release requirements, product installation guidance, historical summaries, and current compatibility contracts. | | Release-note history | `docs/releases/` | One checked-in historical summary for each source release made under the procedure. The note at the immutable tag is that release's record. | Current commands, behavior, contracts, and compatibility definitions. | | Public HTTP contract, if introduced | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. | | Consumer guidance | `docs/consumers/` | Task-oriented subprocess use, minimal client examples, consumer responsibilities, and guidance for any future public package or API. | Wire semantics, external protocol contracts, internal implementation detail. | @@ -125,11 +126,13 @@ other contracts. ### Releases -`docs/release.md` owns the source-release procedure. Release notes are +The [release policy](release.md) owns requirements and invariants; the +[repository procedure](../release.md) owns the commands that satisfy them. +Release notes are historical summaries, not current-state contract owners: the checked-in note at an immutable tag records that release, while current canonical documentation -must change with the behavior it describes. Do not use a release note to defer -or replace current documentation updates. +must change with the behavior it describes. Do not use a release procedure or +note to weaken policy, redefine current contracts, or defer documentation updates. ### Contracts And Implementation diff --git a/docs/policy/release.md b/docs/policy/release.md new file mode 100644 index 00000000..aa701365 --- /dev/null +++ b/docs/policy/release.md @@ -0,0 +1,204 @@ +# Release Policy + +**Base Policy Version:** 1.1 + +**Last Updated**: 2026-09-25 + +**Upstream Source:** `policy` commit `45591a79004c6811950f14dca554f6ff69b7f58e`. + +Notarius adaptations: source-only delivery, lightweight tags, branch-before-tag +publication, no prereleases, and the justified native command check below. + +## Purpose + +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 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 And Completion + +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` and the authoritative remote is `origin`. +Tags are lightweight. Publish the candidate to `main` before pushing its tag. + +A successful push of the intended tag, with its source commit available on the +authoritative primary branch, completes the release. Notarius uses +branch-before-tag publication; it does not require an atomic push. + +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. + +Notarius publishes source only: no binaries, archives, images, packages, +checksums, signatures, or Gitea release objects. Tag CI may provide advisory +validation but publishes no deliverables and is not a completion gate. + +## Versioning And Compatibility + +Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with +no leading zeroes. Notarius does not support prereleases. + +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`: + +- a major release may make intentional incompatible changes; +- a minor release may add backward-compatible behavior; and +- a patch release may make backward-compatible fixes. + +Before `v1.0.0`, a minor release may change a documented public, operational, +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. + +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 And Preparation + +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. + +Include a short summary and any relevant compatibility impact or upgrade +instructions. Keep the note proportionate to the change. Fixed sections and +explicit no-action boilerplate are not required. Existing historical notes +need not be rewritten to adopt this format. + +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. A release note must not substitute for current documentation. +Do not create retrospective notes for the pre-procedure v0.1.0, v0.2.0, or +v0.3.0 tags. + +Version selection, release-note content, and retirement of completed roadmaps +are preparation responsibilities, not separate human approval gates. Preserve +planned and deferred work and repair affected links when retiring roadmaps. +Ordinary change review covers unintended files, credentials, and private data; +do not repeat it as a release-specific sign-off. Ignored local files are not +prohibited merely because they exist: they are not included in the tagged source. + +## Candidate Validation + +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. + +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. + +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 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. + +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. + +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. + +### Notarius Gate Selection + +Run the default offline Go suite once and build the native `cmd/notarius` with +release version injection, checking its `--version` output. The command check +protects the actual executable entry point and linker-supplied version, which +package tests alone do not verify. Build products are temporary, not published. +Use `GOWORK=off` so a sibling checkout cannot substitute for declared dependencies. + +Race tests, standalone vet, formatting/whitespace checks, module tidy audits, +standalone example validation, and Linux/Darwin cross-builds remain useful +change-specific development checks, not mandatory release gates. Default tests +already cover maintained examples. Source-only publication does not require a +binary distribution matrix. No new platform or hardware prerequisites are added. + +## Publication Guards + +Automate the checks needed to publish the intended source under a fresh 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 branch push advances authoritative `main` to the candidate without + discarding remote history, before the tag is published; and +- the tag identifies the validated candidate and only the intended refs are + published. + +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. + +## Immutability And Failure Recovery + +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. + +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. + +Transient network, authentication, CI, or consumer-availability failures do not +by themselves require a new version. Advisory CI or optional installation checks +may be retried against the same immutable tag. Notarius has no asynchronous +artifact delivery to recover. + +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 + +Maintain `docs/release.md` as a short, executable maintainer runbook. It must +identify: + +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 and ambiguous-push recovery. + +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 51d7ff39..da3488d6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,164 +1,84 @@ # Source Releases -This procedure is for maintainers publishing Notarius source releases. A -release is an immutable lightweight `vMAJOR.MINOR.PATCH` tag on `main` together -with its checked-in `docs/releases/.md` note. Tag CI validates that source -candidate after publication; it does not publish or repair a release. +This is the executable runbook for [Release Policy](policy/release.md). +Notarius publishes an immutable lightweight tag and its checked-in release note, +not binaries or Gitea release objects. Linux is supported; macOS is best-effort +for development, and Windows is unsupported. `main` on `origin` is authoritative. -Notarius publishes no binaries, archives, checksums, signatures, containers, -package-manager entries, or Gitea release objects. Windows is not supported. -Do not create retrospective notes for the pre-procedure `v0.1.0`, `v0.2.0`, or -`v0.3.0` tags. +## Prepare -## Select And Describe The Release - -Choose an unused stable semantic version in the form `vMAJOR.MINOR.PATCH`. -Prereleases are not supported. Before `v1.0.0`, a minor release may change a -documented CLI, configuration, durable artifact, integration, or operating -contract when its note explains the impact and required operator action. A -patch release must not intentionally break those documented contracts within -its minor line. - -Create the version-matched note as part of the candidate. Every new note uses -this structure, with concise, truthful content in each section: - -```markdown -# Notarius vMAJOR.MINOR.PATCH - -This release ... - -## Summary - -## Compatibility - -## Upgrade - -## Changes -``` - -The note is a historical summary. Link to current canonical documentation for -exact behavior, and update that documentation in the candidate rather than -using the note as a substitute. - -## Prepare The Candidate - -Set the selected release version and disable Go workspace use for every -candidate command: +Use Git, a POSIX shell, and Go 1.25.5 or newer. Configure ordinary Git credentials +for `origin` and private-module access without putting secrets in the repository. +Prepare dependencies before offline validation where necessary: ```sh -RELEASE_VERSION=vMAJOR.MINOR.PATCH -export RELEASE_VERSION GOWORK=off +GOWORK=off GOPRIVATE=gitea.maximumdirect.net go mod download ``` -Run the shared source-candidate checks from the repository. They cover module -hygiene, tests, race tests, vet, builds, formatting, whitespace, maintained -configuration validation, and the Linux and Darwin command-build matrix: +Choose an unused stable version under the policy's compatibility rules. Add +`docs/releases/.md`, starting with `# Notarius `, with a concise +summary and relevant compatibility/upgrade information. No fixed section list is +required. Update current documentation with behavior changes; retire only +completed roadmaps and preserve planned/deferred work. Commit the candidate on +`main` after ordinary change review. There is no separate release sign-off. + +## Publish + +Run one command from the repository with the actual selected version: ```sh -./scripts/check-release-source.sh "$RELEASE_VERSION" +./scripts/release.sh vMAJOR.MINOR.PATCH ``` -Before committing, manually follow every changed local Markdown link and -review the candidate for unintended files, generated output, credentials, or -other unrelated changes. Commit the release note and all affected current -documentation, then run the shared checker against that exact candidate. Push -the candidate commit to `main` only after it succeeds. Record the exact commit -only after that push: +The command checks version availability on `origin`, branch and worktree state, +and the matching note. It runs the complete offline default test suite once and +a native CLI build/version check with `GOWORK=off`. The +[gate rationale](policy/release.md#notarius-gate-selection) explains the selected +checks. It then rechecks candidate and publication state, pushes the exact +candidate to `main` without force, creates a lightweight tag, and pushes only +that tag. It neither rewrites history nor publishes other local tags. + +A successful tag push completes the source release. The command does not wait +for CI or verify consumer installation/module availability. Tag CI is advisory +validation only; there are no asynchronous deliverables to await. + +## Failure And Recovery + +Before publication, correct failed checks, commit any changes, and rerun the +command. If the candidate changes during validation, validation must run again +on the new candidate. A failed branch push publishes no tag. + +On a failed or ambiguous push, the command queries the affected remote ref. If +a tag push reports failure but the intended lightweight tag is confirmed on the +remote, publication succeeded. Otherwise it stops and reports failure or unknown +status, leaving any local tag intact for inspection. Do not blindly rerun, force +push, or delete a possibly published tag. + +Inspect authoritative state before retrying: ```sh -RELEASE_COMMIT=$(git rev-parse 'HEAD^{commit}') -export RELEASE_COMMIT +git ls-remote origin refs/heads/main refs/tags/vMAJOR.MINOR.PATCH +git rev-parse vMAJOR.MINOR.PATCH ``` -For private-module installation, configure standard `GOPRIVATE` matching this -module and ordinary Git authentication for the hosting service before running -the verification below. The exact authentication mechanism belongs to the -maintainer environment; never record credentials or environment dumps in a -release note, command history, or repository file. +If the intended tag exists remotely, leave it immutable. If it is absent and +you have confirmed that the local tag was never published or used by a release +channel, remove only that unpublished local tag, then rerun the command. A +failed remote query is not proof of absence. Network/authentication failures +alone do not require a new version. A defect requiring changes to published +source requires a new semantically appropriate version and matching note. -## Guard And Publish The Tag +## Optional Diagnostics -Fetch current remote references, then run this guard without editing the -candidate. It requires `main`, a clean worktree and index, disabled workspace -use, a stable release version, the recorded and pushed commit, a matching note, -and unused local and remote tags: +`./scripts/check-release-source.sh ` runs validation without publication. +It is useful for diagnosis, but is not a required preliminary step that must +repeat the publication command's checks. -```sh -git fetch origin main --tags +Race tests, vet, module hygiene, formatting, example checks, and cross-builds may +be run when relevant to a change; they are not additional release gates. The +development guide and testing policy govern change-specific verification. -if ! printf '%s\n' "$RELEASE_VERSION" | - grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null -then - printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2 - exit 1 -fi -test "$GOWORK" = off -test "$(git branch --show-current)" = main -test -z "$(git status --porcelain)" -test "$RELEASE_COMMIT" = "$(git rev-parse 'HEAD^{commit}')" -test "$RELEASE_COMMIT" = "$(git rev-parse 'origin/main^{commit}')" -test -s "docs/releases/$RELEASE_VERSION.md" -grep -F -x "# Notarius $RELEASE_VERSION" "docs/releases/$RELEASE_VERSION.md" -for heading in '## Summary' '## Compatibility' '## Upgrade' '## Changes'; do - grep -F -x "$heading" "docs/releases/$RELEASE_VERSION.md" -done -if git rev-parse -q --verify "refs/tags/$RELEASE_VERSION" >/dev/null; then - printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2 - exit 1 -fi -if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_VERSION" >/dev/null 2>&1; then - printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2 - exit 1 -fi -``` - -Create an explicitly lightweight tag against the guarded commit, verify its -target, and push only that tag ref: - -```sh -git -c tag.gpgSign=false tag "$RELEASE_VERSION" "$RELEASE_COMMIT" -test "$(git cat-file -t "$RELEASE_VERSION")" = commit -test "$(git rev-parse "$RELEASE_VERSION^{commit}")" = "$RELEASE_COMMIT" -git push origin "refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION" -``` - -Never use `git push --tags`, move a published tag, or delete a published tag. - -## Verify The Published Release - -Confirm that the remote tag still points at the guarded commit and that the -note is available from the tagged tree: - -```sh -REMOTE_TAG_COMMIT=$(git ls-remote origin "refs/tags/$RELEASE_VERSION" | awk '{print $1}') -test "$REMOTE_TAG_COMMIT" = "$RELEASE_COMMIT" -git show "$RELEASE_VERSION:docs/releases/$RELEASE_VERSION.md" >/dev/null -``` - -Verify a fresh source installation and its diagnostic version. The temporary -directory confines the installed command to this check: - -```sh -release_verification_dir=$(mktemp -d) -trap 'rm -rf "$release_verification_dir"' 0 HUP INT TERM -mkdir -p "$release_verification_dir/bin" -GOWORK=off GOBIN="$release_verification_dir/bin" go install \ - "gitea.maximumdirect.net/eric/notarius/cmd/notarius@$RELEASE_VERSION" -test "$("$release_verification_dir/bin/notarius" --version)" = "notarius $RELEASE_VERSION" -``` - -An exact fresh checkout and `GOWORK=off go build ./cmd/notarius` is an -equivalent source verification when local installation policy requires it. -`notarius --version` is diagnostic only; downstream compatibility remains -defined by the published receipt and artifact contracts. - -## Failure And Correction Policy - -If candidate validation fails before publication, fix the candidate on `main`, -rerun the shared checker, and repeat the guards. An unpublished local tag may -be deleted after inspection. - -If the remote tag or tag CI reveals a defect, leave the published tag intact. -Fix the defect on `main`, choose a new patch version, write a new matching -note, and repeat this procedure. Do not weaken tag immutability or add release -assets as a workaround. +A fresh source install or exact-tag checkout/build may be used to investigate +consumer availability after release. Such checks are optional and do not delay +or reverse release completion. `notarius --version` is diagnostic; subprocess +compatibility is defined by the receipt and artifact contracts. diff --git a/scripts/check-release-source.sh b/scripts/check-release-source.sh index 69df48ec..7f3c15d0 100755 --- a/scripts/check-release-source.sh +++ b/scripts/check-release-source.sh @@ -2,85 +2,23 @@ set -eu -fail() { - printf '%s\n' "check-release-source: $*" >&2 - exit 1 -} - -if [ "$#" -ne 1 ]; then - fail "usage: $0 vMAJOR.MINOR.PATCH" -fi - -release_version=$1 -if ! printf '%s\n' "$release_version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null; then - fail "version must be a stable semantic version tag" -fi +fail() { printf '%s\n' "check-release-source: $*" >&2; exit 1; } +[ "$#" -eq 1 ] || fail "usage: $0 vMAJOR.MINOR.PATCH" +version=$1 +printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null || fail "invalid stable release version" script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) -repo_root=$(CDPATH= cd "$script_dir/.." && pwd) -cd "$repo_root" +cd "$script_dir/.." +note="docs/releases/$version.md" +[ -s "$note" ] || fail "missing release note: $note" +[ "$(head -n 1 "$note")" = "# Notarius $version" ] || fail "release note title does not match version" -temporary_dir=$(mktemp -d "${TMPDIR:-/tmp}/notarius-release-source.XXXXXX") || fail "create temporary directory" -trap 'rm -rf "$temporary_dir"' 0 HUP INT TERM +export GOWORK=off +go test -count=1 ./... -if ! grep -E '^module[[:space:]]+gitea\.maximumdirect\.net/eric/notarius([[:space:]]|$)' go.mod >/dev/null; then - fail "go.mod does not declare the expected module path" -fi - -if git ls-files --error-unmatch go.work >/dev/null 2>&1 || git ls-files --error-unmatch go.work.sum >/dev/null 2>&1; then - fail "tracked Go workspace files are not allowed" -fi -if [ -d vendor ]; then - fail "vendor directory is not allowed" -fi -if grep -E '^[[:space:]]*replace([[:space:]]|$)' go.mod >/dev/null; then - fail "go.mod replace directives are not allowed" -fi - -GOWORK=off go test -count=1 ./... -GOWORK=off go test -race -count=1 ./... -GOWORK=off go vet ./... -GOWORK=off go build ./... -GOWORK=off go mod tidy -diff - -unformatted=$(git ls-files '*.go' | while IFS= read -r go_file; do - fmt_output=$(gofmt -l "$go_file") - if [ -n "$fmt_output" ]; then - printf '%s\n' "$fmt_output" - fi -done) -if [ -n "$unformatted" ]; then - printf '%s\n' "$unformatted" >&2 - fail "tracked Go files are not gofmt formatted" -fi - -git diff --check -git diff --cached --check - -GOWORK=off go run ./cmd/notarius config validate --config examples/dnd-minimal.config.yml --pipeline dnd-session -GOWORK=off OPENROUTER_API_KEY=validation-placeholder go run ./cmd/notarius config validate --config examples/dnd-complete.config.yml --pipeline dnd-session - -host_os=$(GOWORK=off go env GOOS) -host_arch=$(GOWORK=off go env GOARCH) -version_symbol='gitea.maximumdirect.net/eric/notarius/internal/buildinfo.Override' - -build_target() { - target_os=$1 - target_arch=$2 - target_path="$temporary_dir/notarius-$target_os-$target_arch" - CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" GOWORK=off go build -trimpath \ - -ldflags "-X $version_symbol=$release_version" \ - -o "$target_path" ./cmd/notarius - if [ "$host_os" = "$target_os" ] && [ "$host_arch" = "$target_arch" ]; then - "$target_path" --version > "$temporary_dir/version-output" - printf 'notarius %s\n' "$release_version" > "$temporary_dir/expected-version-output" - if ! cmp -s "$temporary_dir/expected-version-output" "$temporary_dir/version-output"; then - fail "version output is incorrect for $target_os/$target_arch" - fi - fi -} - -build_target linux amd64 -build_target linux arm64 -build_target darwin amd64 -build_target darwin arm64 +# Exercise the actual command and its linker-supplied diagnostic version. +verification_dir=$(mktemp -d "${TMPDIR:-/tmp}/notarius-release-check.XXXXXX") +trap 'rm -rf "$verification_dir"' 0 HUP INT TERM +go build -trimpath -ldflags "-X gitea.maximumdirect.net/eric/notarius/internal/buildinfo.Override=$version" \ + -o "$verification_dir/notarius" ./cmd/notarius +[ "$("$verification_dir/notarius" --version)" = "notarius $version" ] || fail "incorrect diagnostic version" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..3e2c6655 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +set -eu + +fail() { printf '%s\n' "release: $*" >&2; exit 1; } + +[ "$#" -eq 1 ] || fail "usage: $0 vMAJOR.MINOR.PATCH" +version=$1 +printf '%s\n' "$version" | grep -E -x 'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' >/dev/null || fail "invalid stable release version" +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +cd "$script_dir/.." +export GOWORK=off +candidate=$(git rev-parse 'HEAD^{commit}') + +check_candidate() { + [ "$(git branch --show-current)" = main ] || fail "release branch must be main" + [ -z "$(git status --porcelain)" ] || fail "worktree and index must be clean" + [ "$(git rev-parse 'HEAD^{commit}')" = "$candidate" ] || fail "candidate changed during validation" + git cat-file -e "$candidate:docs/releases/$version.md" || fail "release note must be committed" +} + +check_publication() { + # Query failure is not proof that the tag is unused. + remote_tag=$(git ls-remote --tags origin "refs/tags/$version" "refs/tags/$version^{}") || fail "cannot determine remote tag availability" + [ -z "$remote_tag" ] || fail "remote tag already exists: $version" + if git show-ref --verify --quiet "refs/tags/$version"; then + fail "local tag already exists: $version" + fi + git fetch --no-tags origin refs/heads/main || fail "cannot fetch authoritative main" + remote_main=$(git rev-parse 'FETCH_HEAD^{commit}') + git merge-base --is-ancestor "$remote_main" "$candidate" || fail "candidate does not contain authoritative main" +} + +check_candidate +check_publication +"$script_dir/check-release-source.sh" "$version" +check_candidate +check_publication + +if ! git -c push.followTags=false push origin "$candidate:refs/heads/main"; then + # Do not publish a tag after a failed branch push, even if the branch arrived. + git ls-remote origin refs/heads/main >&2 || fail "branch publication status unknown; inspect origin before retrying" + fail "branch push failed; inspect the reported remote state before retrying" +fi + +git -c tag.gpgSign=false tag "$version" "$candidate" +[ "$(git cat-file -t "$version")" = commit ] || fail "tag is not lightweight" +[ "$(git rev-parse "$version^{commit}")" = "$candidate" ] || fail "tag target changed" +if ! git -c push.followTags=false push origin "refs/tags/$version:refs/tags/$version"; then + # A connection can fail after the server accepted the tag. Preserve it locally + # until authoritative state proves whether publication succeeded. + remote_tag=$(git ls-remote --tags origin "refs/tags/$version" "refs/tags/$version^{}") || fail "tag publication status unknown; preserve local tag and inspect origin" + expected=$(printf '%s\t%s' "$candidate" "refs/tags/$version") + [ "$remote_tag" = "$expected" ] || fail "tag push failed; preserve local tag and inspect origin before retrying" +fi +printf 'Released %s at %s\n' "$version" "$candidate" diff --git a/scripts/release_test.go b/scripts/release_test.go new file mode 100644 index 00000000..ac9a33f3 --- /dev/null +++ b/scripts/release_test.go @@ -0,0 +1,243 @@ +package scripts + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Keep publication tests offline: real Git repositories exercise ref safety, +// while a fake Go command represents the expensive candidate validation boundary. +type releaseFixture struct { + t *testing.T + root string + remote string + bin string + log string + initial string +} + +func writeFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func command(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) +} + +func newReleaseFixture(t *testing.T) *releaseFixture { + t.Helper() + dir := t.TempDir() + f := &releaseFixture{t: t, root: filepath.Join(dir, "work"), remote: filepath.Join(dir, "origin.git"), bin: filepath.Join(dir, "bin"), log: filepath.Join(dir, "validation.log")} + command(t, dir, "git", "init", "--bare", f.remote) + command(t, dir, "git", "init", "-b", "main", f.root) + f.git("config", "user.name", "Release Test") + f.git("config", "user.email", "release@example.invalid") + f.git("config", "commit.gpgSign", "false") + f.git("config", "tag.gpgSign", "false") + for _, name := range []string{"release.sh", "check-release-source.sh"} { + content, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(f.root, "scripts", name), string(content), 0755) + } + writeFile(t, filepath.Join(f.root, ".gitignore"), "vendor/\n", 0644) + f.git("add", ".") + f.git("commit", "-m", "Initial source") + f.git("remote", "add", "origin", f.remote) + f.git("push", "origin", "main") + f.initial = f.git("rev-parse", "HEAD") + writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Notarius v0.8.0\n\nA concise note without fixed sections.\n", 0644) + f.git("add", ".") + f.git("commit", "-m", "Prepare release") + writeFile(t, filepath.Join(f.bin, "go"), `#!/bin/sh +set -eu +[ "$GOWORK" = off ] +printf '%s\n' "$1" >> "$RELEASE_TEST_LOG" +case "$1" in +test) + [ "${RELEASE_TEST_FAIL:-}" != test ] || exit 1 + if [ "${RELEASE_TEST_CHANGE:-}" = yes ]; then + printf 'changed\n' >> docs/releases/v0.8.0.md + fi + ;; +build) + while [ "$1" != -o ]; do shift; done + shift + printf '#!/bin/sh\nprintf "notarius v0.8.0\\n"\n' > "$1" + chmod +x "$1" + ;; +*) exit 1 ;; +esac +`, 0755) + return f +} + +func (f *releaseFixture) git(args ...string) string { + f.t.Helper() + return command(f.t, f.root, append([]string{"git"}, args...)...) +} + +func (f *releaseFixture) run(version string, env ...string) (string, error) { + f.t.Helper() + cmd := exec.Command("sh", "scripts/release.sh", version) + cmd.Dir = f.root + cmd.Env = append(os.Environ(), "PATH="+f.bin+string(os.PathListSeparator)+os.Getenv("PATH"), "RELEASE_TEST_LOG="+f.log) + cmd.Env = append(cmd.Env, env...) + output, err := cmd.CombinedOutput() + return string(output), err +} + +func TestReleasePublishesExactCandidateOnce(t *testing.T) { + f := newReleaseFixture(t) + // Neither ignored local files nor unrelated local tags are release inputs. + writeFile(t, filepath.Join(f.root, "vendor/local.txt"), "ignored", 0644) + f.git("tag", "unrelated") + f.git("tag", "-a", "unrelated-annotated", "-m", "Do not publish") + f.git("config", "push.followTags", "true") + candidate := f.git("rev-parse", "HEAD") + output, err := f.run("v0.8.0") + if err != nil { + t.Fatalf("release: %v\n%s", err, output) + } + for _, ref := range []string{"refs/heads/main", "refs/tags/v0.8.0"} { + if got := command(t, f.remote, "git", "rev-parse", ref); got != candidate { + t.Fatalf("%s = %s, want %s", ref, got, candidate) + } + } + if got := command(t, f.remote, "git", "cat-file", "-t", "v0.8.0"); got != "commit" { + t.Fatalf("tag type = %s", got) + } + if got := f.git("ls-remote", "--tags", "origin", "refs/tags/unrelated*"); got != "" { + t.Fatal("published unrelated tag") + } + log, err := os.ReadFile(f.log) + if err != nil || string(log) != "test\nbuild\n" { + t.Fatalf("validation was not performed once: %q, %v", log, err) + } +} + +func TestReleaseGuardsPreventPublication(t *testing.T) { + for _, name := range []string{"invalid_version", "missing_note", "wrong_title", "dirty", "staged", "wrong_branch", "local_tag", "remote_tag", "remote_ahead", "remote_unavailable", "validation_failure", "candidate_changed"} { + t.Run(name, func(t *testing.T) { + f := newReleaseFixture(t) + version := "v0.8.0" + var env []string + switch name { + case "invalid_version": + version = "v00.8.0" + case "missing_note": + version = "v0.8.1" + case "wrong_title": + writeFile(t, filepath.Join(f.root, "docs/releases/v0.8.0.md"), "# Wrong version\n", 0644) + f.git("add", ".") + f.git("commit", "-m", "Wrong title") + case "dirty", "staged": + writeFile(t, filepath.Join(f.root, "dirty.txt"), "dirty", 0644) + if name == "staged" { + f.git("add", ".") + } + case "wrong_branch": + f.git("checkout", "-b", "other") + case "local_tag": + f.git("tag", version) + case "remote_tag": + f.git("push", "origin", f.initial+":refs/tags/"+version) + case "remote_ahead": + // Publish an independent descendant of the old remote tip. + f.git("checkout", "-b", "other", f.initial) + f.git("commit", "--allow-empty", "-m", "Other work") + f.git("push", "origin", "HEAD:main") + f.initial = f.git("rev-parse", "HEAD") + f.git("checkout", "main") + case "remote_unavailable": + f.git("remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing.git")) + case "validation_failure": + env = []string{"RELEASE_TEST_FAIL=test"} + case "candidate_changed": + env = []string{"RELEASE_TEST_CHANGE=yes"} + } + if output, err := f.run(version, env...); err == nil { + t.Fatalf("guard allowed publication: %s", output) + } + if got := command(t, f.remote, "git", "rev-parse", "main"); got != f.initial { + t.Fatal("guard advanced remote branch") + } + if name != "remote_tag" { + if got := command(t, f.remote, "git", "tag", "--list"); got != "" { + t.Fatalf("guard published tag: %s", got) + } + } + }) + } +} + +func TestReleasePushFailureRecovery(t *testing.T) { + for _, mode := range []string{"branch_rejected", "tag_rejected", "tag_accepted_disconnect", "tag_unknown"} { + t.Run(mode, func(t *testing.T) { + f := newReleaseFixture(t) + if mode == "branch_rejected" || mode == "tag_rejected" { + pattern := "refs/heads/main" + if mode == "tag_rejected" { + pattern = "refs/tags/*" + } + writeFile(t, filepath.Join(f.remote, "hooks/update"), "#!/bin/sh\ncase \"$1\" in "+pattern+") exit 1;; esac\n", 0755) + } else { + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + // Simulate a lost acknowledgement, optionally followed by an outage. + writeFile(t, filepath.Join(f.bin, "git"), `#!/bin/sh +set -eu +if [ "$1" = -c ] && [ "$3" = push ] && [ "$5" = refs/tags/v0.8.0:refs/tags/v0.8.0 ]; then + "$RELEASE_TEST_GIT" "$@" + touch "$RELEASE_TEST_MARKER" + exit 1 +fi +if [ "$1" = ls-remote ] && [ -e "$RELEASE_TEST_MARKER" ] && [ "$RELEASE_TEST_MODE" = tag_unknown ]; then + exit 1 +fi +exec "$RELEASE_TEST_GIT" "$@" +`, 0755) + output, err := f.run("v0.8.0", "RELEASE_TEST_GIT="+realGit, "RELEASE_TEST_MARKER="+filepath.Join(f.bin, "marker"), "RELEASE_TEST_MODE="+mode) + if (err == nil) != (mode == "tag_accepted_disconnect") { + t.Fatalf("unexpected outcome: %v\n%s", err, output) + } + if got := command(t, f.remote, "git", "rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") { + t.Fatal("wrong remote tag") + } + return + } + if output, err := f.run("v0.8.0"); err == nil { + t.Fatalf("rejected push reported success: %s", output) + } + if got := command(t, f.remote, "git", "tag", "--list"); got != "" { + t.Fatal("rejected push published tag") + } + if mode == "tag_rejected" { + if got := f.git("rev-parse", "v0.8.0"); got != f.git("rev-parse", "HEAD") { + t.Fatal("unpublished local tag not preserved") + } + } else if got := f.git("tag", "--list"); got != "" { + t.Fatal("created tag after failed branch push") + } + }) + } +}