diff --git a/.gitea/scripts/release.sh b/.gitea/scripts/release.sh new file mode 100755 index 0000000..75e8421 --- /dev/null +++ b/.gitea/scripts/release.sh @@ -0,0 +1,260 @@ +#!/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 four subcommands: +# +# last-tag the most recent v* tag, or empty +# bump patch | minor | major, from markers in the commits +# 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 +# [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. + +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 +} + +# The bump implied by the commits in a range. +bump_for() { + local subjects + subjects="$(subjects_in "$1")" + if printf '%s\n' "$subjects" | grep -qF "$MARKER_MAJOR"; then + echo major + elif printf '%s\n' "$subjects" | grep -qF "$MARKER_MINOR"; 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 + 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.' + 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:-}" ;; + 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 + 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 diff --git a/.gitea/scripts/release.test.sh b/.gitea/scripts/release.test.sh new file mode 100755 index 0000000..c25fd7c --- /dev/null +++ b/.gitea/scripts/release.test.sh @@ -0,0 +1,164 @@ +#!/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 "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 ] diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4c6c1af --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,121 @@ +# 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`). + +name: Release + +on: + push: + branches: [master] + # Manual runs, for the first release or to re-cut one after a failed push. + 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 + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + 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 + + - 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 "release bot" + git config user.email "release@notexpectedyet.com" + 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]" + 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: ${{ github.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)" + + 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}" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77709d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Every release, newest first, generated from the commits in each release. diff --git a/README.md b/README.md index d7a35a6..ae20514 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,66 @@ it but does cover the wiring it cannot work without. declared dependency — an out-of-tree bundle that depended on an in-box package would install a second copy that drifts from the host's. +## Releases and updating + +### The workflow + +`.gitea/workflows/release.yml` runs on every push to `master`: + +1. works out the range since the last tag; +2. decides whether the push releases at all; +3. bumps `package.json`, prepends a `CHANGELOG.md` section, commits, and tags + `vX.Y.Z`; +4. creates the Gitea release with those notes as the body. + +The bump is a patch unless a commit in the release says otherwise: + +| in a commit subject | effect | +|---|---| +| `[minor]` | release as a minor version | +| `[major]` | release as a major version | +| `[skip release]` | this push publishes nothing | + +With no tags yet, the version already in `package.json` is what gets tagged — the +first release should match what the code says, not invent a number. + +Release notes come from the commit subjects in the range. Conventional prefixes +(`feat:`, `fix:`, `docs:`) are grouped under headings when at least half the +commits carry one; otherwise the notes are a flat list. Prose commit subjects get +no invented headings. + +The logic lives in `.gitea/scripts/release.sh` so it runs without a runner, and +`npm run test:release` exercises it — 31 checks, including an empty range, a +repository with no tags yet, and a patch number already at 9. + +### Making `dsh plugin update` work + +A plugin installed as a bare `git+https://…/repo.git` is resolved to a **commit** +and pinned in the profile's lockfile. Verified against a local remote: `pnpm +update ` *does* move that to the branch head, so the mechanism is not +broken — but it tracks every commit, including half-finished ones, and it needs +fetch access to the remote. + +Adding a version selector makes it track **releases** instead: + +```jsonc +// $DSH_HOME/profiles//package.json +"dsh-slopo": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-slopo.git#semver:^0.1.0" +``` + +`#semver:^0.1.0` resolves the highest tag in range. Verified locally: installed +`v0.1.1` where the branch was already at `v0.1.2`-plus-untagged-work, and +`pnpm update` then moved it to a newly pushed `v0.1.2`. + +Two things to know, both of which look like "the update did nothing": + +- **The remote must be fetchable.** `pnpm update` cannot see a new tag it cannot + fetch. If `git ls-remote ` asks for a username, fix that first — the same + credential push needs. +- **A restart is required.** Client bundle bytes are snapshotted when the profile + activates, so a hot update installs new files but keeps serving the old ones + until `dsh --profile ` comes back up. + ## Licence AGPL-3.0-or-later, matching slopo. The three skill bodies are ports of slopo's diff --git a/package.json b/package.json index 7d5ec5e..18af6eb 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "cordis.patch.yml", "README.md", "NOTICE.md", - "LICENSE" + "LICENSE", + "CHANGELOG.md" ], "engines": { "node": ">=20" }, "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)\"" }, "dsh": { "bundle": {