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:
1 parent
e0813bcbea
commit
ffefc42e01
4 files changed
+160
-24
No files matched your search
+48
-14
@@ -3,20 +3,27 @@
|
|||||||
# Release plumbing for a dsh plugin repository.
|
# Release plumbing for a dsh plugin repository.
|
||||||
#
|
#
|
||||||
# Kept as a script rather than inline YAML so it can be run and tested without a
|
# 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
|
# 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
|
# next-version <bump> the version to release
|
||||||
# notes <range> markdown release notes for the range
|
# notes <range> markdown release notes for the range
|
||||||
#
|
#
|
||||||
# Markers are read from commit subjects in the range being released:
|
# The bump is read from the commit subjects in the range being released:
|
||||||
# [major] release as a major version
|
#
|
||||||
# [minor] release as a minor version
|
# 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
|
# [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
|
# Writing the intent in the commit subject means the decision travels with the
|
||||||
# remember to update.
|
# change rather than living in a file somebody has to remember to update.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ subjects_in() {
|
|||||||
|
|
||||||
# Whether any commit in the range asks for no release.
|
# Whether any commit in the range asks for no release.
|
||||||
wants_skip() {
|
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
|
# The commit range this release covers: `<last tag>..HEAD`, or empty when the
|
||||||
@@ -79,13 +86,30 @@ should_release() {
|
|||||||
echo yes
|
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() {
|
bump_for() {
|
||||||
local subjects
|
local subjects
|
||||||
subjects="$(subjects_in "$1")"
|
subjects="$(subjects_in "${1:-}")"
|
||||||
if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR"; then
|
if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR" || is_breaking "$subjects"; then
|
||||||
echo major
|
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
|
echo minor
|
||||||
else
|
else
|
||||||
echo patch
|
echo patch
|
||||||
@@ -162,6 +186,12 @@ notes() {
|
|||||||
if [ -z "$subject" ]; then
|
if [ -z "$subject" ]; then
|
||||||
continue
|
continue
|
||||||
fi
|
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")"
|
type="$(commit_type "$subject")"
|
||||||
subject="$(strip_prefix "$subject")"
|
subject="$(strip_prefix "$subject")"
|
||||||
case "$type" in
|
case "$type" in
|
||||||
@@ -215,7 +245,7 @@ changelog_prepend() {
|
|||||||
END { while (n > 0 && line[n-1] == "") n--
|
END { while (n > 0 && line[n-1] == "") n--
|
||||||
for (i = 0; i < n; i++) printf "%s%s", (i ? "\n" : ""), line[i] }'
|
for (i = 0; i < n; i++) printf "%s%s", (i ? "\n" : ""), line[i] }'
|
||||||
else
|
else
|
||||||
printf '# Changelog\n\nEvery release, newest first.'
|
printf '# Changelog\n\nEvery release, newest first, generated from the commits in each release.'
|
||||||
fi
|
fi
|
||||||
printf '\n\n## v%s — %s\n\n' "$version" "$date"
|
printf '\n\n## v%s — %s\n\n' "$version" "$date"
|
||||||
cat "$notes_file"
|
cat "$notes_file"
|
||||||
@@ -233,6 +263,8 @@ case "${1:-}" in
|
|||||||
range) release_range ;;
|
range) release_range ;;
|
||||||
should-release) shift; should_release "${1:-}" ;;
|
should-release) shift; should_release "${1:-}" ;;
|
||||||
bump) shift; bump_for "${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}" ;;
|
next-version) shift; next_version "${1:-patch}" ;;
|
||||||
apply-bump) shift; apply_bump "$1" "$2" ;;
|
apply-bump) shift; apply_bump "$1" "$2" ;;
|
||||||
notes) shift; notes "${1:-}" ;;
|
notes) shift; notes "${1:-}" ;;
|
||||||
@@ -248,6 +280,8 @@ usage: release.sh <command>
|
|||||||
range the commit range this release covers
|
range the commit range this release covers
|
||||||
should-release <range> yes when this push should publish a release
|
should-release <range> yes when this push should publish a release
|
||||||
bump <range> patch | minor | major
|
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
|
next-version <bump> the version to release
|
||||||
apply-bump <version> <bump> semver arithmetic
|
apply-bump <version> <bump> semver arithmetic
|
||||||
notes <range> markdown release notes
|
notes <range> markdown release notes
|
||||||
|
|||||||
@@ -100,6 +100,41 @@ commit "Rewrite everything [major]"
|
|||||||
check "major outranks minor" "major" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
|
check "major outranks minor" "major" "$(bash "$RELEASE" bump "v0.1.3..HEAD")"
|
||||||
cd /;
|
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
|
||||||
echo "skip release"
|
echo "skip release"
|
||||||
new_repo 0.1.0 >/dev/null
|
new_repo 0.1.0 >/dev/null
|
||||||
|
|||||||
@@ -8,14 +8,21 @@
|
|||||||
# them.
|
# them.
|
||||||
#
|
#
|
||||||
# The work lives in `.gitea/scripts/release.sh` so it can be run and tested
|
# The work lives in `.gitea/scripts/release.sh` so it can be run and tested
|
||||||
# without a runner (`bash .gitea/scripts/release.test.sh`).
|
# without a runner (`bash .gitea/scripts/release.test.sh`, or `npm run
|
||||||
|
# test:release`).
|
||||||
|
#
|
||||||
|
# The bump comes from the commit subjects in the range being released:
|
||||||
|
# `feat` is a minor, `!:` (or an explicit `[major]`) is a major, and everything
|
||||||
|
# else — `fix:`, `docs:`, prose — is a patch. `[skip release]` in a subject
|
||||||
|
# suppresses the release for that push.
|
||||||
|
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
# Manual runs, for the first release or to re-cut one after a failed push.
|
# Manual runs, for the first release (no tags yet) or to publish a release
|
||||||
|
# whose push failed after the tag was created.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# One release at a time. Two pushes in quick succession would otherwise race to
|
# One release at a time. Two pushes in quick succession would otherwise race to
|
||||||
@@ -24,11 +31,13 @@ concurrency:
|
|||||||
group: release-master
|
group: release-master
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
# The job token is what pushes the version commit, the tag, and the release.
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
env:
|
env:
|
||||||
BRANCH: master
|
BRANCH: master
|
||||||
steps:
|
steps:
|
||||||
@@ -37,6 +46,10 @@ jobs:
|
|||||||
# Tags are the whole point, so the clone must carry them.
|
# Tags are the whole point, so the clone must carry them.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: master
|
ref: master
|
||||||
|
# Gitea mints a token for every job and exposes it as
|
||||||
|
# secrets.GITEA_TOKEN; `permissions: contents: write` above is what
|
||||||
|
# lets it push. No hand-made secret is needed.
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Decide whether this push releases
|
- name: Decide whether this push releases
|
||||||
id: decide
|
id: decide
|
||||||
@@ -69,14 +82,23 @@ jobs:
|
|||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git config user.name "release bot"
|
git config user.name "gitea-actions"
|
||||||
git config user.email "release@notexpectedyet.com"
|
git config user.email "actions@notexpectedyet.com"
|
||||||
|
|
||||||
|
# Idempotency: a run that pushed the tag and then failed at the
|
||||||
|
# release call must not fail here on the re-run.
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||||
|
echo "tag v${VERSION} already exists — publishing the release for it"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
bash .gitea/scripts/release.sh set-version "$VERSION"
|
bash .gitea/scripts/release.sh set-version "$VERSION"
|
||||||
bash .gitea/scripts/release.sh changelog-prepend "$VERSION" "$(date -u +%Y-%m-%d)" /tmp/notes.md
|
bash .gitea/scripts/release.sh changelog-prepend "$VERSION" "$(date -u +%Y-%m-%d)" /tmp/notes.md
|
||||||
git add package.json CHANGELOG.md
|
git add package.json CHANGELOG.md
|
||||||
# The marker is the second line of defence against releasing this very
|
# `[skip release]` is the guard that has to hold, because the script
|
||||||
# commit: the script also refuses an empty range.
|
# is what decides. `[skip actions]` is the cheap extra: Gitea reads it
|
||||||
git commit -m "Release v${VERSION} [skip release]"
|
# and can skip queueing a run for this commit at all.
|
||||||
|
git commit -m "Release v${VERSION} [skip release] [skip actions]"
|
||||||
git tag "v${VERSION}"
|
git tag "v${VERSION}"
|
||||||
git push origin "HEAD:${BRANCH}"
|
git push origin "HEAD:${BRANCH}"
|
||||||
git push origin "v${VERSION}"
|
git push origin "v${VERSION}"
|
||||||
@@ -87,7 +109,7 @@ jobs:
|
|||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
SERVER: ${{ github.server_url }}
|
SERVER: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
TOKEN: ${{ github.token }}
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# The body is built by an encoder rather than by string interpolation:
|
# The body is built by an encoder rather than by string interpolation:
|
||||||
@@ -113,6 +135,12 @@ jobs:
|
|||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
--data-binary @/tmp/release.json)"
|
--data-binary @/tmp/release.json)"
|
||||||
|
|
||||||
|
# 409 is "a release for this tag already exists": the job has nothing
|
||||||
|
# left to do rather than something to report.
|
||||||
|
if [ "$status" = "409" ]; then
|
||||||
|
echo "v${VERSION} is already released"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
|
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
|
||||||
echo "release creation failed (HTTP ${status})" >&2
|
echo "release creation failed (HTTP ${status})" >&2
|
||||||
cat /tmp/release-response.json >&2
|
cat /tmp/release-response.json >&2
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Working in this repository
|
||||||
|
|
||||||
|
`master` releases itself. Every push runs `.gitea/workflows/release.yml`, which
|
||||||
|
reads the commit subjects since the last tag, bumps `package.json`, prepends
|
||||||
|
`CHANGELOG.md`, tags `vX.Y.Z`, and publishes the Gitea release with those
|
||||||
|
subjects as the body.
|
||||||
|
|
||||||
|
## Commit subjects are release input
|
||||||
|
|
||||||
|
The subject is both the version decision and the sentence a user reads in the
|
||||||
|
release notes. Write it for that reader:
|
||||||
|
|
||||||
|
- `feat: …` — the change adds a capability. Minor bump: `0.1.0` → `0.2.0`.
|
||||||
|
- `feat!: …` or `fix(x)!: …` — the change breaks an existing install. Major
|
||||||
|
bump: `0.1.0` → `1.0.0`, and the note lands under **Breaking**. `[major]`
|
||||||
|
anywhere in the subject does the same when a `!` will not fit.
|
||||||
|
- `fix: …`, `docs: …`, or a plain sentence — everything else. Patch bump:
|
||||||
|
`0.1.0` → `0.1.1`.
|
||||||
|
- `[skip release]` — this push publishes nothing.
|
||||||
|
|
||||||
|
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
|
||||||
|
subject line is read; the body is not.
|
||||||
|
|
||||||
|
## The release job owns the version and the changelog
|
||||||
|
|
||||||
|
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
|
||||||
|
from the commits. Pushing to `master` is the whole trigger — the tag and the
|
||||||
|
release come with it.
|
||||||
|
|
||||||
|
## Checking before pushing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run release:plan # would this HEAD release? (yes/no)
|
||||||
|
npm run release:notes # the exact notes it would publish
|
||||||
|
npm run test:release # the checks behind the bump and the notes
|
||||||
|
```
|
||||||
|
|
||||||
|
The rules in full — the marker table, first-release behaviour, how prose commits
|
||||||
|
are handled — are in the README under *Releases*.
|
||||||
Reference in new issue
Block a user