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
80 changes: 80 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
37 changes: 37 additions & 0 deletions scripts/release-green.sh
Original file line number Diff line number Diff line change
@@ -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/(?<id>[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)"
30 changes: 30 additions & 0 deletions scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -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"