Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/release-gate.yaml
Original file line number Diff line number Diff line change
@@ -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}"
6 changes: 6 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
71 changes: 71 additions & 0 deletions docs/development/release.md
Original file line number Diff line number Diff line change
@@ -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.
145 changes: 145 additions & 0 deletions hack/release/test-verify-eligibility.sh
Original file line number Diff line number Diff line change
@@ -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"
66 changes: 66 additions & 0 deletions hack/release/verify-eligibility.sh
Original file line number Diff line number Diff line change
@@ -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 <full-commit-sha> <version>}"
VERSION="${2:?usage: verify-eligibility.sh <full-commit-sha> <version>}"
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})."
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading