Release / release (push) Successful in 6s
Replicate the dsh-facet release stack: a Gitea Actions workflow (.gitea/workflows/release.yml) + .gitea/scripts/release.sh read the commit subjects since the last tag, bump package.json, prepend CHANGELOG.md, tag vX.Y.Z and publish the release via API. Agreed install ref is #semver:^0.1.0, and tags are what 'dsh plugin --profile web update' moves to, so the workflow is what makes updates work. Add the release.test.sh suite (43 checks), npm scripts (test:release / release:plan / release:notes), AGENTS.md with the commit conventions, CHANGELOG.md seed, and the README *Releases* rules.
295 lines
10 KiB
Bash
Executable File
295 lines
10 KiB
Bash
Executable File
#!/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
|