feat: derive the release bump from conventional commit subjects

The bump was decided by `[major]` / `[minor]` markers alone, so a range of
ordinary `fix:` commits and a range of `feat:` commits released the same way,
and the version number said nothing about what changed.

Read the intent from the subject instead, keeping the explicit markers as
overrides for the cases a subject cannot carry:

  `feat` as its own word   minor   0.1.0 -> 0.2.0
  `!:` or BREAKING         major   0.1.0 -> 1.0.0
  anything else            patch   0.1.0 -> 0.1.1

`feat` has to be a word, so "Fix the feature flag leak" is a patch rather than
a minor. Only the subject line is read; the body is not.

Adds `npm run release:notes`, which prints the notes a push would publish, and
AGENTS.md, which states the rules where an agent will read them.
This commit is contained in:
dsh-slopo agent committed 2026-09-26 06:38:08 +01:00
1 parent e0813bcbea
commit ffefc42e01
4 files changed
+160 -24

No files matched your search

+48 -14
View File
@@ -3,20 +3,27 @@
# 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:
# 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 markers in the commits
# bump <range> patch | minor | major, from the commit subjects
# 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
# 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
# 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.
#
# 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
@@ -46,7 +53,7 @@ subjects_in() {
# Whether any commit in the range asks for no release.
wants_skip() {
subjects_in "$1" | grep -qF "$MARKER_SKIP"
subjects_in "${1:-}" | grep -qF "$MARKER_SKIP"
}
# The commit range this release covers: `<last tag>..HEAD`, or empty when the
@@ -79,13 +86,30 @@ should_release() {
echo yes
}
# The bump implied by the commits in a range.
# 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"; then
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"; then
elif printf '%s\n' "$subjects" | grep -qF "$MARKER_MINOR" || is_feature "$subjects"; then
echo minor
else
echo patch
@@ -162,6 +186,12 @@ notes() {
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
@@ -215,7 +245,7 @@ changelog_prepend() {
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.'
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"
@@ -233,6 +263,8 @@ case "${1:-}" in
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:-}" ;;
@@ -248,6 +280,8 @@ usage: release.sh <command>
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
+35
View File
@@ -100,6 +100,41 @@ commit "Rewrite everything [major]"
check "major outranks minor" "major" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
cd /;
echo
echo "the subject decides the bump"
new_repo 0.1.0 >/dev/null
tag v0.1.0
check "prose is a patch" "patch" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
commit "feat: add the recall index"
check "feat is a minor" "minor" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
check "so 0.1 -> 0.2" "0.2.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.0..HEAD")")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat(scope): add the scope filter"
check "a scoped feat counts" "minor" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "Fix the feature flag leak"
check "feat inside a word is not a feature" "no" "$(bash "$RELEASE" is-feature "Fix the feature flag leak")"
check "so it stays a patch" "patch" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
cd /;
new_repo 0.1.0 >/dev/null
tag v0.1.0
commit "feat!: move the index to sqlite"
check "a bang is breaking" "yes" "$(bash "$RELEASE" is-breaking "feat!: move the index to sqlite")"
check "so it is a major" "major" "$(bash "$RELEASE" bump "v0.1.0..HEAD")"
check "and 0.1 -> 1.0" "1.0.0" "$(bash "$RELEASE" next-version "$(bash "$RELEASE" bump "v0.1.0..HEAD")")"
NOTES="$(bash "$RELEASE" notes "v0.1.0..HEAD")"
check_contains "breaking gets a section" "### Breaking" "$NOTES"
check_contains "and is listed there" "- move the index to sqlite" "$NOTES"
check_absent "not under Added" "### Added" "$NOTES"
cd /;
echo
echo "skip release"
new_repo 0.1.0 >/dev/null