#!/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 patch | minor | major, from the commit subjects # next-version the version to release # notes 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: `..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 last-tag most recent v* tag, or empty wants-skip yes when the range asks for no release 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 current-version version declared in package.json set-version update package.json in place changelog-prepend USAGE exit 2 ;; esac