ci: publish a release on every push to master
Release / release (push) Successful in 5s

Tags are what make `dsh plugin update` work at all: a plugin installed as a
bare `git+https://…/repo.git` is pinned to a commit in the profile's lockfile,
so with no tags there is nothing for an update to move to. This adds the
pipeline that creates them.

`.gitea/workflows/release.yml` runs on a push to master and on manual dispatch:
it reads the commit subjects since the last tag, derives the bump, prepends a
`CHANGELOG.md` section, commits and tags `vX.Y.Z`, and publishes the Gitea
release with those subjects as the body. The range with no tags yet is the
whole history, so the first release describes everything it contains.

The logic lives in `.gitea/scripts/release.sh` rather than in the YAML so it can
run — and be checked — without a runner: `npm run test:release` exercises it
(43 checks), `npm run release:plan` says whether HEAD would release, and
`npm run release:notes` prints the notes it would publish. `AGENTS.md` states
the subject conventions a contributor has to follow, because the subject is both
the version decision and the sentence a user reads in the release notes.

One property worth knowing: with no tags yet the version released is the one
`package.json` already declares — no bump is applied to a version that has never
been published — so the first tag is `v0.1.0`, and the bump rules take effect
from the second release onward.
This commit is contained in:
NotExpectedYet committed 2026-09-26 06:42:46 +01:00
1 parent 040c69b57d
commit 7261d46ee2
7 files changed
+724 -1

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
#!/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 a handful of subcommands:
#
# last-tag the most recent v* tag, or empty
# bump <range> patch | minor | major, from the commit subjects
# next-version <bump> the version to release
# notes <range> markdown release notes for the range
#
# The bump is read from the commit subjects in the range being released:
#
# a subject containing the word `feat` minor (0.1.0 -> 0.2.0)
# a `!:` subject, or BREAKING major (0.1.0 -> 1.0.0)
# anything else patch (0.1.0 -> 0.1.1)
#
# and two explicit markers override that when the subject alone is ambiguous:
#
# [major] release as a major version
# [minor] release as a minor version
# [skip release] do not release this push at all
#
# 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
}
# Whether a subject claims a feature.
#
# `feat` as its own word, so `feat:`, `feat(x):` and `feat something` all count,
# while `Fix the feature flag leak` does not.
is_feature() {
printf '%s\n' "${1:-}" | grep -Eqi '(^|[^a-z0-9_])feat([^a-z0-9_]|$)'
}
# Whether a subject claims a breaking change.
#
# Only the subject is read, so this is the conventional `!:` form — a
# `BREAKING CHANGE:` trailer in a body is invisible here. Use `[major]` for the
# cases a subject cannot carry.
is_breaking() {
printf '%s\n' "${1:-}" | grep -Eqi '^[a-z]+(\([^)]*\))?!:|BREAKING[ -]?CHANGE'
}
# The bump implied by the commits in a range. Markers win over the subject.
bump_for() {
local subjects
subjects="$(subjects_in "${1:-}")"
if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR" || is_breaking "$subjects"; then
echo major
elif printf '%s\n' "$subjects" | grep -qF "$MARKER_MINOR" || is_feature "$subjects"; 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
# Checked before the type, so `feat!:` lands under Breaking rather than
# under Added.
if is_breaking "$subject"; then
breakings="${breakings}- $(strip_prefix "$subject")"$'\n'
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, generated from the commits in each release.'
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:-}" ;;
is-feature) shift; is_feature "${1:-}" && echo yes || echo no ;;
is-breaking) shift; is_breaking "${1:-}" && echo yes || echo no ;;
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
is-feature <subject> yes when a subject claims a feature
is-breaking <subject> yes when a subject claims a breaking change
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