Release on every push to master, and make updating possible

A dsh plugin installed as a bare git URL is pinned to a commit in the profile
lockfile. With no tags in the repository there was nothing for an update to move
to, which is why updating appeared to do nothing and a reinstall was the only way
through.

Gitea action on push to master: work out the range since the last tag, decide
whether the push releases at all, bump package.json, prepend a CHANGELOG section,
commit, tag vX.Y.Z, and publish the release with those notes as the body.

The bump is a patch unless a commit says [minor] or [major]; [skip release]
publishes nothing. With no tags yet the declared version is what gets tagged, so
the first release matches the code. Notes come from commit subjects, grouped under
headings only when the range actually uses conventional prefixes.

The logic is a script rather than inline YAML so it runs without a runner:
npm run test:release covers 31 cases, including an empty range, a repository with
no tags, and a patch number already at 9. The empty-range guard is load-bearing:
the release commit is itself a push to master.

Two bugs the tests caught: an empty note section made '[ -n x ] && printf' return
non-zero, which under set -e aborted the whole release, and a bullet beginning
with '-' was read by grep as an option.

README documents the #semver: spec, which tracks releases rather than every
commit, and the two things that look like a failed update: an unfetchable remote,
and the profile restart client bundle bytes require.
This commit is contained in:
James Mackay committed 2026-09-25 19:03:50 +01:00
1 parent 5ff10eaefd
commit e0813bcbea
6 files changed
+613 -2

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
#
# Release plumbing for a dsh plugin repository.
#
# Kept as a script rather than inline YAML so it can be run and tested without a
# runner. The workflow is a thin wrapper around four subcommands:
#
# last-tag the most recent v* tag, or empty
# bump <range> patch | minor | major, from markers in the commits
# next-version <bump> the version to release
# notes <range> markdown release notes for the range
#
# Markers are read from commit subjects in the range being released:
# [major] release as a major version
# [minor] release as a minor version
# [skip release] do not release this push at all
# Anything else is a patch. Writing the intent in the commit subject means the
# decision travels with the change rather than living in a file somebody has to
# remember to update.
set -euo pipefail
MARKER_MAJOR='[major]'
MARKER_MINOR='[minor]'
MARKER_SKIP='[skip release]'
# The most recent release tag, or nothing when the repository has never released.
last_tag() {
git tag --list 'v[0-9]*' --sort=-v:refname | head -1
}
# The version currently declared in package.json (or a crate/module equivalent).
current_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1
}
# Commits in a range, newest last, one subject per line.
subjects_in() {
local range="$1"
if [ -z "$range" ]; then
git log --no-merges --format='%s'
else
git log --no-merges --format='%s' "$range"
fi
}
# Whether any commit in the range asks for no release.
wants_skip() {
subjects_in "$1" | grep -qF "$MARKER_SKIP"
}
# The commit range this release covers: `<last tag>..HEAD`, or empty when the
# repository has never released. Computed here rather than in the workflow so
# the runner, the npm script and a human all get the same answer.
release_range() {
local tag
tag="$(last_tag)"
if [ -n "$tag" ]; then
echo "${tag}..HEAD"
fi
}
# Whether this push should produce a release at all.
#
# The empty-range case is not hypothetical: the release commit this workflow
# pushes is itself a push to master, and without this guard the next run would
# find nothing to describe and publish a release saying so. `[skip release]` in
# the release commit is the second line of defence.
should_release() {
local range="$1"
if wants_skip "$range"; then
echo no
return
fi
if [ -z "$(subjects_in "$range")" ]; then
echo no
return
fi
echo yes
}
# The bump implied by the commits in a range.
bump_for() {
local subjects
subjects="$(subjects_in "$1")"
if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR"; then
echo major
elif printf '%s\n' "$subjects" | grep -qF "$MARKER_MINOR"; then
echo minor
else
echo patch
fi
}
# semver arithmetic, without pulling in a node dependency.
apply_bump() {
local version="$1" bump="$2"
local major minor patch
IFS=. read -r major minor patch <<< "${version%%-*}"
major="${major:-0}"; minor="${minor:-0}"; patch="${patch:-0}"
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
*) echo "unknown bump: $bump" >&2; return 1 ;;
esac
echo "${major}.${minor}.${patch}"
}
# The version this push should release.
#
# With no tag yet, the declared version *is* the release: tagging 0.1.0 rather
# than inventing 0.1.1 means the first release matches what the code says, and
# every later one moves forward from it.
next_version() {
local bump="$1" tag current
tag="$(last_tag)"
current="$(current_version)"
if [ -z "$tag" ]; then
echo "${current:-0.1.0}"
return
fi
apply_bump "${tag#v}" "$bump"
}
# Strip a conventional-commit prefix, keeping the sentence intact.
strip_prefix() {
printf '%s' "$1" | sed -E 's/^[a-z]+(\([^)]*\))?!?:[[:space:]]*//'
}
# The type of a conventional commit, or empty.
commit_type() {
printf '%s' "$1" | sed -nE 's/^([a-z]+)(\([^)]*\))?!?:.*/\1/p'
}
# Markdown release notes for a range.
#
# Grouped only when the range actually uses conventional prefixes — otherwise a
# flat list. Inventing headings for commits that do not carry types would put a
# label on a change that never claimed one.
notes() {
local range="$1" total typed subject type
local subjects
subjects="$(subjects_in "$range" | grep -vF "$MARKER_SKIP" || true)"
if [ -z "$subjects" ]; then
echo "_No changes._"
return
fi
total="$(printf '%s\n' "$subjects" | grep -c . || true)"
typed="$(printf '%s\n' "$subjects" | while IFS= read -r subject; do
[ -n "$(commit_type "$subject")" ] && echo x
done | grep -c . || true)"
# Half or more carrying a type is enough to be a convention rather than a
# coincidence, and a couple of strays can still be filed under "Other".
if [ "$total" -gt 0 ] && [ "$typed" -ge $(( (total + 1) / 2 )) ]; then
local breakings fixes features docs others
breakings=''; fixes=''; features=''; docs=''; others=''
while IFS= read -r subject; do
if [ -z "$subject" ]; then
continue
fi
type="$(commit_type "$subject")"
subject="$(strip_prefix "$subject")"
case "$type" in
feat) features="${features}- ${subject}"$'\n' ;;
fix|perf|revert) fixes="${fixes}- ${subject}"$'\n' ;;
docs) docs="${docs}- ${subject}"$'\n' ;;
*) others="${others}- ${subject}"$'\n' ;;
esac
done <<< "$subjects"
# Written as explicit `if`s rather than `[ -n "$x" ] && printf`: an empty
# section makes that test fail, the `&&` list returns non-zero, and under
# `set -e` the whole release aborts. A repository with no breaking changes
# is the normal case, not an error.
if [ -n "$breakings" ]; then printf '### Breaking\n\n%s\n' "$breakings"; fi
if [ -n "$features" ]; then printf '### Added\n\n%s\n' "$features"; fi
if [ -n "$fixes" ]; then printf '### Fixed\n\n%s\n' "$fixes"; fi
if [ -n "$docs" ]; then printf '### Documented\n\n%s\n' "$docs"; fi
if [ -n "$others" ]; then printf '### Other\n\n%s\n' "$others"; fi
return
fi
printf '%s\n' "$subjects" | while IFS= read -r subject; do
if [ -n "$subject" ]; then
printf -- '- %s\n' "$(strip_prefix "$subject")"
fi
done
}
# Write a version into package.json, preserving the rest of the file byte for byte.
set_version() {
local version="$1" tmp
tmp="$(mktemp)"
sed -E "s/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]*(\")/\1${version}\2/" package.json > "$tmp"
mv "$tmp" package.json
}
# Prepend one release section to CHANGELOG.md.
#
# The notes file is passed by path rather than by argument because release notes
# are multi-line markdown, and a shell argument would mangle them.
changelog_prepend() {
local version="$1" date="$2" notes_file="$3" tmp
tmp="$(mktemp)"
{
# The title and preamble stay at the top; only the sections move.
if [ -f CHANGELOG.md ]; then
# Buffer the preamble and drop its trailing blank lines, so repeated
# releases do not accumulate whitespace where the sections meet.
awk '/^## / { exit } { print }' CHANGELOG.md \
| awk 'BEGIN { n = 0 } { line[n++] = $0 }
END { while (n > 0 && line[n-1] == "") n--
for (i = 0; i < n; i++) printf "%s%s", (i ? "\n" : ""), line[i] }'
else
printf '# Changelog\n\nEvery release, newest first.'
fi
printf '\n\n## v%s — %s\n\n' "$version" "$date"
cat "$notes_file"
printf '\n'
if [ -f CHANGELOG.md ]; then
awk 'BEGIN { seen = 0 } /^## / { seen = 1 } seen == 1 { print }' CHANGELOG.md
fi
} > "$tmp"
mv "$tmp" CHANGELOG.md
}
case "${1:-}" in
last-tag) shift; last_tag ;;
wants-skip) shift; wants_skip "${1:-}" && echo yes || echo no ;;
range) release_range ;;
should-release) shift; should_release "${1:-}" ;;
bump) shift; bump_for "${1:-}" ;;
next-version) shift; next_version "${1:-patch}" ;;
apply-bump) shift; apply_bump "$1" "$2" ;;
notes) shift; notes "${1:-}" ;;
current-version) current_version ;;
set-version) shift; set_version "$1" ;;
changelog-prepend) shift; changelog_prepend "$1" "$2" "$3" ;;
*)
cat >&2 <<'USAGE'
usage: release.sh <command>
last-tag most recent v* tag, or empty
wants-skip <range> yes when the range asks for no release
range the commit range this release covers
should-release <range> yes when this push should publish a release
bump <range> patch | minor | major
next-version <bump> the version to release
apply-bump <version> <bump> semver arithmetic
notes <range> markdown release notes
current-version version declared in package.json
set-version <version> update package.json in place
changelog-prepend <version> <date> <notes-file>
USAGE
exit 2
;;
esac
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
#
# Tests for the release script, in throwaway git repositories.
#
# These paths only occur at moments you cannot stage by hand — a repository with
# no tags, a range that asks for no release, a version whose patch number is
# already two digits — and getting them wrong publishes a wrong version or no
# release at all. Run with: bash .gitea/scripts/release.test.sh
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELEASE="$HERE/release.sh"
PASS=0
FAIL=0
# A fresh repository with one commit, optionally tagged.
new_repo() {
local dir
dir="$(mktemp -d)"
cd "$dir"
git init -q
git config user.email test@example.com
git config user.name Test
printf '{\n "name": "thing",\n "version": "%s"\n}\n' "${1:-0.1.0}" > package.json
git add -A
git commit -qm "Scaffold the thing"
echo "$dir"
}
commit() {
git commit -q --allow-empty -m "$1"
}
tag() {
git tag "$1"
}
check() {
local label="$1" expected="$2" actual="$3"
if [ "$expected" = "$actual" ]; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
else
FAIL=$((FAIL + 1))
printf ' FAIL %s\n expected: %s\n actual: %s\n' "$label" "$expected" "$actual"
fi
}
check_contains() {
local label="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
else
FAIL=$((FAIL + 1))
printf ' FAIL %s\n missing: %s\n in: %s\n' "$label" "$needle" "$haystack"
fi
}
check_absent() {
local label="$1" needle="$2" haystack="$3"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
FAIL=$((FAIL + 1))
printf ' FAIL %s\n should not contain: %s\n' "$label" "$needle"
else
PASS=$((PASS + 1))
printf ' ok %s\n' "$label"
fi
}
echo "semver arithmetic"
check "patch" "0.1.1" "$(bash "$RELEASE" apply-bump 0.1.0 patch)"
check "minor" "0.2.0" "$(bash "$RELEASE" apply-bump 0.1.5 minor)"
check "major" "1.0.0" "$(bash "$RELEASE" apply-bump 0.9.9 major)"
check "patch to 10" "0.1.10" "$(bash "$RELEASE" apply-bump 0.1.9 patch)"
check "minor to 10" "0.10.0" "$(bash "$RELEASE" apply-bump 0.9.3 minor)"
check "prerelease" "0.1.1" "$(bash "$RELEASE" apply-bump 0.1.0-rc.1 patch)"
echo
echo "first release with no tags"
new_repo 0.2.0 >/dev/null
check "version is the declared one" "0.2.0" "$(bash "$RELEASE" next-version patch)"
check "no tag yet" "" "$(bash "$RELEASE" last-tag)"
cd /;
echo
echo "subsequent releases move forward from the tag"
new_repo 0.1.0 >/dev/null
tag v0.1.3
check "patch" "0.1.4" "$(bash "$RELEASE" next-version patch)"
check "minor" "0.2.0" "$(bash "$RELEASE" next-version minor)"
check "major" "1.0.0" "$(bash "$RELEASE" next-version major)"
commit "Fix the thing"
check "bump defaults to patch" "patch" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
commit "Add a capability [minor]"
check "marker raises the bump" "minor" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
check "and the version follows" "0.2.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.3..HEAD")")"
commit "Rewrite everything [major]"
check "major outranks minor" "major" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
cd /;
echo
echo "skip release"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Update the README [skip release]"
check "skip is detected" "yes" "$(bash "$RELEASE" wants-skip "v0.1.0..HEAD")"
check_absent "skipped work is not in the notes" "Update the README" "$(bash "$RELEASE" notes "v0.1.0..HEAD")"
commit "Fix a real bug"
check "a later real change does not un-skip" "yes" "$(bash "$RELEASE" wants-skip "v0.1.0..HEAD")"
check "so nothing is released" "no" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
echo "notes only cover the range"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Fix the leaked handle"
commit "Add the status card"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "includes the new work" "Add the status card" "$NOTES"
check_contains "includes the fix" "Fix the leaked handle" "$NOTES"
check_absent "excludes released work" "Scaffold the thing" "$NOTES"
check "a range with real work releases" "yes" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
echo "conventional commits are grouped, plain ones are not"
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat: add the release workflow"
commit "fix: stop the runner tagging twice"
commit "docs: explain the markers"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "grouped heading" "### Added" "$NOTES"
check_contains "grouped fix" "### Fixed" "$NOTES"
check_contains "prefix stripped" "- add the release workflow" "$NOTES"
check_absent "no raw prefix" "feat: add" "$NOTES"
cd /;
echo
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Background only the warm-up, and use the framework's own parameter name"
commit "Make the settings card behave like the other cards"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_absent "prose commits get no invented headings" "### Added" "$NOTES"
check_contains "and stay as bullets" "- Make the settings card behave" "$NOTES"
cd /;
echo
echo "an empty range says so rather than printing nothing"
new_repo 0.1.0 >/dev/null
tag v0.1.0
check "empty range" "_No changes._" "$(bash "$RELEASE" notes "v0.1.0..HEAD")"
# The release commit is itself a push to master; without this the next run would
# publish a release describing nothing.
check "an empty range releases nothing" "no" "$(bash "$RELEASE" should-release "v0.1.0..HEAD")"
cd /;
echo
printf 'passed %d, failed %d\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]
+121
View File
@@ -0,0 +1,121 @@
# Publish a release every time master moves.
#
# What this is for, beyond tidiness: a dsh plugin installed as a bare
# `git+https://…/repo.git` is resolved to a commit and pinned in the profile's
# lockfile. With no tags, there is nothing for `dsh plugin update` to move to —
# which is why updating appeared to do nothing and a reinstall was the only way
# through. Tags are what make an update possible; this workflow is what creates
# them.
#
# The work lives in `.gitea/scripts/release.sh` so it can be run and tested
# without a runner (`bash .gitea/scripts/release.test.sh`).
name: Release
on:
push:
branches: [master]
# Manual runs, for the first release or to re-cut one after a failed push.
workflow_dispatch:
# One release at a time. Two pushes in quick succession would otherwise race to
# tag the same commit and one of them would lose.
concurrency:
group: release-master
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
env:
BRANCH: master
steps:
- uses: actions/checkout@v4
with:
# Tags are the whole point, so the clone must carry them.
fetch-depth: 0
ref: master
- name: Decide whether this push releases
id: decide
run: |
set -euo pipefail
tag="$(bash .gitea/scripts/release.sh last-tag)"
range="$(bash .gitea/scripts/release.sh range)"
echo "range=${range}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
verdict="$(bash .gitea/scripts/release.sh should-release "$range")"
echo "release=${verdict}" >> "$GITHUB_OUTPUT"
echo "last tag: ${tag:-none} range: ${range:-everything} release: ${verdict}"
- name: Work out the version and the notes
if: steps.decide.outputs.release == 'yes'
id: version
run: |
set -euo pipefail
bump="$(bash .gitea/scripts/release.sh bump "${{ steps.decide.outputs.range }}")"
version="$(bash .gitea/scripts/release.sh next-version "$bump")"
bash .gitea/scripts/release.sh notes "${{ steps.decide.outputs.range }}" > /tmp/notes.md
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "bump=${bump}" >> "$GITHUB_OUTPUT"
echo "releasing v${version} (${bump})"
cat /tmp/notes.md
- name: Bump, changelog, commit and tag
if: steps.decide.outputs.release == 'yes'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
git config user.name "release bot"
git config user.email "release@notexpectedyet.com"
bash .gitea/scripts/release.sh set-version "$VERSION"
bash .gitea/scripts/release.sh changelog-prepend "$VERSION" "$(date -u +%Y-%m-%d)" /tmp/notes.md
git add package.json CHANGELOG.md
# The marker is the second line of defence against releasing this very
# commit: the script also refuses an empty range.
git commit -m "Release v${VERSION} [skip release]"
git tag "v${VERSION}"
git push origin "HEAD:${BRANCH}"
git push origin "v${VERSION}"
- name: Publish the release
if: steps.decide.outputs.release == 'yes'
env:
VERSION: ${{ steps.version.outputs.version }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# The body is built by an encoder rather than by string interpolation:
# release notes are multi-line markdown and quoting them into JSON by
# hand is a reliable way to publish a malformed release.
python3 - <<'PY' > /tmp/release.json
import json, os
with open('/tmp/notes.md', encoding='utf-8') as handle:
body = handle.read()
version = os.environ['VERSION']
print(json.dumps({
'tag_name': 'v' + version,
'name': 'v' + version,
'body': body,
'draft': False,
'prerelease': False,
}))
PY
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/release.json)"
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
echo "release creation failed (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
fi
echo "published v${VERSION}"