diff --git a/docs/backlog/COR-103-verification-loop.md b/docs/backlog/COR-103-verification-loop.md index 604f4323..2d9aadd0 100644 --- a/docs/backlog/COR-103-verification-loop.md +++ b/docs/backlog/COR-103-verification-loop.md @@ -32,7 +32,7 @@ Each check fails first on one named past mistake. | COR-103-T7 | One install and uninstall path | agent | - | done | | COR-103-T8 | Update through the target updater | agent | COR-103-T7 | done | | COR-103-T9 | Sign off an isolated checkout | agent | COR-103-T7 | done | -| COR-103-T10 | Release with a receipt | agent | COR-103-T7, COR-103-T9 | todo | +| COR-103-T10 | Release with a receipt | agent | COR-103-T7, COR-103-T9 | done | | COR-103-T11 | macOS job in the template | agent | - | done | | COR-103-T12 | Author path from the published tag | agent | - | done | | COR-103-T13 | Rules and prose | agent | COR-103-T4, COR-103-T5, COR-103-T6, COR-103-T8, COR-103-T10, COR-103-T11, COR-103-T12 | todo | diff --git a/docs/north-star/contract/extension-repository.md b/docs/north-star/contract/extension-repository.md index 2ac24c59..0f99c003 100644 --- a/docs/north-star/contract/extension-repository.md +++ b/docs/north-star/contract/extension-repository.md @@ -31,10 +31,11 @@ The template refuses `main` and each Go keyword as a domain, such as `type`. It A push can skip Lefthook, so its GitHub workflow runs `just check` again on `ubuntu-24.04`. Public repositories also run the same steps on `macos-15`, and both runners are free. -The repository tests itself through the [test kit](test-kit.md). Its `justfile` has seven recipes: +The repository tests itself through the [test kit](test-kit.md). Its `justfile` has eight recipes: - `just check` runs every check: `go mod tidy -diff`, `gofmt`, `go vet`, and `go test`, whose test kit installs the extension into scratch folders - `just signoff` checks the committed SHA in isolation, then signs that SHA, as [Local checks](../decisions/D016-local-ci.md) defines +- `just release [fixture]` publishes the signed SHA and proves the README install, as [Release and install](extensions.md#release-and-install) defines - `just install` builds the executable and exports its sidecars from that build. It replaces the previous install of the slug in the extensions folder of [Discovery](discovery.md), the executable last - `just uninstall` removes the install of the slug from that folder, the executable first. With nothing installed, it says so and exits 0 - `just acceptance [fixture]` runs `acceptance.sh` as an agent would, with `--json` and `--no-input`. It installs the extension and the `ncly` of the pinned Nicely version into scratch XDG folders. It works on a copy of the fixture and checks that the fixture keeps its checksum. Without a fixture, it makes an input @@ -43,10 +44,11 @@ The repository tests itself through the [test kit](test-kit.md). Its `justfile` `just install` grants no key. A human grants one after reviewing the extension, as [Grants](grants.md) defines. -The SDK package `extensionrepo` owns install, uninstall, and signoff. +The SDK package `extensionrepo` owns install, uninstall, signoff, and release. The template's `internal/export/` program calls `extensionrepo.Main(Extension)`. The recipes each call that program in one line: `go run ./internal/export install` or `go run ./internal/export uninstall`. Signoff calls `go run ./internal/export signoff`. +Release calls `go run ./internal/export release [fixture]`. Its `export ` subcommand writes the embedded sidecars of the staged build. Install stages both builds beside the destination with one `go build`. diff --git a/docs/north-star/contract/extensions.md b/docs/north-star/contract/extensions.md index b978bd49..2c0ccea8 100644 --- a/docs/north-star/contract/extensions.md +++ b/docs/north-star/contract/extensions.md @@ -20,7 +20,34 @@ An author outside Nicely keeps an extension in a Git repository of its own, whic ### Release and install -An author releases a version as one Git tag `vX.Y.Z` of Semantic Versioning, such as `v0.1.0`, on a pushed commit where `just check` passes. +An author releases a version with `just release vX.Y.Z [fixture]`. +The SDK requires a clean HEAD equal to the fetched default branch of `origin`. +A successful signoff receipt must name that SHA, with exit 0 for every check and signoff. +Release runs `just acceptance [fixture]`, then creates and pushes an annotated tag whose message holds that signoff receipt. +It executes the fenced `sh` block under `## Install` in `README.md`, with `TAG` replaced by the new tag. +The install uses scratch HOME and XDG folders, without the caller's keys or installed extensions. +The clone's HEAD must equal the tagged SHA, even when another README paragraph names the new tag. +Release then checks discovery, measures [Economy](../../qa/north-star.md#economy), uninstalls, and requires `NOT_FOUND`. +Economy runs literal manifest examples in both output formats, with a dry run when supported. +It selects these modes even when an example already sets them. +Each extension invocation extracts the files of `testdata/examples.txtar` from the tagged scratch clone. +It ignores archive setup commands and keeps extracted files off PATH. +An optional fixture replaces the first example argument that names an archive file, under that argument's filename. +Each measurement uses a fresh working folder, so previous output cannot change the next measurement. +Each measurement records the command, exit, elapsed milliseconds, and separate stdout and stderr byte counts. +Failed Economy calls and exceeded budgets are reservations, not release failures. +When available, `gh` reads the SHA's hosted check runs. Missing or failed hosted evidence becomes a note. +Release stores its receipt and command logs outside the repository, under the Nicely cache's `release/` folder. +It prints the receipt path, and its last success line names the tag and SHA. +A failed published install keeps the pushed tag and records the failure. Release never replaces an existing tag. + +The receipt uses `tag`, `sha`, `os`, `signoff`, `pushed`, `cloned_sha`, `steps`, `economy`, `hosted_checks`, `notes`, `reservations`, and optional `failure`. +`signoff` holds the original signoff receipt. `pushed` records tag publication, and `cloned_sha` records the published clone's HEAD. +`hosted_checks` holds paginated check-run responses. `notes` records unavailable hosted evidence, and `reservations` records Economy findings. +`failure` holds the fatal diagnostic. +Each row in `steps` or `economy` holds `name`, `command`, `exit`, `elapsed_ms`, `stdout_bytes`, `stderr_bytes`, `stdout`, and `stderr`. +`stdout` and `stderr` name log files relative to the receipt. Scratch installs and measurement folders are removed after use. + A user installs a fixed version with `git clone --branch `, then `just install` in the clone, which need Go and `just`. For an annotated tag, Git can warn that the tag `is not a commit!`, then note a detached HEAD: the clone holds the tagged commit. `ncly describe --json` then confirms the install. diff --git a/scripts/template-release-helpers.sh b/scripts/template-release-helpers.sh new file mode 100644 index 00000000..c237f384 --- /dev/null +++ b/scripts/template-release-helpers.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Shared calls and receipt fixtures for the local release proof. + +fct_release_attempt() { + local repo="${1}" scratch="${2}" tag="${3}" status=0 + shift 3 + (cd "${scratch}" && PATH="${scratch}/release-bin:${PATH}" XDG_CACHE_HOME="${scratch}/release-cache" just --justfile "${repo}/justfile" release "${tag}" "$@") >"${scratch}/release.out" 2>&1 || status=$? + printf 'release proof: just release %s, exit %s\n' "${tag}" "${status}" + cat "${scratch}/release.out" + return "${status}" +} + +# Receipts are fixture facts. The release under test must read T9's format. +fct_release_receipt() { + local repo="${1}" scratch="${2}" sha + sha="$(git -C "${repo}" rev-parse HEAD)" + mkdir -p "${scratch}/release-cache/nicely/signoff/test-${sha}" + PATH="/usr/bin:${PATH}" jq -n --arg sha "${sha}" '{sha:$sha,os:"test",tools:{git:"test"},checks:[{command:["just","check"],exit:0}],signoff:{command:["gh","signoff","--commit",$sha],exit:0}}' >"${scratch}/release-cache/nicely/signoff/test-${sha}/receipt.json" +} diff --git a/scripts/template-release-test.sh b/scripts/template-release-test.sh index 4364b5d8..e5ffe01f 100644 --- a/scripts/template-release-test.sh +++ b/scripts/template-release-test.sh @@ -1,38 +1,139 @@ #!/usr/bin/env bash -# Sourced by template-test.sh to prove the release steps and the install -# commands of a rendered repository, as "Release and install" in -# docs/north-star/contract/extensions.md says. - -# fct_test_release releases the rendered repository $1 as the tag v0.1.0, then -# installs it as a user does with the commands of its README.md, from a clone -# at that tag. The install goes to a new data folder, where ncly describe then -# finds the root $4 of the slug $3, so no earlier install can answer. +# Sourced by template-test.sh. Only local bare remotes receive release tags. + +# shellcheck source=scripts/template-release-helpers.sh +source "${BASH_SOURCE[0]%/*}/template-release-helpers.sh" + fct_test_release() { - local repo="${1}" scratch="${2}" slug="${3}" extension_root="${4}" root_words data="${2}/release-data" - read -r -a root_words <<<"${extension_root}" - fct_has "${repo}/AGENTS.md" "## Release" - fct_has "${repo}/README.md" "git clone --branch TAG https://example.com/ncly-${slug} ncly-${slug}" + local repo="${1}" scratch="${2}" slug="${3}" extension_root="${4}" sha previous receipt dir status out_file out_bytes err_file err_bytes i examples command + local remote="${2}/release-origin.git" fixture="${2}/release-fixture/input.md" + fct_has "${repo}/AGENTS.md" '## Release' fct_has "${repo}/README.md" "ncly describe ${extension_root} --json" - # An annotated tag makes Git warn that the tag is not a commit, which README.md names. - git -C "${repo}" tag -a v0.1.0 -m v0.1.0 - git clone --quiet --branch v0.1.0 "${repo}" "${scratch}/ncly-${slug}-v0.1.0" 2>"${scratch}/clone.err" - (cd "${scratch}/ncly-${slug}-v0.1.0" && XDG_DATA_HOME="${data}" just install) - [[ -x "${data}/nicely/extensions/ncly-${slug}" ]] || fct_fail "the install of the tag wrote no ${data}/nicely/extensions/ncly-${slug}" - XDG_DATA_HOME="${data}" PATH="/usr/bin:${PATH}" "$(command -v go)" -C "${repo}" tool ncly describe "${root_words[@]}" --json >"${scratch}/describe.out" - grep -qE "\"path\":\"${extension_root}\",\"summary\":\"[^\"]*\",\"source\":\"extension\"" "${scratch}/describe.out" || - fct_fail "ncly describe does not find ${extension_root} after the install of the tag: $(cat "${scratch}/describe.out")" + fct_has "${repo}/README.md" "git clone --branch TAG https://example.com/ncly-${slug} ncly-${slug}" + [[ "$(just --justfile "${repo}/justfile" --show release | grep -c '^ ')" == 1 ]] || fct_fail 'just release must have one command' + just --justfile "${repo}/justfile" --show release | grep -q '^ go run ./internal/export release ' || fct_fail 'just release must call the SDK program' + git init --bare -q "${remote}" + git -C "${repo}" remote add origin "${remote}" + git -C "${repo}" push -q origin HEAD:main + git -C "${remote}" symbolic-ref HEAD refs/heads/main + git config --file "${GIT_CONFIG_GLOBAL}" "url.${remote}.insteadOf" "https://example.com/ncly-${slug}" + mkdir -p "${scratch}/release-bin" "${scratch}/release-fixture" + printf '# Release input\nTwo lines\n' >"${fixture}" + cat >"${scratch}/release-bin/gh" <<'SCRIPT' +#!/usr/bin/env bash +set -Eeuo pipefail +[[ "$#" == 4 && "$1" == api && "$2" == --paginate && "$3" == --slurp && "$4" == "repos/{owner}/{repo}/commits/$(git rev-parse HEAD)/check-runs" ]] +printf '[{"total_count":1,"check_runs":[{"name":"local-stub","head_sha":"%s","conclusion":"success"}]}]\n' "$(git rev-parse HEAD)" +SCRIPT + chmod 755 "${scratch}/release-bin/gh" + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.0 || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF 'just signoff' "${scratch}/release.out"; then + fct_fail 'release accepts a SHA without a receipt' + fi + [[ -z "$(git -C "${remote}" tag -l)" ]] || fct_fail 'receipt refusal pushed a tag' + + # Invalid receipt exits cannot authorize a release. + fct_release_receipt "${repo}" "${scratch}" + sha="$(git -C "${repo}" rev-parse HEAD)" + receipt="${scratch}/release-cache/nicely/signoff/test-${sha}/receipt.json" + cp "${receipt}" "${scratch}/valid-signoff.json" + for previous in '.checks[0].exit = 1' '.signoff.exit = 1' '.sha = "previous"' 'del(.checks[0].exit)' '.signoff.exit = null'; do + PATH="/usr/bin:${PATH}" jq "${previous}" "${scratch}/valid-signoff.json" >"${receipt}" + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.0 || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF 'just signoff' "${scratch}/release.out"; then + fct_fail 'release accepts an unsuccessful receipt' + fi + done + cp "${scratch}/valid-signoff.json" "${receipt}" + + printf '\nDirty change\n' >>"${repo}/README.md" + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.0 || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF 'clean HEAD' "${scratch}/release.out"; then + fct_fail 'release accepts a dirty tree' + fi + git -C "${repo}" restore README.md + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.0 missing-fixture.md || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF 'acceptance exited' "${scratch}/release.out"; then + fct_fail 'release accepts failed acceptance' + fi + [[ -z "$(git -C "${remote}" tag -l)" ]] || fct_fail 'acceptance refusal pushed a tag' + + # Economy selects modes even when the first example already sets them. + examples="$(grep -m1 '^examples = ' "${repo}/manifest.toml")" + command="$(sed -n 's/^examples = \["ncly \(.*\) notes.md".*$/\1/p' "${repo}/manifest.toml" | head -n 1)" + fct_edit "${repo}/manifest.toml" "${examples}" "examples = [\"ncly ${command} 'notes.md' --dry-run --json\", \"ncly ${command} notes.md\"]" + git -C "${repo}" add manifest.toml + + # D129: exceeding a skill budget is a reported reservation, never a release block. + for ((i = 0; i < 100; i++)); do printf 'Release budget sample.\n' >>"${repo}/skill/SKILL.md"; done + git -C "${repo}" add skill/SKILL.md + printf '\nUnpushed change\n' >>"${repo}/README.md" + git -C "${repo}" add README.md + git -C "${repo}" commit -qm 'unpushed release input' + fct_release_receipt "${repo}" "${scratch}" + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.0 || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF 'pushed default branch' "${scratch}/release.out"; then + fct_fail 'release accepts an unpushed SHA' + fi + git -C "${repo}" push -q origin HEAD:main + fct_release_attempt "${repo}" "${scratch}" v0.1.0 || fct_fail 'first local release failed' + receipt="$(sed -n 's/^release: receipt in //p' "${scratch}/release.out")" + PATH="/usr/bin:${PATH}" jq -r '.economy[] | select(.command[1:3] != ["skill", "view"] and .command[1] != "describe" and .command[1] != "doctor") | "default Economy: \(.command | join(" ")), exit \(.exit)"' "${receipt}" + PATH="/usr/bin:${PATH}" jq -e '[.economy[] | select(.command | index("notes.md"))] | length == 4 and all(.[]; .exit == 0)' "${receipt}" >/dev/null || fct_fail 'default release Economy did not run all four extension samples successfully' + previous="$(git -C "${repo}" rev-parse HEAD)" + + # D095: a new tag in prose cannot repair an install block pinned to the previous tag. + fct_edit "${repo}/README.md" "git clone --branch TAG https://example.com/ncly-${slug} ncly-${slug}" "git clone --branch v0.1.0 https://example.com/ncly-${slug} ncly-${slug}" + printf '\nThe current release is v0.1.1.\n' >>"${repo}/README.md" + git -C "${repo}" add README.md + git -C "${repo}" commit -qm 'pin the README install to the previous tag' + git -C "${repo}" push -q origin HEAD:main + fct_release_receipt "${repo}" "${scratch}" + status=0 + fct_release_attempt "${repo}" "${scratch}" v0.1.1 "${fixture}" || status=$? + if [[ "${status}" == 0 ]] || ! grep -qF "README install cloned SHA ${previous}, want tagged SHA" "${scratch}/release.out"; then + fct_fail 'D095: release accepts a README install of the previous tag' + fi + printf 'RED D095: previous-tag README rejected after publication, exit %s\n' "${status}" + + fct_edit "${repo}/README.md" "git clone --branch v0.1.0 https://example.com/ncly-${slug} ncly-${slug}" "git clone --branch TAG https://example.com/ncly-${slug} ncly-${slug}" + git -C "${repo}" add README.md + git -C "${repo}" commit -qm 'install the selected release tag' + git -C "${repo}" push -q origin HEAD:main + fct_release_receipt "${repo}" "${scratch}" + sha="$(git -C "${repo}" rev-parse HEAD)" + fct_release_attempt "${repo}" "${scratch}" v0.1.2 release-fixture/input.md || fct_fail 'correct README release failed' + [[ "$(tail -n 1 "${scratch}/release.out")" == "Released v0.1.2 ${sha}" ]] || fct_fail 'release ends without the tag and SHA' + receipt="$(sed -n 's/^release: receipt in //p' "${scratch}/release.out")" + dir="${receipt%/*}" + PATH="/usr/bin:${PATH}" jq -e '.ok == true' "${dir}/economy-6-json.stdout" >/dev/null || fct_fail 'Economy JSON mode returned no answer' + PATH="/usr/bin:${PATH}" jq -e --argjson bytes "$(wc -c <"${fixture}" | tr -d ' ')" '.size_bytes == $bytes' "${dir}/economy-6-json.stdout" >/dev/null || fct_fail 'Economy did not replace the example input with the supplied fixture' + if PATH="/usr/bin:${PATH}" jq -e . "${dir}/economy-6-text.stdout" >/dev/null 2>&1; then fct_fail 'Economy text mode returned JSON'; fi + PATH="/usr/bin:${PATH}" jq -e --arg sha "${sha}" '.tag == "v0.1.2" and .sha == $sha and .cloned_sha == $sha and .pushed and (.failure == null) and .signoff.sha == $sha and .hosted_checks[0].check_runs[0].head_sha == $sha and any(.reservations[]; contains("exceeds a budget")) and (.economy | length == 14) and all(.economy[]; .exit == 0 and (.elapsed_ms | type) == "number") and ([.steps[] | select(.name == "not-found")][0].exit == 2)' "${receipt}" >/dev/null || fct_fail 'release receipt lacks published proof or Economy rows' + while IFS=$'\t' read -r out_file out_bytes err_file err_bytes; do + [[ "$(wc -c <"${dir}/${out_file}" | tr -d ' ')" == "${out_bytes}" ]] || fct_fail 'Economy stdout count differs from the actual output' + [[ "$(wc -c <"${dir}/${err_file}" | tr -d ' ')" == "${err_bytes}" ]] || fct_fail 'Economy stderr count differs from the actual output' + done < <(PATH="/usr/bin:${PATH}" jq -r '.economy[] | [.stdout, .stdout_bytes, .stderr, .stderr_bytes] | @tsv' "${receipt}") + grep -qF '"code":"NOT_FOUND"' "${dir}/not-found.stderr" || fct_fail 'uninstall gives no NOT_FOUND' + git -C "${remote}" for-each-ref --format='%(contents)' refs/tags/v0.1.2 >"${scratch}/tag-message.json" + PATH="/usr/bin:${PATH}" jq -e --arg sha "${sha}" '.sha == $sha and .signoff.exit == 0 and ([.checks[].exit] | all(. == 0))' "${scratch}/tag-message.json" >/dev/null || fct_fail 'annotated tag lacks its successful signoff receipt' + [[ -z "$(git -C "${repo}" status --porcelain)" ]] || fct_fail 'release wrote into tracked files' + printf 'GREEN release receipt: correct README, Economy, and NOT_FOUND, exit 0\n' } -# fct_test_release_url renders the template source $1 for a module path with a -# major version suffix, such as /v2. Go adds that suffix to the module path, not -# to the repository URL, so the clone commands of README.md omit it. +# A Go module's /v2 suffix is not part of its published repository URL. fct_test_release_url() { local template_src="${1}" scratch="${2}" copier shift 2 copier=("$@") "${copier[@]}" copy --quiet --vcs-ref v0.0.1 --defaults --data domain=probe --data module=example.com/ncly-probe/v2 "${template_src}" "${scratch}/ncly-probe-v2" - fct_has "${scratch}/ncly-probe-v2/README.md" "git clone --branch TAG https://example.com/ncly-probe ncly-probe" + fct_has "${scratch}/ncly-probe-v2/README.md" 'git clone --branch TAG https://example.com/ncly-probe ncly-probe' if grep -qF 'example.com/ncly-probe/v2' "${scratch}/ncly-probe-v2/README.md"; then - fct_fail "README.md gives the module path example.com/ncly-probe/v2 as a repository URL" + fct_fail 'README.md gives the module path example.com/ncly-probe/v2 as a repository URL' fi } diff --git a/sdk/extensionrepo/program.go b/sdk/extensionrepo/program.go index 726f7a1d..599b7a14 100644 --- a/sdk/extensionrepo/program.go +++ b/sdk/extensionrepo/program.go @@ -19,6 +19,9 @@ func Main(e extension.Extension) { } func run(e extension.Extension, args []string) int { + if len(args) > 0 && args[0] == "release" { + return report("release", release(e, args[1:])) + } if len(args) > 0 && args[0] == "test" { return Test(args[1:]) } @@ -38,7 +41,7 @@ func run(e extension.Extension, args []string) int { return report("export", extension.Export(e, args[1])) } if len(args) != 1 || (args[0] != "install" && args[0] != "uninstall") { - fmt.Fprintln(os.Stderr, "usage: export install | uninstall | signoff | check | contract | export | test [go test arguments] | update vX.Y.Z /absolute/repository [--continue]") + fmt.Fprintln(os.Stderr, "usage: export install | uninstall | signoff | release [fixture] | check | contract | export | test [go test arguments] | update vX.Y.Z /absolute/repository [--continue]") return 2 } action := args[0] diff --git a/sdk/extensionrepo/release.go b/sdk/extensionrepo/release.go new file mode 100644 index 00000000..66dd9e13 --- /dev/null +++ b/sdk/extensionrepo/release.go @@ -0,0 +1,129 @@ +package extensionrepo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "regexp" + "syscall" + + "github.com/pascalandy/nicely/sdk/contract" + "github.com/pascalandy/nicely/sdk/extension" +) + +func release(e extension.Extension, args []string) error { + if len(args) < 1 || len(args) > 2 || !regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`).MatchString(args[0]) { + return errors.New("usage: just release vX.Y.Z [fixture]") + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + env := releaseEnv() + root, err := releaseOutput(ctx, env, "", "git", "rev-parse", "--show-toplevel") + if err != nil { + return err + } + sha, err := releaseOutput(ctx, env, root, "git", "rev-parse", "HEAD^{commit}") + if err != nil { + return err + } + if err := releasePushed(ctx, env, root, sha); err != nil { + return err + } + signed, err := releaseSigned(sha) + if err != nil { + return err + } + manifest, err := contract.ParseManifest(e.Manifest, "") + if err != nil { + return err + } + r, err := newReleaseRun(root, args[0], signed) + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, "release: receipt in", filepath.Join(r.dir, "receipt.json")) + fixture := "" + if len(args) == 2 && args[1] != "" { + fixture, err = filepath.Abs(args[1]) + if err != nil { + return err + } + } + err = r.publish(ctx, env, root, manifest, fixture) + if err != nil { + r.Failure = err.Error() + } + if writeErr := r.write(); writeErr != nil { + return errors.Join(err, writeErr) + } + if err != nil { + return err + } + fmt.Println("Released", r.Tag, r.SHA) + return nil +} + +func (r *releaseReceipt) publish(ctx context.Context, env []string, root string, m contract.Manifest, fixture string) error { + acceptance := []string{"just", "acceptance"} + if fixture != "" { + acceptance = append(acceptance, fixture) + } + if err := r.require(ctx, env, root, "acceptance", acceptance...); err != nil { + return err + } + // Acceptance cannot change the source that the receipt signed. + if err := releasePushed(ctx, env, root, r.SHA); err != nil { + return err + } + r.hosted(ctx, env, root) + message := filepath.Join(r.dir, "signoff.json") + data, err := json.MarshalIndent(r.Signoff, "", " ") + if err != nil { + return err + } + // Keep T9's JSON bytes unchanged in the tag, separate from the release receipt. + if err := os.WriteFile(message, append(data, '\n'), 0o600); err != nil { + return err + } + if err := r.require(ctx, env, root, "tag", "git", "-c", "tag.gpgSign=false", "tag", "--annotate", r.Tag, r.SHA, "--file", message); err != nil { + return err + } + ref := "refs/tags/" + r.Tag + if err := r.require(ctx, env, root, "push", "git", "push", "origin", ref+":"+ref); err != nil { + return err + } + r.Pushed = true + if err := r.write(); err != nil { + return err + } + return r.publishedInstall(ctx, env, root, m, fixture) +} + +func releasePushed(ctx context.Context, env []string, root, sha string) error { + head, err := releaseOutput(ctx, env, root, "git", "rev-parse", "HEAD^{commit}") + if err != nil { + return err + } + status, err := releaseOutput(ctx, env, root, "git", "status", "--porcelain") + if err != nil { + return err + } + if status != "" || head != sha { + return errors.New("release needs an unchanged, clean HEAD; commit the intended files and run just signoff") + } + if _, err := releaseOutput(ctx, env, root, "git", "fetch", "--no-tags", "origin", "HEAD"); err != nil { + return err + } + pushed, err := releaseOutput(ctx, env, root, "git", "rev-parse", "FETCH_HEAD^{commit}") + if err != nil { + return err + } + if pushed != sha { + return errors.New("release SHA is not the pushed default branch of origin; merge and push the signed SHA first") + } + return nil +} diff --git a/sdk/extensionrepo/release_command.go b/sdk/extensionrepo/release_command.go new file mode 100644 index 00000000..1e56ada5 --- /dev/null +++ b/sdk/extensionrepo/release_command.go @@ -0,0 +1,40 @@ +package extensionrepo + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// releaseProgram resolves PATH from the child's environment, before exec.Command reads the caller's PATH. +func releaseProgram(env []string, name string) (string, error) { + if strings.ContainsRune(name, filepath.Separator) { + return name, nil + } + for _, entry := range env { + search, ok := strings.CutPrefix(entry, "PATH=") + if !ok { + continue + } + for _, folder := range filepath.SplitList(search) { + candidate := filepath.Join(folder, name) + if !filepath.IsAbs(candidate) { + continue + } + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + } + return "", fmt.Errorf("%s is not on the command's PATH", name) +} + +func releaseOutput(ctx context.Context, env []string, dir, name string, args ...string) (string, error) { + program, err := releaseProgram(env, name) + if err != nil { + return "", err + } + return signoffOutput(ctx, env, dir, program, args...) +} diff --git a/sdk/extensionrepo/release_economy.go b/sdk/extensionrepo/release_economy.go new file mode 100644 index 00000000..1e0ca9b2 --- /dev/null +++ b/sdk/extensionrepo/release_economy.go @@ -0,0 +1,130 @@ +package extensionrepo + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/pascalandy/nicely/sdk/contract" + "github.com/rogpeppe/go-internal/txtar" +) + +// economy measures declared examples. Acceptance owns proof on the extension's real input. +func (r *releaseReceipt) economy(ctx context.Context, env []string, clone string, m contract.Manifest, fixture string) error { + archive, err := txtar.ParseFile(filepath.Join(clone, "testdata", "examples.txtar")) + if os.IsNotExist(err) { + archive, err = &txtar.Archive{}, nil + } + if err != nil { + return err + } + type sample struct { + command []string + budget int + files []txtar.File + input string + } + samples := []sample{ + {[]string{"ncly", "skill", "view", "nicely"}, 2500, nil, ""}, + {[]string{"ncly", "skill", "view", m.Slug()}, 1600, nil, ""}, + {[]string{"ncly", "describe"}, 2500, nil, ""}, + {append([]string{"ncly", "describe"}, m.Root()...), 4500, nil, ""}, + {[]string{"ncly", "doctor", m.Slug()}, 500, nil, ""}, + } + for _, c := range m.Commands { + if !m.Runs(c) { + continue + } + var command []string + for _, example := range c.Examples { + call, err := releaseExample(example) + if err == nil && len(call) > len(c.Path) && slices.Equal(call[1:1+len(c.Path)], c.Path) { + command = call + break + } + } + if command == nil { + r.Reservations = append(r.Reservations, "Economy: no runnable manifest example for "+strings.Join(c.Path, " ")) + continue + } + input := "" + for _, arg := range command[1+len(c.Path):] { + if slices.ContainsFunc(archive.Files, func(file txtar.File) bool { return filepath.Clean(file.Name) == filepath.Clean(arg) }) { + input = arg + break + } + } + if c.Modes.DryRun { + samples = append(samples, sample{releaseFlags(command, "--dry-run"), 1000, archive.Files, input}) + } + samples = append(samples, sample{command, 1000, archive.Files, input}) + } + for i, sample := range samples { + for _, jsonOutput := range []bool{false, true} { + command, format := sample.command, "text" + if jsonOutput { + command, format = releaseFlags(command, "--json"), "json" + } + row, err := r.economyMeasure(ctx, env, fmt.Sprintf("economy-%d-%s", i, format), fixture, sample.input, sample.files, command...) + r.Economy = append(r.Economy, row) + if err != nil { + return err + } + if row.Exit != 0 { + r.Reservations = append(r.Reservations, fmt.Sprintf("Economy: %s exited %d", strings.Join(command, " "), row.Exit)) + } + if row.StdoutBytes > sample.budget || row.StderrBytes > 500 { + r.Reservations = append(r.Reservations, fmt.Sprintf("Economy: %s exceeds a budget: stdout %d/%d bytes, stderr %d/500 bytes", strings.Join(command, " "), row.StdoutBytes, sample.budget, row.StderrBytes)) + } + } + } + return nil +} + +// economyMeasure gives each invocation fresh inputs and output paths. +func (r *releaseReceipt) economyMeasure(ctx context.Context, env []string, name, fixture, input string, files []txtar.File, args ...string) (releaseMeasurement, error) { + work, err := os.MkdirTemp(r.dir, "economy-work-") + if err != nil { + return releaseMeasurement{}, err + } + defer func() { _ = os.RemoveAll(work) }() + // Extract only data files. Archive setup and fake programs never enter the command environment. + for _, file := range files { + if !filepath.IsLocal(file.Name) { + return releaseMeasurement{}, fmt.Errorf("examples.txtar file is outside its working folder: %s", file.Name) + } + path := filepath.Join(work, file.Name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return releaseMeasurement{}, err + } + if err := os.WriteFile(path, file.Data, 0o600); err != nil { + return releaseMeasurement{}, err + } + } + if fixture != "" && input != "" { + if err := releaseCopyFixture(fixture, filepath.Join(work, input)); err != nil { + return releaseMeasurement{}, err + } + } + return r.measure(ctx, env, work, name, args...) +} + +// releaseCopyFixture streams media without loading the whole fixture into memory. +func releaseCopyFixture(from, to string) error { + input, err := os.Open(from) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + output, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + _, err = io.Copy(output, input) + return errors.Join(err, output.Close()) +} diff --git a/sdk/extensionrepo/release_example.go b/sdk/extensionrepo/release_example.go new file mode 100644 index 00000000..8e368917 --- /dev/null +++ b/sdk/extensionrepo/release_example.go @@ -0,0 +1,76 @@ +package extensionrepo + +import ( + "fmt" + "slices" + "strings" +) + +// releaseExample reads literal shell words, without expansion or a shell process. +func releaseExample(line string) ([]string, error) { + var words []string + var word strings.Builder + var quote byte + started := false + for i := 0; i < len(line); i++ { + ch := line[i] + switch { + case ch == '\\' && quote != '\'': + i++ + if i == len(line) { + return nil, fmt.Errorf("unfinished escape") + } + word.WriteByte(line[i]) + started = true + case quote != 0: + if ch == quote { + quote = 0 + } else { + word.WriteByte(ch) + } + case ch == '\'' || ch == '"': + quote, started = ch, true + case strings.ContainsRune(" \t\r\n", rune(ch)): + if started { + words = append(words, word.String()) + word.Reset() + started = false + } + case strings.ContainsRune(";|&<>`", rune(ch)): + return nil, fmt.Errorf("shell operators are unsupported") + default: + word.WriteByte(ch) + started = true + } + } + if quote != 0 { + return nil, fmt.Errorf("unfinished quote") + } + if started { + words = append(words, word.String()) + } + if len(words) < 2 || words[0] != "ncly" { + return nil, fmt.Errorf("want a command starting with ncly") + } + // Select each output and execution mode ourselves, before the argument delimiter. + var call []string + options := true + for _, word := range words { + if word == "--" { + options = false + } + if options && (word == "--json" || strings.HasPrefix(word, "--json=") || word == "--dry-run" || strings.HasPrefix(word, "--dry-run=")) { + continue + } + call = append(call, word) + } + return call, nil +} + +func releaseFlags(call []string, flags ...string) []string { + at := slices.Index(call, "--") + if at < 0 { + at = len(call) + } + return slices.Insert(slices.Clone(call), at, flags...) +} diff --git a/sdk/extensionrepo/release_install.go b/sdk/extensionrepo/release_install.go new file mode 100644 index 00000000..1bcbc527 --- /dev/null +++ b/sdk/extensionrepo/release_install.go @@ -0,0 +1,177 @@ +package extensionrepo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "github.com/pascalandy/nicely/sdk/contract" +) + +func releaseEnv() []string { + env := signoffEnv() + // Git URL rewrites also let the published install run against a local test remote. + for _, key := range []string{"GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM"} { + if value, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+value) + } + } + return env +} + +func releaseInstallBlock(readme []byte) (string, error) { + install, fenced := false, false + var lines []string + for _, line := range strings.Split(string(readme), "\n") { + if strings.HasPrefix(line, "## ") { + if install { + break + } + install = line == "## Install" + } + if !install { + continue + } + if fenced { + if line == "```" { + if len(lines) == 0 { + break + } + return strings.Join(lines, "\n"), nil + } + lines = append(lines, line) + } else if line == "```sh" { + fenced = true + } + } + return "", errors.New("README.md needs a fenced sh block under ## Install") +} + +func (r *releaseReceipt) publishedInstall(ctx context.Context, env []string, root string, m contract.Manifest, fixture string) error { + readme, err := releaseOutput(ctx, env, root, "git", "show", r.SHA+":README.md") + if err != nil { + return err + } + block, err := releaseInstallBlock([]byte(readme)) + if err != nil { + return err + } + work := filepath.Join(r.dir, "install") + if err := os.MkdirAll(filepath.Join(work, "bin"), 0o700); err != nil { + return err + } + defer func() { _ = os.RemoveAll(work) }() + // Build the pinned host before HOME and the XDG cache move. + if err := r.require(ctx, env, root, "build-host", "go", "build", "-o", filepath.Join(work, "bin", "ncly"), "github.com/pascalandy/nicely/cmd/ncly"); err != nil { + return err + } + isolated, err := releaseScratchEnv(ctx, env, root, work, m) + if err != nil { + return err + } + cloneFile := filepath.Join(work, "clone-path") + script := strings.ReplaceAll(block, "TAG", r.Tag) + "\npwd -P > '" + strings.ReplaceAll(cloneFile, "'", "'\\''") + "'\n" + if err := r.require(ctx, isolated, work, "readme-install", "sh", "-e", "-c", script); err != nil { + return err + } + data, err := os.ReadFile(cloneFile) + if err != nil { + return err + } + clone := strings.TrimSpace(string(data)) + rel, err := filepath.Rel(work, clone) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return errors.New("README install must finish in its scratch clone") + } + r.ClonedSHA, err = releaseOutput(ctx, isolated, clone, "git", "rev-parse", "HEAD^{commit}") + if err != nil { + return err + } + if r.ClonedSHA != r.SHA { + return fmt.Errorf("README install cloned SHA %s, want tagged SHA %s; correct the install block", r.ClonedSHA, r.SHA) + } + if err := r.require(ctx, isolated, clone, "describe", append([]string{"ncly", "describe"}, append(m.Root(), "--json")...)...); err != nil { + return err + } + if err := r.economy(ctx, isolated, clone, m, fixture); err != nil { + return err + } + if err := r.require(ctx, isolated, clone, "uninstall", "just", "uninstall"); err != nil { + return err + } + row, err := r.measure(ctx, isolated, clone, "not-found", append([]string{"ncly", "describe"}, append(m.Root(), "--json")...)...) + r.Steps = append(r.Steps, row) + if err != nil { + return err + } + stdout, err := os.ReadFile(filepath.Join(r.dir, row.Stdout)) + if err != nil { + return err + } + stderr, err := os.ReadFile(filepath.Join(r.dir, row.Stderr)) + if err != nil { + return err + } + var outcome contract.Outcome + if row.Exit != 2 || len(stdout) != 0 || json.Unmarshal(stderr, &outcome) != nil || len(outcome.Errors) != 1 || outcome.Errors[0].Code != contract.NotFound { + return errors.New("after uninstall, ncly describe must exit 2 with NOT_FOUND; read not-found.stderr") + } + return nil +} + +func releaseScratchEnv(ctx context.Context, env []string, root, work string, m contract.Manifest) ([]string, error) { + bin := filepath.Join(work, "bin") + path := []string{bin} + // Resolve version-manager shims while they can still read the caller's configuration. + for i, name := range append([]string{"go", "just", "git", "sh"}, m.Programs...) { + if _, err := os.Lstat(filepath.Join(bin, name)); err == nil { + continue + } + found, err := releaseOutput(ctx, env, root, "mise", "which", name) + if err != nil { + found, err = exec.LookPath(name) + } + if err != nil { + if i >= 4 { + continue + } + return nil, err + } + if err := os.Symlink(found, filepath.Join(bin, name)); err != nil { + return nil, err + } + } + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if !filepath.IsAbs(dir) { + continue + } + if found, _ := filepath.Glob(filepath.Join(dir, "ncly-*")); len(found) == 0 { + path = append(path, dir) + } + } + isolated := []string{ + "HOME=" + work, "PATH=" + strings.Join(slices.Compact(path), string(filepath.ListSeparator)), + "TMPDIR=" + work, "XDG_CONFIG_HOME=" + filepath.Join(work, "config"), "XDG_DATA_HOME=" + filepath.Join(work, "data"), + "XDG_STATE_HOME=" + filepath.Join(work, "state"), "XDG_CACHE_HOME=" + filepath.Join(work, "cache"), + "DBUS_SESSION_BUS_ADDRESS=disabled:", "NCLY_NO_INPUT=1", "GOWORK=off", "GOFLAGS= ", "LC_ALL=C", + } + for _, key := range []string{"GOCACHE", "GOMODCACHE", "GOPATH"} { + value, err := releaseOutput(ctx, env, root, "go", "env", key) + if err != nil { + return nil, err + } + isolated = append(isolated, key+"="+value) + } + for _, key := range []string{"GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM"} { + if value, ok := os.LookupEnv(key); ok { + isolated = append(isolated, key+"="+value) + } + } + return isolated, nil +} diff --git a/sdk/extensionrepo/release_receipt.go b/sdk/extensionrepo/release_receipt.go new file mode 100644 index 00000000..1f37b248 --- /dev/null +++ b/sdk/extensionrepo/release_receipt.go @@ -0,0 +1,194 @@ +package extensionrepo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/pascalandy/nicely/sdk/config" +) + +type releaseMeasurement struct { + Name string `json:"name"` + Command []string `json:"command"` + Exit int `json:"exit"` + ElapsedMS int64 `json:"elapsed_ms"` + StdoutBytes int `json:"stdout_bytes"` + StderrBytes int `json:"stderr_bytes"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type releaseReceipt struct { + dir string + Tag string `json:"tag"` + SHA string `json:"sha"` + OS string `json:"os"` + Signoff signoffReceipt `json:"signoff"` + Pushed bool `json:"pushed"` + ClonedSHA string `json:"cloned_sha"` + Steps []releaseMeasurement `json:"steps"` + Economy []releaseMeasurement `json:"economy"` + HostedChecks json.RawMessage `json:"hosted_checks,omitempty"` + Notes []string `json:"notes"` + Reservations []string `json:"reservations"` + Failure string `json:"failure,omitempty"` +} + +func releaseSigned(sha string) (signoffReceipt, error) { + base := filepath.Join(config.Locate(os.Getenv("HOME"), os.LookupEnv).Cache, "signoff") + paths, err := filepath.Glob(filepath.Join(base, "*", "receipt.json")) + if err != nil { + return signoffReceipt{}, err + } + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var r signoffReceipt + if json.Unmarshal(data, &r) != nil || r.SHA != sha || len(r.Checks) == 0 || r.Signoff == nil || r.Signoff.Exit != 0 || len(r.Signoff.Command) == 0 { + continue + } + var raw struct { + Checks []json.RawMessage `json:"checks"` + Signoff json.RawMessage `json:"signoff"` + } + if json.Unmarshal(data, &raw) != nil { + continue + } + valid := releaseZeroExit(raw.Signoff) + for _, check := range raw.Checks { + valid = valid && releaseZeroExit(check) + } + for _, check := range r.Checks { + valid = valid && check.Exit == 0 && len(check.Command) != 0 + } + if valid { + return r, nil + } + } + return signoffReceipt{}, fmt.Errorf("no successful receipt for %s; run just signoff for this pushed SHA", sha) +} + +// Missing and null exit fields cannot stand for successful checks. +func releaseZeroExit(data json.RawMessage) bool { + var check map[string]json.RawMessage + if json.Unmarshal(data, &check) != nil { + return false + } + return bytes.Equal(bytes.TrimSpace(check["exit"]), []byte("0")) +} + +func newReleaseRun(root, tag string, signed signoffReceipt) (*releaseReceipt, error) { + cache := config.Locate(os.Getenv("HOME"), os.LookupEnv).Cache + if !filepath.IsAbs(cache) { + return nil, errors.New("release needs an absolute cache folder outside the repository") + } + base := filepath.Join(cache, "release") + if err := os.MkdirAll(base, 0o700); err != nil { + return nil, err + } + base, err := filepath.EvalSymlinks(base) + if err != nil { + return nil, err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + rel, err := filepath.Rel(root, base) + if err != nil { + return nil, err + } + if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, errors.New("cache folder is inside the repository; set XDG_CACHE_HOME outside it") + } + dir, err := os.MkdirTemp(base, time.Now().UTC().Format("20060102T150405Z")+"-"+signed.SHA+"-") + if err != nil { + return nil, err + } + return &releaseReceipt{ + dir: dir, Tag: tag, SHA: signed.SHA, OS: runtime.GOOS, Signoff: signed, + Steps: []releaseMeasurement{}, Economy: []releaseMeasurement{}, Notes: []string{}, Reservations: []string{}, + }, nil +} + +func (r *releaseReceipt) write() error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(r.dir, "receipt.json"), append(data, '\n'), 0o600) +} + +// measure keeps streams in separate files, without copying command output into the receipt. +func (r *releaseReceipt) measure(ctx context.Context, env []string, dir, name string, args ...string) (releaseMeasurement, error) { + row := releaseMeasurement{Name: name, Command: args, Stdout: name + ".stdout", Stderr: name + ".stderr"} + stdout, err := os.OpenFile(filepath.Join(r.dir, row.Stdout), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return row, err + } + defer func() { _ = stdout.Close() }() + stderr, err := os.OpenFile(filepath.Join(r.dir, row.Stderr), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return row, err + } + defer func() { _ = stderr.Close() }() + program, err := releaseProgram(env, args[0]) + if err != nil { + row.Exit = 1 + return row, err + } + cmd := signoffCommand(ctx, env, dir, program, args[1:]...) + cmd.Stdout, cmd.Stderr = stdout, stderr + start := time.Now() + runErr := cmd.Run() + row.ElapsedMS, row.Exit = time.Since(start).Milliseconds(), signoffExit(runErr) + out, outErr := stdout.Stat() + errout, errErr := stderr.Stat() + if err := errors.Join(outErr, errErr); err != nil { + return row, err + } + row.StdoutBytes, row.StderrBytes = int(out.Size()), int(errout.Size()) + var exited *exec.ExitError + if runErr != nil && !errors.As(runErr, &exited) { + return row, runErr + } + return row, nil +} + +func (r *releaseReceipt) require(ctx context.Context, env []string, dir, name string, args ...string) error { + row, err := r.measure(ctx, env, dir, name, args...) + r.Steps = append(r.Steps, row) + if err != nil { + return err + } + if row.Exit != 0 { + return fmt.Errorf("%s exited %d; read %s", name, row.Exit, filepath.Join(r.dir, row.Stderr)) + } + return r.write() +} + +func (r *releaseReceipt) hosted(ctx context.Context, env []string, root string) { + if _, err := exec.LookPath("gh"); err != nil { + r.Notes = append(r.Notes, "gh is unavailable; hosted check runs were not read") + return + } + row, err := r.measure(ctx, env, root, "hosted-checks", "gh", "api", "--paginate", "--slurp", "repos/{owner}/{repo}/commits/"+r.SHA+"/check-runs") + r.Steps = append(r.Steps, row) + data, readErr := os.ReadFile(filepath.Join(r.dir, row.Stdout)) + if err != nil || readErr != nil || row.Exit != 0 || !json.Valid(bytes.TrimSpace(data)) { + r.Notes = append(r.Notes, "hosted check runs could not be read; see hosted-checks.stderr") + return + } + r.HostedChecks = json.RawMessage(data) +} diff --git a/template/AGENTS.md.jinja b/template/AGENTS.md.jinja index 032b3864..8a948d87 100644 --- a/template/AGENTS.md.jinja +++ b/template/AGENTS.md.jinja @@ -85,6 +85,10 @@ A release is one Git tag `vX.Y.Z` of Semantic Versioning, such as `v0.1.0`. 3. Run `just signoff` for that pushed commit 4. Make sure that "Done when" holds for this commit 5. Merge that exact signed SHA into the default branch -6. Tag that commit, such as with `git tag v0.1.0` -7. Push the tag, such as with `git push origin v0.1.0` -8. Run the commands of `README.md` for the new tag in a new folder. `ncly describe {{ root }} --json` must describe `{{ root }}` +6. Run `just release vX.Y.Z`, with an optional fixture path +7. Read the printed receipt for acceptance, the published install, Economy reservations, hosted checks, and uninstall + +`just release` requires the successful signoff receipt for the pushed SHA. +It runs acceptance, pushes an annotated tag with that receipt, and executes the README install block in scratch folders. +It requires the tagged SHA and proves uninstall with `NOT_FOUND`. +A failed published install keeps the tag. Read the receipt before choosing a new version. diff --git a/template/justfile b/template/justfile index 9304e225..f3d38391 100644 --- a/template/justfile +++ b/template/justfile @@ -15,6 +15,10 @@ check: signoff: go run ./internal/export signoff +# Release the signed, pushed SHA and prove the published README install in scratch folders +release version fixture="": + go run ./internal/export release {{ quote(version) }} {{ if fixture == "" { "" } else { quote(join(invocation_directory(), fixture)) } }} + # Build the extension and install its sidecars before its executable install: go run ./internal/export install