Files
unsupervised-scheduler/.gitea/workflows/release.yml
T
KydoimosandClaude Opus 5 9c6b4415cd
CI / Coding Standards (pull_request) Successful in 18s
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.1) (pull_request) Successful in 31s
CI / Static Analysis (pull_request) Successful in 31s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Tests (PHP 8.5) (pull_request) Successful in 56s
CI / Tests (PHP 8.3) (pull_request) Successful in 56s
CI / Build Plugin Zip (pull_request) Skipped
Sign the automated version bump commit in CI
main now requires signed commits, and Gitea refuses to merge a pull request
carrying an unsigned one, so the post-release bump PR could not be merged
without disabling the protection first.

The runner now signs that commit with a dedicated release-bot SSH key held as
an organisation Actions secret, trusted by the instance through
[repository.signing] TRUSTED_SSH_KEYS. The key Gitea signs merge commits with
lives on the server and no runner can reach it.

The step fails fast when the secret is missing, unparseable or passphrase
protected, and the commit is re-read before pushing so an unsigned one is
caught here rather than days later at merge time.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:17:28 -03:00

220 lines
9.8 KiB
YAML

name: Release
# Fires when a v* tag is pushed — including tags created through Gitea's
# "New Release" UI. Builds the distributable plugin zip and attaches it to
# the release for that tag (creating the release if only a bare tag was
# pushed). The attached zip is what UpdateChecker serves to WordPress
# sites as the update package.
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and Publish Release
runs-on: ubuntu-latest
# The shared CI image carries composer, curl, jq and the GNU coreutils
# the steps below shell out to. See docs/ci.md.
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps:
- uses: actions/checkout@v4
# A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version
id: meta
run: |
tag_version="${GITHUB_REF_NAME#v}"
header_version="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
if [ "$tag_version" != "$header_version" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match plugin header Version: ${header_version}" >&2
exit 1
fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
# COMPOSER_HOME is /composer in the image, so that is where the
# download cache lives.
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run tests
run: composer test
- name: Build plugin zip
run: composer build
# Pull the section for this version out of CHANGELOG.md so it can become
# the release body. Matches "## [x.y.z]" and prints every line up to the
# next "## " heading. A missing section is a warning, not a failure — the
# release still publishes with empty notes.
- name: Extract changelog notes
run: |
version="${{ steps.meta.outputs.version }}"
awk -v ver="$version" '
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed -e '/./,$!d' | tac | sed -e '/./,$!d' | tac > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::warning::No CHANGELOG.md section found for version ${version}"
fi
- name: Publish release with zip asset
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
version="${{ steps.meta.outputs.version }}"
zip="dist/unsupervised-schedular-${version}.zip"
# Pre-release versions (1.2.3-rc.1) are flagged so Gitea's
# /releases/latest endpoint — and therefore the update checker —
# skips them.
prerelease=false
case "$version" in *-*) prerelease=true ;; esac
# Reuse the release if the tag was created via Gitea's release UI.
release_id="$(curl -sS -H "Authorization: token ${TOKEN}" \
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
if [ -z "$release_id" ]; then
body="$(jq -Rs --arg tag "${GITHUB_REF_NAME}" --argjson pre "${prerelease}" \
'{tag_name:$tag, name:$tag, prerelease:$pre, body:.}' release-notes.md)"
release_id="$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${body}" | jq -r '.id')"
else
# Release pre-created via the UI: fill in the notes from the changelog.
body="$(jq -Rs '{body:.}' release-notes.md)"
curl -fsS -X PATCH "${api}/releases/${release_id}" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${body}" > /dev/null
fi
echo "Attaching ${zip} to release ${release_id}"
curl -fsS -X POST \
"${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${zip}" > /dev/null
# After a stable release, move main forward: bump the plugin to the next patch
# version and open a fresh changelog section for it, via a PR. Skipped for
# pre-releases (tags containing a hyphen, e.g. v1.2.0-rc.1) — those don't
# advance the mainline version.
bump-version:
name: Open next-version bump PR
needs: release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Compute next patch version
id: next
run: |
current="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
base="${current%%-*}" # drop any pre-release suffix
major="${base%%.*}"
rest="${base#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
next="${major}.${minor}.$((patch + 1))"
echo "next=${next}" >> "$GITHUB_OUTPUT"
- name: Apply version bump and open changelog section
run: |
next="${{ steps.next.outputs.next }}"
# Plugin header + USC_VERSION constant must stay in lockstep.
sed -i -E "s/^([[:space:]]*\*?[[:space:]]*Version:[[:space:]]*).*/\1${next}/" unsupervised-schedular.php
sed -i -E "s/(define\('USC_VERSION', ')[^']+('\);)/\1${next}\2/" unsupervised-schedular.php
# Insert an empty section for the new version above the current top one
# (the first "## [" heading in the file).
awk -v ver="$next" '
!done && /^## \[/ { print "## [" ver "]"; print ""; done = 1 }
{ print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# main requires signed commits, and Gitea refuses to merge a pull request
# that carries an unsigned one. The key Gitea signs merge commits with
# lives on the server and is not reachable from a runner, so the bump
# commit is signed here with a dedicated release-bot key that the instance
# trusts via TRUSTED_SSH_KEYS. Generating that key, trusting it and storing
# the secret is documented in docs/ci.md.
- name: Configure signing as Release Bot
env:
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
run: |
if [ -z "${SIGNING_KEY}" ]; then
echo "RELEASE_BOT_SIGNING_KEY is not set - the bump commit would be unsigned and unmergeable." >&2
exit 1
fi
if ! command -v ssh-keygen > /dev/null; then
echo "ssh-keygen is missing from the runner image; git cannot make SSH signatures without it." >&2
exit 1
fi
# The secret holds an OpenSSH private key ("-----BEGIN OPENSSH PRIVATE
# KEY-----"). git signs by shelling out to ssh-keygen, which wants that
# key on disk next to the .pub it is pointed at, readable only by us,
# and rejects it unless the final newline survived the round trip.
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
install -m 700 -d "${keydir}"
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
chmod 600 "${keydir}/key"
# Doubles as a format check: a truncated or re-wrapped key fails here,
# with a clearer cause than "gpg failed to sign the data" later on.
if ! ssh-keygen -y -f "${keydir}/key" < /dev/null > "${keydir}/key.pub"; then
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
exit 1
fi
# No Gitea account backs this address; TRUSTED_SSH_KEYS verifies the
# signature without an account lookup, so it is a label, not an identity.
git config user.name 'Release Bot'
git config user.email '[email protected]'
# Named gpg.format for historical reasons; "ssh" is what switches git
# over to signing with the SSH key above rather than a GPG key.
git config gpg.format ssh
git config user.signingkey "${keydir}/key.pub"
git config commit.gpgsign true
- name: Open pull request
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
next="${{ steps.next.outputs.next }}"
branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
git checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section"
# A commit that came out unsigned would otherwise go unnoticed until
# someone tried to merge the PR, so fail here instead.
if ! git cat-file commit HEAD | grep -q '^gpgsig'; then
echo "Bump commit is unsigned; refusing to push it." >&2
exit 1
fi
git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg head "${branch}" --arg title "Bump version to ${next}" \
'{head:$head, base:"main", title:$title, body:"Automated post-release bump to the next patch version. Record changes for this version under its changelog heading."}')" \
> /dev/null