From ffefc42e01106ad02057f92f88a7924266f5fde7 Mon Sep 17 00:00:00 2001 From: dsh-slopo agent Date: Sat, 26 Sep 2026 06:38:08 +0100 Subject: [PATCH] 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. --- .gitea/scripts/release.sh | 62 ++++++++++++++++++++++++++-------- .gitea/scripts/release.test.sh | 35 +++++++++++++++++++ .gitea/workflows/release.yml | 48 ++++++++++++++++++++------ AGENTS.md | 39 +++++++++++++++++++++ 4 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 AGENTS.md diff --git a/.gitea/scripts/release.sh b/.gitea/scripts/release.sh index 75e8421..c751a22 100755 --- a/.gitea/scripts/release.sh +++ b/.gitea/scripts/release.sh @@ -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 patch | minor | major, from markers in the commits +# bump patch | minor | major, from the commit subjects # next-version the version to release # notes 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: `..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 range the commit range this release covers should-release yes when this push should publish a release bump patch | minor | major + is-feature yes when a subject claims a feature + is-breaking yes when a subject claims a breaking change next-version the version to release apply-bump semver arithmetic notes markdown release notes diff --git a/.gitea/scripts/release.test.sh b/.gitea/scripts/release.test.sh index c25fd7c..0f80bf6 100755 --- a/.gitea/scripts/release.test.sh +++ b/.gitea/scripts/release.test.sh @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4c6c1af..8e9cd23 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,14 +8,21 @@ # 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`). +# 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 on: push: 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: # One release at a time. Two pushes in quick succession would otherwise race to @@ -24,11 +31,13 @@ concurrency: group: release-master cancel-in-progress: false +permissions: + # The job token is what pushes the version commit, the tag, and the release. + contents: write + jobs: release: runs-on: ubuntu-latest - permissions: - contents: write env: BRANCH: master steps: @@ -37,6 +46,10 @@ jobs: # Tags are the whole point, so the clone must carry them. fetch-depth: 0 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 id: decide @@ -69,14 +82,23 @@ jobs: VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - git config user.name "release bot" - git config user.email "release@notexpectedyet.com" + git config user.name "gitea-actions" + 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 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]" + # `[skip release]` is the guard that has to hold, because the script + # is what decides. `[skip actions]` is the cheap extra: Gitea reads it + # 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 push origin "HEAD:${BRANCH}" git push origin "v${VERSION}" @@ -87,7 +109,7 @@ jobs: VERSION: ${{ steps.version.outputs.version }} SERVER: ${{ github.server_url }} REPO: ${{ github.repository }} - TOKEN: ${{ github.token }} + TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail # The body is built by an encoder rather than by string interpolation: @@ -113,6 +135,12 @@ jobs: -H 'Content-Type: application/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 echo "release creation failed (HTTP ${status})" >&2 cat /tmp/release-response.json >&2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0e50364 --- /dev/null +++ b/AGENTS.md @@ -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*.