From 1bccade3661b19c50c11a7165565e4b43055feaa Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Fri, 13 Nov 2026 11:51:32 +0100 Subject: [PATCH] Release: create the GitHub release from a tag workflow New .github/workflows/release.yml, run on a pushed tag v* and by hand as a dry run (inputs sha, version). The verify job (contents, actions and checks read only) refuses unless the tag is vX.Y.Z and equals the plugin.json version at the commit, the commit is on main, it has a successful check "required" from a pull_request run of ci.yml (scripts/release-green.sh, ported from obsbot4linux) and CHANGELOG.md has a non-empty section for the version (scripts/release-notes.sh). Only the release job has contents: write; it runs after verify, on tag pushes only, and the CHANGELOG section is the release notes. The dry run prints the release and creates nothing. README gets a short "Releasing" section; CHANGELOG an Unreleased line. Fixes #138 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 80 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 ++ README.md | 11 +++++ scripts/release-green.sh | 37 ++++++++++++++++ scripts/release-notes.sh | 30 +++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/release-green.sh create mode 100755 scripts/release-notes.sh 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"