diff --git a/README.md b/README.md index 1e27278..297262c 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,9 @@ Contributors should start with the [development guide](docs/development.md). The [architecture policy](docs/policy/architecture.md) defines the library boundary and constraints that framework work must preserve. +## Related Project + +[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and +HTTP application built on Promptkit. + Promptkit is licensed under the [GNU General Public License version 3](LICENSE). diff --git a/docs/release.md b/docs/release.md index 34154b7..6b15cab 100644 --- a/docs/release.md +++ b/docs/release.md @@ -7,27 +7,91 @@ tags. It does not publish runnable binaries or binary packages and does not currently use hosted CI. The release maintainer performs and records the required validation. -The first planned release is `v0.1.0`. Do not create that tag until the -framework has been extracted and the resulting public library has passed this -procedure. Later tags use the `vMAJOR.MINOR.PATCH` form. While Promptkit remains -pre-`v1`, release notes must identify intentional public API changes and any -consumer migration required by them. +`v0.1.0` is the initial published release. Later releases use semantic +`vMAJOR.MINOR.PATCH` tags. Before `v1`, minor releases may change the public +API and patch releases preserve compatibility within their minor line. Every +pre-`v1` release note must summarize compatibility, identify public API +changes, and state any action required of consumers. -## Prepare The Release +Promptkit releases are source-only. The annotated tag message is the release +note; there is no separate hosted release or binary packaging step. -Work from a clean checkout of the intended release commit, outside any Go -workspace and without a local module replacement. Confirm the source commit is -already published through the normal branch workflow. +## Establish The Candidate -From the Promptkit repository root, verify the checkout: +Choose a version that has not been published and export it as +`RELEASE_VERSION`. Run every command in this procedure from the Promptkit +repository root in the same POSIX shell. Do not reuse `v0.1.0` or another +existing version. + +The following guard derives the release commit from `HEAD` and stops on a +missing or malformed version, a checkout other than synchronized `main`, +uncommitted changes, an active Go workspace, a module replacement, a vendor +tree, or an existing local or remote tag: ```sh -gowork=$(go env GOWORK) -test -z "$gowork" || test "$gowork" = off -test -z "$(git status --short)" -git fetch --tags origin +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 the guard completes successfully. In particular, push +the intended commit through the normal `main` branch workflow before release; +the tag procedure is not a substitute for publishing the source commit. + +## Validate The Candidate + Confirm the module and root package metadata: ```sh @@ -42,7 +106,7 @@ gitea.maximumdirect.net/eric/promptkit 1.25.5 promptkit gitea.maximumdirect.net/eric/promptkit ``` -Run the same default Go validation required by the +Run the complete maintainer validation required by the [development guide](development.md): ```sh @@ -53,84 +117,158 @@ go build ./... go run ./examples/go-library/prepare ``` -Check every tracked Go file and repository whitespace: +Check every tracked Go file. This command must produce no output: ```sh -gofmt -l $(git ls-files '*.go') +unformatted=$( + git ls-files '*.go' | + while IFS= read -r go_file + do + gofmt -l "$go_file" + done +) +test -z "$unformatted" +``` + +Follow every maintained Markdown link and confirm that its local or published +target exists. Review the repository for generated binaries, test or coverage +output, credentials, template residue, downloaded assets, and other files that +do not belong in source control. + +Recheck module and repository hygiene, whitespace, and the clean checkout: + +```sh +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 git diff --check +test -z "$(git status --porcelain)" ``` -The formatting command must produce no paths. Follow every maintained Markdown -link and confirm its target exists. Review the repository for generated -binaries, test or coverage output, credentials, template residue, and other -files that do not belong in source control. +## Write The Release Note -Confirm that no workspace override is tracked and that `go.mod` contains no -`replace` directive: +Prepare a plain-text annotated-tag message outside the repository and export +its path as `RELEASE_NOTES_FILE`. Use this form, replacing each summary with +release-specific text; write `None.` when there are no public API changes or +consumer actions: -```sh -git ls-files go.work go.work.sum -rg -n '^replace\b' go.mod +```text +Promptkit vMAJOR.MINOR.PATCH + +Validated commit: full commit ID +Compatibility: compatibility summary +Public API changes: changes or None. +Consumer action: required action or None. ``` -Both commands must produce no output. Re-run `git status --short` and require a -clean result after every validation and review check. - -## Create And Publish The Tag - -Choose the semantic version from the intended compatibility change. Record the -release commit before tagging: +After writing it, require all release-note fields, the selected version, and +the validated commit to be present: ```sh -release_version=v0.1.0 -release_commit=$(git rev-parse HEAD) +: "${RELEASE_NOTES_FILE:?export the path to the release-note file}" +test -f "$RELEASE_NOTES_FILE" +test -s "$RELEASE_NOTES_FILE" +grep -F "Promptkit $RELEASE_VERSION" "$RELEASE_NOTES_FILE" +grep -F "Validated commit: $RELEASE_COMMIT" "$RELEASE_NOTES_FILE" +grep -F 'Compatibility:' "$RELEASE_NOTES_FILE" +grep -F 'Public API changes:' "$RELEASE_NOTES_FILE" +grep -F 'Consumer action:' "$RELEASE_NOTES_FILE" ``` -Replace the example version for later releases and keep both values in the same -shell for the remaining commands. Confirm the tag does not already exist -locally or remotely: +Inspect the complete message and confirm that it accurately records the +compatibility impact, public API changes, and required consumer action. + +## Create And Inspect The Tag + +Run the candidate guard again immediately before tag creation. This ensures +that validation or release-note preparation did not change the checkout and +that the commit is still published and untagged: ```sh -test -z "$(git tag --list "$release_version")" -test -z "$(git ls-remote --tags origin "refs/tags/$release_version")" +check_release_candidate ``` -Create an annotated tag whose message identifies the release and records that -the documented validation passed for the tagged commit: +Create the annotated tag from the prepared release note and bind it explicitly +to the validated commit: ```sh -git tag --annotate "$release_version" \ - --message "Promptkit $release_version; documented validation passed for $release_commit" +git tag --annotate "$RELEASE_VERSION" \ + --file "$RELEASE_NOTES_FILE" \ + "$RELEASE_COMMIT" ``` -Inspect the tag before publication: +Inspect both the tag message and its source commit before publication: ```sh -git show --no-patch --decorate "$release_version" -test "$(git rev-list -n 1 "$release_version")" = "$release_commit" +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" ``` -Publish the tag without relying on a hosting-provider-specific release -interface: +If inspection finds an error, delete the unpublished local tag, correct the +release note or candidate, and repeat the guards. Never move or recreate a tag +that has been published. + +## Publish The Selected Tag + +Push only the selected tag ref. Do not use `git push --tags`: ```sh -git push origin "refs/tags/$release_version" +git push origin \ + "refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION" ``` ## Verify Publication -Confirm that the remote tag object matches the local annotated tag and still -resolves to the intended source commit: +Compare the remote annotated-tag object with the local object, then compare the +remote peeled source commit with the validated commit: ```sh -remote_tag=$(git ls-remote --tags origin "refs/tags/$release_version" | awk '{print $1}') -test "$remote_tag" = "$(git rev-parse "refs/tags/$release_version")" -test "$(git rev-list -n 1 "refs/tags/$release_version")" = "$release_commit" +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" ``` -Promptkit must publish the required tag before Scriptorium or another consumer -publishes a release that depends on that version. Released consumer modules -must not use a local replacement or unpublished Promptkit revision. +Finally, resolve the version as an ordinary Go module in a temporary module +outside this repository and without a workspace or replacement: + +```sh +resolution_dir=$(mktemp -d) +( + trap 'rm -rf "$resolution_dir"' 0 1 2 15 + cd "$resolution_dir" + GOWORK=off go mod init example.com/promptkit-release-check + GOWORK=off go mod download \ + "gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION" + resolved_version=$( + GOWORK=off go list -m -f '{{.Version}}' \ + "gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION" + ) + test "$resolved_version" = "$RELEASE_VERSION" +) +``` + +Promptkit must publish and verify the required version before Scriptorium or +another consumer publishes a release that depends on it. This ordering does +not replace the consumer project's own release procedure. Released consumers +must select the published Promptkit tag through ordinary module resolution, +without a workspace, replacement, vendored Promptkit source, or unpublished +revision. ## Policy Changes