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:
1 parent
040c69b57d
commit
7261d46ee2
7 files changed
+724
-1
No files matched your search
Executable
+294
@@ -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
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/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 "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
|
||||
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 ]
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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`, 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 (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
|
||||
# tag the same commit and one of them would lose.
|
||||
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
|
||||
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
|
||||
# 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
|
||||
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 "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
|
||||
# `[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}"
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.decide.outputs.release == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITEA_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)"
|
||||
|
||||
# 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
|
||||
exit 1
|
||||
fi
|
||||
echo "published v${VERSION}"
|
||||
@@ -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*.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
Every release, newest first, generated from the commits in each release.
|
||||
@@ -352,6 +352,42 @@ Three things now guard against it:
|
||||
If `status` reports `endpoint did NOT answer`, check `ollama ps` on that host
|
||||
before assuming the plugin is at fault.
|
||||
|
||||
## Releases
|
||||
|
||||
`.gitea/workflows/release.yml` runs on every push to `master`: it works out the
|
||||
range since the last tag, bumps `package.json`, prepends a `CHANGELOG.md`
|
||||
section, commits and tags `vX.Y.Z`, then creates the Gitea release with the
|
||||
commit subjects as the body.
|
||||
|
||||
The bug it fixes is not tidiness. Installed as a bare `git+https://…/repo.git`,
|
||||
a plugin is pinned to a **commit** in the profile's lockfile, so with no tags
|
||||
there is nothing for `dsh plugin update` to move to. Point the profile at
|
||||
`#semver:^0.1.0` and the updates follow releases instead:
|
||||
|
||||
```jsonc
|
||||
"dsh-recall": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-recall.git#semver:^0.1.0"
|
||||
```
|
||||
|
||||
The bump is read from the commit subjects being released:
|
||||
|
||||
| in a commit subject | effect |
|
||||
|---|---|
|
||||
| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
|
||||
| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
|
||||
| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
|
||||
| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
|
||||
| `[skip release]` | this push publishes nothing |
|
||||
|
||||
Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
|
||||
flag leak` is a patch, not a minor. With no tags yet, the version already in
|
||||
`package.json` is what gets tagged, so the first release matches the code.
|
||||
|
||||
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner:
|
||||
`npm run test:release` exercises it — an empty range, a repository with no tags
|
||||
yet, a `feat` that is only a word — `npm run release:plan` says whether the
|
||||
current HEAD would release, and `npm run release:notes` prints the notes it
|
||||
would publish.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@
|
||||
"node": ">=22.5"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
"test": "node --test test/*.test.js",
|
||||
"test:release": "bash .gitea/scripts/release.test.sh",
|
||||
"release:plan": "bash .gitea/scripts/release.sh should-release \"$(bash .gitea/scripts/release.sh range)\"",
|
||||
"release:notes": "bash .gitea/scripts/release.sh notes \"$(bash .gitea/scripts/release.sh range)\""
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
|
||||
Reference in new issue
Block a user