diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 532ec50..f30e407 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -11,9 +11,16 @@ steps: version="$CI_COMMIT_TAG" dist="dist" pkg="gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium" + notes="docs/releases/$version.md" + + if [ ! -f "$notes" ]; then + printf 'release notes not found: %s\n' "$notes" >&2 + exit 1 + fi rm -rf "$dist" mkdir -p "$dist" + cp "$notes" "$dist/RELEASE_NOTES.md" build_binary() { goos="$1" @@ -22,7 +29,7 @@ steps: output="$dist/scriptorium-$version-$goos-$goarch$suffix" CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ - go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/scriptorium/internal/buildinfo.Version=$version" \ + go build -trimpath -ldflags "-s -w" \ -o "$output" "$pkg" } @@ -38,6 +45,7 @@ steps: from_secret: GITEA_RELEASE_TOKEN files: - dist/scriptorium-* + note: dist/RELEASE_NOTES.md checksum: sha256 checksum-file: SHA256SUMS checksum-flatten: true diff --git a/docs/development.md b/docs/development.md index 392de98..4da4c53 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,7 +31,7 @@ tests. | OpenAI-compatible outbound behavior or timeout layering | [Promptkit integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/integrations/openai-compatible-chat.md) | | Subprocess behavior | [Subprocess integration](integrations/subprocess.md) and [CLI contract](cli.md) | | Runtime operation or recovery | [Operations](operations.md) | -| Release packaging | `.woodpecker/release.yml`, [operations](operations.md), and the architecture policy | +| Release packaging or publication | The [release procedure](release.md), [hosted release workflow](../.woodpecker/release.yml), and [architecture policy](policy/architecture.md) | | Examples or copyable assets | The owning Scriptorium contract, the relevant [Promptkit format contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.1.0/docs/formats.md), and the related files under `examples/` | | Architecture decisions or future work | The [documentation policy](policy/documentation.md), relevant accepted ADRs, and relevant roadmap documents | diff --git a/docs/policy/documentation.md b/docs/policy/documentation.md index 44361c6..5d2ed47 100644 --- a/docs/policy/documentation.md +++ b/docs/policy/documentation.md @@ -71,6 +71,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` | Application discovery and precedence, source locations, server fields, render default, HTTP limits, and credential mapping. | Promptkit framework formats and defaults, complete example files, CLI syntax, runtime lifecycle, and implementation detail. | | 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. | +| Release procedure | `docs/release.md` | Candidate validation, version and tag operations, hosted-workflow observation, and published-artifact verification. | Runtime operations, version-specific announcements, and complete application-interface contracts. | +| Version-specific release notes | `docs/releases/` | Immutable release summaries, compatibility notices, and migration announcements for one published version. | Complete CLI, HTTP, configuration, operations, or dependency contracts. | | Public HTTP contract | `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/` | Choosing between Scriptorium's executable interfaces and understanding consumer responsibilities. | HTTP wire semantics, CLI syntax, Promptkit's Go package, and internal implementation detail. | | External and durable integration contracts | `docs/integrations/` | Scriptorium-owned process and executable integration contracts. | Promptkit framework formats and outbound provider protocols, physical runtime placement, internal transformations, CLI syntax, and configuration defaults. | diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..a165392 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,373 @@ +# Release Procedure + +## Release Model And Status + +Scriptorium publishes annotated semantic tags and tag-triggered Linux binary +releases. The hosted +[release workflow](../.woodpecker/release.yml) builds `amd64` and `arm64` +executables, publishes their SHA-256 checksums, and uses the matching file +under `docs/releases/` as the hosted release body. + +`v0.12.0` is the selected version for the pending first application-only +release. It remains an unreleased candidate until its annotated tag is +published, the hosted workflow succeeds, and every published artifact is +verified. Later releases select a new `vMAJOR.MINOR.PATCH` version according to +the intended compatibility change. + +Run this procedure from the Scriptorium repository root. A release must not +depend on a Go workspace, module replacement, vendor tree, sibling checkout, +unpublished dependency, or unpushed source commit. + +## Establish The Candidate + +For the pending application-only release, start a POSIX shell and select: + +```sh +export RELEASE_VERSION=v0.12.0 +``` + +For a later release, export its not-yet-published semantic version instead. +Then run the following guard in that same shell: + +```sh +set -eu + +: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}" +if ! printf '%s\n' "$RELEASE_VERSION" | + grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +then + printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2 + exit 1 +fi + +RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}') +export RELEASE_COMMIT + +check_release_candidate() { + test "$(git branch --show-current)" = main + test -z "$(git status --porcelain)" + + gowork_value=$(go env GOWORK) + case "$gowork_value" in + ''|off) ;; + *) + printf '%s\n' "active Go workspace: $gowork_value" >&2 + return 1 + ;; + esac + + test -z "$(git ls-files go.work go.work.sum)" + test ! -e vendor + if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod + then + printf '%s\n' 'go.mod contains a replacement' >&2 + return 1 + fi + + git fetch origin main --tags + test "$RELEASE_COMMIT" = \ + "$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')" + + if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION" + then + printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2 + return 1 + fi + if test -n "$( + git ls-remote --tags origin \ + "refs/tags/$RELEASE_VERSION" \ + "refs/tags/$RELEASE_VERSION^{}" + )" + then + printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2 + return 1 + fi +} + +check_release_candidate +``` + +Do not continue unless this guard succeeds. It deliberately requires the +candidate to be the exact clean commit already published at `origin/main`. + +## Verify Modules And Repository Boundaries + +Confirm the module path and declared Go version: + +```sh +test "$( + GOWORK=off go list -m -f '{{.Path}} {{.GoVersion}}' +)" = 'gitea.maximumdirect.net/eric/scriptorium 1.25.5' +``` + +Require Promptkit `v0.1.0` as both the direct module-graph edge and the selected +module version: + +```sh +direct_promptkit=$( + GOWORK=off go mod graph | + awk ' + $1 == "gitea.maximumdirect.net/eric/scriptorium" && + $2 ~ /^gitea\.maximumdirect\.net\/eric\/promptkit@/ { + print $2 + } + ' +) +test "$direct_promptkit" = \ + 'gitea.maximumdirect.net/eric/promptkit@v0.1.0' +test "$( + GOWORK=off go list -m -f '{{.Path}}@{{.Version}}' \ + gitea.maximumdirect.net/eric/promptkit +)" = 'gitea.maximumdirect.net/eric/promptkit@v0.1.0' +GOWORK=off go list -m all +``` + +Require tidy module metadata and recheck the repository exclusions: + +```sh +GOWORK=off go mod tidy -diff +test -z "$(git ls-files go.work go.work.sum)" +test ! -e vendor +if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod +then + printf '%s\n' 'go.mod contains a replacement' >&2 + exit 1 +fi +test -z "$(git status --porcelain)" +``` + +## Validate The Application + +Run the complete application validation: + +```sh +GOWORK=off go test ./... +GOWORK=off go test -race ./... +GOWORK=off go vet ./... +validation_build_dir=$(mktemp -d) +GOWORK=off go build \ + -o "$validation_build_dir/scriptorium" \ + ./cmd/scriptorium +``` + +The ordinary test run includes the architecture guard that rejects a root Go +package, former framework package families, and imports of Promptkit internal +packages. Inspect the repository for generated binaries, credentials, +temporary output, sibling paths, and other files that do not belong in the +tracked release source. + +Check every tracked Go file. This command must produce no output: + +```sh +unformatted=$( + git ls-files '*.go' | + while IFS= read -r go_file + do + gofmt -l "$go_file" + done +) +test -z "$unformatted" +``` + +Run the maintained render script and smoke-test both maintained configuration +examples without a model call: + +```sh +GOWORK=off ./examples/render-markdown-summary.sh +for config_file in examples/config.yml examples/config.full.yml +do + GOWORK=off go run ./cmd/scriptorium render \ + --config "$config_file" \ + --prompt generic.markdown_summary \ + --input transcript=./examples/fixtures/transcript.md \ + --input glossary=./examples/fixtures/glossary.yml \ + --format json >/dev/null +done +``` + +Exercise usage output and offline rendering with the temporary native +executable: + +```sh +usage_output="$validation_build_dir/usage.txt" +if "$validation_build_dir/scriptorium" >"$usage_output" 2>&1 +then + printf '%s\n' 'expected an invocation without a command to fail' >&2 + exit 1 +fi +grep -F 'usage: scriptorium' "$usage_output" +"$validation_build_dir/scriptorium" render \ + --config ./examples/config.yml \ + --prompt generic.markdown_summary \ + --input transcript=./examples/fixtures/transcript.md \ + --input glossary=./examples/fixtures/glossary.yml \ + --format text >/dev/null +``` + +Follow every maintained local, Promptkit-tagged, and other external Markdown +link. Confirm that all repository-relative link targets exist. Finish the +application checks with: + +```sh +git diff --check +test -z "$(git status --porcelain)" +``` + +## Require Release Notes And Reproduce Packaging + +The immutable release note must exist before tagging: + +```sh +release_notes="docs/releases/$RELEASE_VERSION.md" +test -f "$release_notes" +test -s "$release_notes" +``` + +Validate every local and currently published link in the note. For links +pinned to the candidate Scriptorium tag, confirm that the corresponding +repository-relative path exists even though its tag URL is not live yet. + +Reproduce the hosted build flags, targets, and filenames in a temporary +directory: + +```sh +release_dist=$(mktemp -d) +release_package='gitea.maximumdirect.net/eric/scriptorium/cmd/scriptorium' + +build_release_binary() { + target_os="$1" + target_arch="$2" + output="$release_dist/scriptorium-$RELEASE_VERSION-$target_os-$target_arch" + + CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" GOWORK=off \ + go build -trimpath -ldflags '-s -w' \ + -o "$output" "$release_package" +} + +build_release_binary linux amd64 +build_release_binary linux arm64 + +test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64" +test -s "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64" +file "$release_dist/scriptorium-$RELEASE_VERSION-linux-amd64" +file "$release_dist/scriptorium-$RELEASE_VERSION-linux-arm64" +``` + +Require `file` to identify Linux executables for `x86-64` and `ARM aarch64`, +respectively. Inspect the +[hosted workflow](../.woodpecker/release.yml) and confirm that it uses the +same build flags and names, copies the selected release note to +`dist/RELEASE_NOTES.md`, publishes only `dist/scriptorium-*`, and keeps +checksum generation enabled. + +## Create And Publish The Tag + +Run the candidate guard again immediately before creating the tag: + +```sh +check_release_candidate +test -f "$release_notes" +test -s "$release_notes" +``` + +Create an annotated tag explicitly bound to the validated commit, using the +version-specific release note as its message: + +```sh +git tag --annotate "$RELEASE_VERSION" \ + --file "$release_notes" \ + "$RELEASE_COMMIT" +``` + +Inspect the tag and require it to resolve to the validated source: + +```sh +test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag +git show --no-patch --decorate "refs/tags/$RELEASE_VERSION" +test "$( + git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}" +)" = "$RELEASE_COMMIT" +``` + +If inspection finds an error, delete only the unpublished local tag, correct +the candidate, and repeat the complete validation. Never move or recreate a +published tag. + +Push only the selected tag ref: + +```sh +git push origin \ + "refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION" +``` + +## Observe And Verify Publication + +Open the hosted workflow run for the selected tag. Require +`build-release-assets` to succeed before `publish-release`, then require the +publication step and hosted release to succeed. A queued, running, failed, or +partially published workflow is not a verified release. + +Compare the local and remote annotated-tag objects and their source commits: + +```sh +remote_tag=$( + git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" | + awk 'NR == 1 { print $1 }' +) +remote_commit=$( + git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" | + awk 'NR == 1 { print $1 }' +) +test -n "$remote_tag" +test "$remote_tag" = \ + "$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")" +test "$remote_commit" = "$RELEASE_COMMIT" +``` + +Download the hosted binaries and checksum file into a temporary directory: + +```sh +release_base="https://gitea.maximumdirect.net/eric/scriptorium/releases/download/$RELEASE_VERSION" +download_dir=$(mktemp -d) +( + cd "$download_dir" + for asset in \ + "scriptorium-$RELEASE_VERSION-linux-amd64" \ + "scriptorium-$RELEASE_VERSION-linux-arm64" \ + SHA256SUMS + do + curl --fail --location --remote-name "$release_base/$asset" + done + + test -s "scriptorium-$RELEASE_VERSION-linux-amd64" + test -s "scriptorium-$RELEASE_VERSION-linux-arm64" + test -s SHA256SUMS + sha256sum --check SHA256SUMS + test "$(wc -l < SHA256SUMS | tr -d ' ')" = 2 + file "scriptorium-$RELEASE_VERSION-linux-amd64" + file "scriptorium-$RELEASE_VERSION-linux-arm64" +) +``` + +Require the same Linux architectures observed in the local packaging check and +confirm that the hosted release contains no unexpected asset. On a compatible +Linux host, make the matching downloaded binary executable and repeat the +usage-output and offline-render smoke checks against it. + +Only after the tag, workflow, release body, binaries, architectures, and +checksums all pass verification is the candidate a verified published release. + +## Handle Failures + +Before tag publication, correct the release commit or note and restart the +complete procedure. After tag publication, never delete, move, overwrite, or +recreate the tag. A transient hosted failure may be retried only against the +same immutable tag and commit and only when doing so cannot overwrite or +silently retain partial assets. A source, packaging, note, or artifact defect +requires a new corrective semantic version from a new validated commit. + +Record the selected version, validated commit, tag object, workflow result, +artifact names, checksum result, and smoke-check outcome in the release +checkpoint. Keep temporary builds and downloaded assets outside the repository, +and require a clean `main` synchronized with `origin/main` when verification +is complete.