diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..759863f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +# Pushing a tag vX.Y.Z creates the GitHub release. Before it does, verify checks that the tag +# equals the version in plugin.json, that the tagged commit is on main, that it was green as a +# pull request (scripts/release-green.sh) and that CHANGELOG.md has a section for the version. +# If a check fails, no release is created. The release notes are that CHANGELOG section. +# workflow_dispatch is a dry run of the same checks on `sha` (default: the head of the ref) and +# `version`: it prints the release and creates nothing. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + sha: + description: "Dry run: the commit to check (default: the head of the ref). Creates nothing." + required: false + type: string + version: + description: "Dry run: the version to check, X.Y.Z without v." + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + actions: read # the run behind the required check + checks: read # the required check run + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + # The scripts run from this checkout and are given the commit and the tag to check: a dry + # run's commit can be older than the scripts. + - env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ inputs.sha || github.sha }} + TAG: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref_name }} + run: | + sha=$(git rev-parse --verify --quiet "$SHA^{commit}") || { + echo "release refused: $SHA is not a commit" >&2 + exit 1 + } + git merge-base --is-ancestor "$sha" origin/main || { + echo "release refused: $sha is not on main" >&2 + exit 1 + } + scripts/release-green.sh "$sha" + scripts/release-notes.sh "$sha" "$TAG" > notes.md + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ]; then + echo "Dry run: would create release $TAG on $sha with these notes, nothing created:" + else + echo "Checks passed: release $TAG on $sha with these notes:" + fi + cat notes.md + + # Tags only, and only after verify passed. The one job with write access. + release: + if: github.event_name == 'push' + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - env: + GH_TOKEN: ${{ github.token }} + run: | + scripts/release-notes.sh "$GITHUB_SHA" "$GITHUB_REF_NAME" > notes.md + gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" \ + --notes-file notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d999a8..9b9e65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ the change) are credited at the end of their line as `(#N, thanks @handle)`. - A GitHub Actions workflow runs `scripts/check-qml.sh` and shellcheck on every pull request into `main` (#132). +- A tag `vX.Y.Z` now creates the GitHub release by workflow. It first checks that the tag equals + the `plugin.json` version, that the commit is on `main` and was green as a pull request, and that + `CHANGELOG.md` has a section for the version; the release notes are that section (#138). ### Changed diff --git a/README.md b/README.md index d8e9500..1f97912 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,17 @@ dms restart The code was written with Claude Code (AI) and reviewed and tested on a Tiny 2 Lite. +## Releasing + +1. Land a commit "Release X.Y.Z" through a pull request: it sets `version` in `plugin.json` and + turns the `## [Unreleased]` entries of `CHANGELOG.md` into a `## [X.Y.Z] - date` section. +2. Push the signed tag `vX.Y.Z` on that commit. The Release workflow creates the GitHub release, + with that CHANGELOG section as notes, after it has checked that the tag equals the `plugin.json` + version, that the commit is on `main` and was green as a pull request, and that the section + exists. If a check fails, no release is created. +3. Dry run: run the Release workflow by hand with a commit and a version; it runs the checks and + prints the release it would create. + ## License GPL-3.0-or-later, see [LICENSE](LICENSE). diff --git a/scripts/release-green.sh b/scripts/release-green.sh new file mode 100755 index 0000000..1eb320b --- /dev/null +++ b/scripts/release-green.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Release guard: the commit to release must already be green as a pull request. Fails, with the +# reason, unless SHA has a check run "required" from a pull_request run of .github/workflows/ci.yml +# and the newest such check run completed with success. Main moves only by a fast-forward push of a +# green pull request head, so the tagged commit is that head and carries the check. +# A "required" job of another workflow or event (an old push run) does not count. +# +# Usage: release-green.sh SHA env: GITHUB_REPOSITORY, GH_TOKEN (checks: read, actions: read) +# release.yml runs it on a tag push and, as a dry run that publishes nothing, on workflow_dispatch. +set -euo pipefail +sha=${1:?usage: release-green.sh SHA} +repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set} +fail() { + echo "release refused: $*" >&2 + exit 1 +} + +# Newest first. +checks=$(gh api --paginate "repos/$repo/commits/$sha/check-runs?check_name=required&filter=all&per_page=100" \ + --jq '.check_runs[]' | jq -s 'sort_by(.started_at) | reverse') +found= +for row in $(jq -r '.[] | select(.html_url | test("/actions/runs/[0-9]+")) | "\(.html_url | capture("/actions/runs/(?[0-9]+)").id):\(.status):\(.conclusion // "none")"' <<<"$checks"); do + id=${row%%:*} + rest=${row#*:} + status=${rest%%:*} + conclusion=${rest#*:} + [ "$(gh api "repos/$repo/actions/runs/$id" --jq '"\(.path) \(.event)"')" = ".github/workflows/ci.yml pull_request" ] || continue + found=$id + [ "$status" = completed ] || fail "$sha: the check \"required\" of ci.yml (run $id) is $status, not finished" + [ "$conclusion" = success ] || fail "$sha: the check \"required\" of ci.yml (run $id) concluded $conclusion" + echo "required: success (ci.yml pull_request run $id)" + break +done +[ -n "$found" ] || fail "$sha has no check \"required\" from a ci.yml pull_request run: it was never green as a pull request" +echo "$sha is green: required (ci.yml)" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..82ed236 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Release guard and release notes: prints the CHANGELOG.md section of a release. Fails, with the +# reason, unless TAG is vX.Y.Z, plugin.json has that version at SHA, and CHANGELOG.md at SHA has a +# non-empty "## [X.Y.Z]" (or "## X.Y.Z") section. The section text, without surrounding blank +# lines, is the release notes. +# +# Usage: release-notes.sh SHA TAG (reads plugin.json and CHANGELOG.md from the commit SHA) +set -euo pipefail +sha=${1:?usage: release-notes.sh SHA TAG} +tag=${2:?usage: release-notes.sh SHA TAG} +fail() { + echo "release refused: $*" >&2 + exit 1 +} + +[[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "$tag is not a tag of the form vX.Y.Z" +ver=${tag#v} +git rev-parse --verify --quiet "$sha^{commit}" >/dev/null || fail "$sha is not a commit" + +have=$(git show "$sha:plugin.json" | jq -r '.version // empty') || fail "cannot read plugin.json at $sha" +[ "$have" = "$ver" ] || fail "plugin.json has version \"$have\" at $sha, the tag is $tag" + +notes=$(git show "$sha:CHANGELOG.md" | awk -v v="$ver" ' + /^## / { on = ($2 == v || $2 == "[" v "]"); next } + on +' | sed -e '/./,$!d' | sed -e ':a' -e '/^\n*$/{$d;N;ba' -e '}') +[ -n "${notes//[[:space:]]/}" ] || fail "CHANGELOG.md has no non-empty section for $ver at $sha" +printf '%s\n' "$notes"