diff --git a/.github/workflows/release-gate.yaml b/.github/workflows/release-gate.yaml new file mode 100644 index 0000000..3ae6ab9 --- /dev/null +++ b/.github/workflows/release-gate.yaml @@ -0,0 +1,82 @@ +# Read-only pre-tag gate. Never creates tags or publishes artifacts. +# Loads verifier scripts from the default branch (not a candidate SHA tree). +name: Release gate + +on: + workflow_dispatch: + inputs: + sha: + description: "Full commit SHA (empty = current protected main HEAD)" + required: false + type: string + default: "" + version: + description: "Release version without leading v (must match a CHANGELOG.md section)" + required: true + type: string + +permissions: + contents: read + checks: read + pull-requests: read + +jobs: + gate: + name: release gate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout trusted gate scripts from default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + hack/release + sparse-checkout-cone-mode: true + persist-credentials: false + path: trusted-gate + + - name: Resolve protected-main commit and prove eligibility + id: prove + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + bash trusted-gate/hack/release/test-verify-eligibility.sh + version="${INPUT_VERSION}" + [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { + echo "version must be SemVer-like without a leading v (e.g. 0.2.0)" >&2 + exit 1 + } + if [[ -n "${INPUT_SHA}" ]]; then + [[ "${INPUT_SHA}" =~ ^[0-9a-f]{40}$ ]] || { + echo "sha must be a full lowercase commit SHA" >&2 + exit 1 + } + sha="${INPUT_SHA}" + else + sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)" + fi + gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}" --silent + bash trusted-gate/hack/release/verify-eligibility.sh "${sha}" "${version}" + { + echo "sha=${sha}" + echo "version=${version}" + } >> "${GITHUB_OUTPUT}" + + - name: Record immutable result + env: + RELEASE_SHA: ${{ steps.prove.outputs.sha }} + RELEASE_VERSION: ${{ steps.prove.outputs.version }} + run: | + { + echo "## Release gate passed" + echo + echo "Reviewed protected-main commit \`${RELEASE_SHA}\` for version \`${RELEASE_VERSION}\`." + echo "Required exact-SHA checks succeeded and CHANGELOG.md has a matching section." + echo "This workflow did not tag or publish anything." + echo + echo "Tag only that SHA as \`v${RELEASE_VERSION}\`, then push the tag to trigger Release." + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/docs/development.md b/docs/development.md index 65d1f15..f6b71de 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,6 +43,12 @@ UPDATE_GOLDEN=1 go test ./internal/format/ ./internal/render/ Review the golden diff like code before committing. Do not regenerate goldens to “make CI green” without understanding the behaviour change. +## Releases + +Before tagging `vX.Y.Z`, run the read-only [release gate](development/release.md). It proves +the candidate SHA is on protected `main`, required checks are green, and `CHANGELOG.md` has a +matching section — without creating a tag or publishing artifacts. + ## Commits Conventional commits with an ASCII gitmoji shortcode (no Unicode emoji, no AI co-author trailers). diff --git a/docs/development/release.md b/docs/development/release.md new file mode 100644 index 0000000..9c941c6 --- /dev/null +++ b/docs/development/release.md @@ -0,0 +1,71 @@ +# Releasing kollect-render + +Maintainers publish versions by tagging a **proven** commit on protected `main`. +The publishing workflow (`.github/workflows/release.yaml`) is tag-driven and unchanged +by the pre-tag gate — eligibility is a separate, read-only check you run **before** +creating `vX.Y.Z`. + +## Pre-tag release gate + +[`.github/workflows/release-gate.yaml`](https://github.com/PlatformRelay/kollect-render/blob/main/.github/workflows/release-gate.yaml) +is `workflow_dispatch` only. Permissions are read-only (`contents`, `checks`, +`pull-requests`) — the job never creates a tag, never logs into a registry, and never +uploads release assets. + +Given a full 40-character lowercase commit SHA (or empty to use current `main` HEAD) +and a SemVer-like `version` **without** a leading `v`, the gate proves: + +1. The SHA is reachable from protected `main` (ancestor or identical). +2. Required exact-SHA checks on that commit are `completed/success` (`check`, `changelog`). +3. `CHANGELOG.md` **at that SHA** contains a Keep a Changelog heading for the version + (`## [X.Y.Z]` …). + +Short SHAs, commits not on `main`, red/missing checks, and a missing changelog section +each fail with a message naming the failed precondition. + +Verifier scripts always load from the **default branch** sparse checkout so a candidate +commit cannot supply its own gate. + +### Run locally (fixture tests) + +```bash +bash hack/release/test-verify-eligibility.sh +``` + +These tests mock `gh` — no live GitHub API and no credentials beyond what you already +use for local development. + +### Run the gate on GitHub + +```bash +RELEASE_SHA="$(git rev-parse HEAD)" # must be full 40-hex, on main +VERSION="0.2.0" # must match CHANGELOG.md section + +gh workflow run release-gate.yaml \ + -f sha="${RELEASE_SHA}" \ + -f version="${VERSION}" +gh run list --workflow release-gate.yaml --limit 1 +``` + +Omit `sha` to evaluate current protected `main` HEAD: + +```bash +gh workflow run release-gate.yaml -f version="${VERSION}" +``` + +When the gate is green, tag **only that SHA** and push: + +```bash +git tag "v${VERSION}" "${RELEASE_SHA}" +git push origin "v${VERSION}" +``` + +That push triggers Release (GoReleaser). The gate itself does not publish. + +## Checklist before tagging + +1. `main` is green for the candidate SHA (`check` + `changelog`). +2. `CHANGELOG.md` on that SHA already has `## [X.Y.Z]` (run `task changelog:write` / + commit as needed). +3. Release gate passed for `${RELEASE_SHA}` + `${VERSION}`. +4. Tag `vX.Y.Z` at that exact SHA — never an arbitrary branch tip. diff --git a/hack/release/test-verify-eligibility.sh b/hack/release/test-verify-eligibility.sh new file mode 100755 index 0000000..8a269f4 --- /dev/null +++ b/hack/release/test-verify-eligibility.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Deterministic fixture tests for verify-eligibility.sh (no live GitHub API). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SUBJECT="${ROOT}/hack/release/verify-eligibility.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +SHA="1111111111111111111111111111111111111111" +MAIN="2222222222222222222222222222222222222222" +VERSION="0.2.0" + +REQUIRED_NAMES='check changelog' + +cat >"${TMP}/gh" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +args="$*" +# Mirror gh ≥2.x: --slurp cannot be combined with --jq/--template. +if [[ "${args}" == *--slurp* && ( "${args}" == *--jq* || "${args}" == *--template* ) ]]; then + echo 'the `--slurp` option is not supported with `--jq` or `--template`' >&2 + exit 1 +fi +case "${args}" in + *"/commits/main"*) + printf '%s\n' "${MOCK_MAIN}" + ;; + *"/compare/"*) + printf '%s\n' "${MOCK_COMPARE}" + ;; + *"/check-runs"*) + cat "${MOCK_CHECKS}" + ;; + *"/contents/CHANGELOG.md"*) + cat "${MOCK_CHANGELOG}" + ;; + *) + echo "unexpected gh invocation: ${args}" >&2 + exit 70 + ;; +esac +MOCK +chmod +x "${TMP}/gh" + +write_green_checks() { + # Shape matches `gh api --paginate --slurp` (array of pages). + jq -n \ + --arg names "${REQUIRED_NAMES}" \ + --arg sha "${SHA}" \ + '[{check_runs: ($names | split(" ") | to_entries | map({ + id: (.key + 1), + name: .value, + status: "completed", + conclusion: "success", + head_sha: $sha + }))}]' >"${TMP}/checks.json" +} + +write_changelog() { + local body="$1" + # Raw contents Accept header returns plain text (script uses -H Accept: raw). + printf '%s\n' "${body}" >"${TMP}/changelog.txt" +} + +run_case() { + PATH="${TMP}:${PATH}" \ + GITHUB_REPOSITORY=PlatformRelay/kollect-render \ + MOCK_MAIN="${MAIN}" \ + MOCK_COMPARE="${MOCK_COMPARE}" \ + MOCK_CHECKS="${TMP}/checks.json" \ + MOCK_CHANGELOG="${TMP}/changelog.txt" \ + bash "${SUBJECT}" "${SHA}" "${VERSION}" >"${TMP}/out" 2>"${TMP}/err" +} + +fail_if_passes() { + local label="$1" + if run_case; then + echo "${label} unexpectedly passed" >&2 + exit 1 + fi +} + +write_green_checks +write_changelog "## [Unreleased] + +## [${VERSION}] - 2026-08-04 + +### Features + +- Something releasable +" + +if [[ ! -x "${SUBJECT}" && ! -f "${SUBJECT}" ]]; then + echo "missing subject ${SUBJECT}" >&2 + exit 1 +fi + +if PATH="${TMP}:${PATH}" GITHUB_REPOSITORY=PlatformRelay/kollect-render \ + bash "${SUBJECT}" bad-sha "${VERSION}" >"${TMP}/out" 2>"${TMP}/err"; then + echo "invalid SHA unexpectedly passed" >&2 + exit 1 +fi +grep -q 'full 40-character lowercase commit SHA' "${TMP}/err" + +MOCK_COMPARE=diverged +fail_if_passes "non-main SHA" +grep -q 'not reachable from protected main' "${TMP}/err" + +MOCK_COMPARE=ahead +jq '.[0].check_runs |= map(select(.name != "check"))' "${TMP}/checks.json" >"${TMP}/missing.json" +mv "${TMP}/missing.json" "${TMP}/checks.json" +fail_if_passes "missing check" +grep -q 'required exact-SHA check check: missing' "${TMP}/err" + +write_green_checks +jq '(.[0].check_runs[] | select(.name == "check")).conclusion = "failure"' "${TMP}/checks.json" >"${TMP}/red.json" +mv "${TMP}/red.json" "${TMP}/checks.json" +fail_if_passes "red check" +grep -q 'required exact-SHA check check: completed/failure' "${TMP}/err" + +write_green_checks +write_changelog "## [Unreleased] + +### Features + +- No release section yet +" +fail_if_passes "missing changelog" +grep -q "CHANGELOG.md has no section for ${VERSION}" "${TMP}/err" + +write_green_checks +write_changelog "## [Unreleased] + +## [${VERSION}] - 2026-08-04 + +### Features + +- Something releasable +" +run_case +grep -q "Release eligibility passed for PlatformRelay/kollect-render@${SHA}" "${TMP}/out" +grep -q "CHANGELOG section \\[${VERSION}\\]" "${TMP}/out" + +echo "verify-eligibility tests: ok" diff --git a/hack/release/verify-eligibility.sh b/hack/release/verify-eligibility.sh new file mode 100755 index 0000000..27ca34f --- /dev/null +++ b/hack/release/verify-eligibility.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Fail closed unless a release commit is on protected main with exact-SHA checks +# and a Keep-a-Changelog section for the release version. Read-only — never tags +# or publishes. Intended to run from a trusted checkout (default branch). +set -euo pipefail + +SHA="${1:?usage: verify-eligibility.sh }" +VERSION="${2:?usage: verify-eligibility.sh }" +REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY required}" +DEFAULT_BRANCH="${RELEASE_DEFAULT_BRANCH:-main}" + +if [[ ! "${SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: release SHA must be a full 40-character lowercase commit SHA" >&2 + exit 1 +fi + +if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "error: version must be SemVer-like (e.g. 0.2.0 or 0.2.0-rc.1), without a leading v" >&2 + exit 1 +fi + +required_checks=( + check + changelog +) + +main_sha="$(gh api "repos/${REPO}/commits/${DEFAULT_BRANCH}" --jq .sha)" +comparison="$(gh api "repos/${REPO}/compare/${SHA}...${main_sha}" --jq .status)" +if [[ "${comparison}" != "ahead" && "${comparison}" != "identical" ]]; then + echo "error: release SHA ${SHA} is not reachable from protected main (${main_sha})" >&2 + exit 1 +fi + +# gh ≥2.x rejects combining --slurp with --jq; flatten pages in a separate jq pass. +checks_pages="$(gh api --paginate --slurp "repos/${REPO}/commits/${SHA}/check-runs?per_page=100")" +checks="$(jq '[.[].check_runs[] | select(.head_sha == "'"${SHA}"'") | {id, name, status, conclusion, head_sha}]' \ + <<<"${checks_pages}")" + +failed=0 +for name in "${required_checks[@]}"; do + result="$(jq -r --arg name "${name}" ' + (map(select(.name == $name)) | sort_by(.id) | last) as $run | + if $run == null then "missing" + else ($run.status + "/" + ($run.conclusion // "")) + end + ' <<<"${checks}")" + if [[ "${result}" != "completed/success" ]]; then + echo "error: required exact-SHA check ${name}: ${result}" >&2 + failed=1 + else + echo "ok: ${name}" + fi +done +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +changelog="$(gh api \ + -H "Accept: application/vnd.github.raw" \ + "repos/${REPO}/contents/CHANGELOG.md?ref=${SHA}")" +if ! grep -Eq "^## \\[${VERSION}\\]( |$)" <<<"${changelog}"; then + echo "error: CHANGELOG.md has no section for ${VERSION} at ${SHA}" >&2 + exit 1 +fi + +echo "Release eligibility passed for ${REPO}@${SHA} (CHANGELOG section [${VERSION}], main ${main_sha})." diff --git a/mkdocs.yml b/mkdocs.yml index 11c7276..67f0b49 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Output formats: formats.md - Inventory schema: schema.md - Development: development.md + - Releasing: development/release.md - Security: security.md markdown_extensions: