diff --git a/.github/workflows/commit-convention.yml b/.github/workflows/commit-convention.yml new file mode 100644 index 0000000..6cda42d --- /dev/null +++ b/.github/workflows/commit-convention.yml @@ -0,0 +1,67 @@ +name: commit-convention + +# Guard the version-bump markers the release workflow reads to pick the next +# semver bump. Markers are namespaced [bump:patch|minor|major]; a typo like +# [bump:pacth] would otherwise silently ship as a default minor, so reject +# anything malformed before merge. Make this a required status check on master. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + # master has a merge queue, which fires merge_group and waits for required + # checks to report on it. Markers are already validated at PR time, so the + # job just needs to run and pass here — otherwise a required "markers" check + # deadlocks the queue. + merge_group: + +permissions: + contents: read + +jobs: + markers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate [bump:*] markers + if: github.event_name == 'pull_request' # merge_group: validated at PR time, just report green + env: + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # Scan the PR title plus each commit's SUBJECT line (not the body) — + # the marker lives in the subject, like [skip ci], so prose in a body + # that merely documents the markers never trips the check. The + # namespaced bump: form also won't collide with ordinary + # bracket text ([skip ci], markdown links, …). + commits="$(git log --format=%s "${BASE_SHA}..${HEAD_SHA}" 2>/dev/null || true)" + text="$PR_TITLE + $commits" + + fail=false + + # A candidate marker is a single token [bump:]. Prose/notation + # like [bump:patch|minor|major] or [bump:*] (pipes, stars, spaces) + # isn't a marker attempt — only a malformed SINGLE level is a typo. + bad="$(printf '%s' "$text" \ + | grep -oiE '\[bump:[a-z0-9.]+\]' \ + | grep -viE '\[bump:(patch|minor|major)\]' || true)" + if [ -n "$bad" ]; then + echo "::error::Unrecognised bump marker(s): $(printf '%s' "$bad" | tr '\n' ' ')" + echo "Valid markers: [bump:patch], [bump:minor], [bump:major]." + fail=true + fi + + if $fail; then + echo "See the commit convention in README.md." + exit 1 + fi + + # Advisory: report the level this PR is asking for. + if printf '%s' "$text" | grep -qi '\[bump:major\]'; then lvl="major" + elif printf '%s' "$text" | grep -qi '\[bump:patch\]'; then lvl="patch (only if every commit in the release range is [bump:patch])" + else lvl="minor (default)"; fi + echo "✓ bump markers valid — this PR requests a **${lvl}** bump" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b2ab86..027508f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,41 @@ -name: release-latest +name: release + +# Snapshot the template on every push to master (and to release/* maintenance +# lines) as a versioned GitHub release, plus a rolling "latest" that the stable +# .../releases/latest/download/ URL tracks. +# +# Versions are semver vMAJOR.MINOR.PATCH. The bump is the HIGHEST level any +# commit since the last release asks for, via a [bump:*] marker in its SUBJECT +# (see the markers check in commit-convention.yml): +# * [bump:major] -> major (X+1.0.0) +# * [bump:minor] -> minor (X.Y+1.0) +# * [bump:patch] -> patch (X.Y.Z+1) +# An UNMARKED commit takes the branch default: minor on master, patch on a +# release/* maintenance branch (where you're back-patching, and a minor could +# collide with a mainline tag). Pre-semver tags (vX.Y two-component) read as +# X.Y.0; the first publish ever is v0.1.0. on: push: - branches: [master] + branches: + - master + - 'release/*' + workflow_dispatch: -# Allow the workflow to create/update releases and tags. permissions: contents: write concurrency: - group: release-latest + # Per-ref: a release/* back-patch never queues behind a master build. + group: release-${{ github.ref }} cancel-in-progress: false jobs: - archive: + release: + # Don't publish on branch creation (e.g. opening a release/* line at an + # existing release commit) — there are no new commits to release, and it + # would otherwise fall through to a spurious bump. + if: ${{ github.event_name != 'push' || !github.event.created }} runs-on: ubuntu-latest steps: - name: Checkout @@ -25,23 +47,53 @@ jobs: - name: Compute next version id: version run: | - # Find the highest existing vX.Y tag and bump by 0.1 (working in - # tenths to avoid float drift). First release is v0.1. - latest="$(git tag -l 'v[0-9]*.[0-9]*' \ - | sed 's/^v//' \ - | sort -t. -k1,1n -k2,2n \ - | tail -1)" + set -euo pipefail + # Select the baseline by tag NAME: a release/vX.Y branch sees only its + # X.Y line, master (or any other ref) sees all clean tags globally. + # The rolling "latest" tag and any suffixed tag are excluded. + case "${GITHUB_REF_NAME:-}" in + release/v*) default_lvl=1 # maintenance line: unmarked -> patch + mm="$(printf '%s' "${GITHUB_REF_NAME#release/v}" | cut -d. -f1,2 | sed 's/[.]/\\./g')" + tagpat="^v${mm}(\.[0-9]+)?$" ;; + *) default_lvl=2 # mainline: unmarked -> minor + tagpat='^v[0-9]+\.[0-9]+(\.[0-9]+)?$' ;; + esac + latest="$(git tag -l 'v[0-9]*' | grep -E "$tagpat" | sed 's/^v//' | sort -V | tail -1)" if [ -z "$latest" ]; then - next="0.1" + next="0.1.0" else - tenths=$(( $(echo "$latest" | awk -F. '{print $1*10 + $2}') + 1 )) - next="$(( tenths / 10 )).$(( tenths % 10 ))" + # No new commits since this line's latest release => nothing to do. + commits="$(git rev-list "v${latest}..HEAD")" + if [ -z "$commits" ]; then + echo "no new commits since v${latest} — nothing to release" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + maj=$(echo "$latest" | awk -F. '{print $1+0}') + min=$(echo "$latest" | awk -F. '{print $2+0}') + pat=$(echo "$latest" | awk -F. '{print $3+0}') # missing patch -> 0 + rank=0 # highest level seen: 1=patch 2=minor 3=major + while IFS= read -r sha; do + [ -n "$sha" ] || continue + msg="$(git log -1 --format=%s "$sha")" # subject line only + if printf '%s' "$msg" | grep -qi '\[bump:major\]'; then lvl=3 + elif printf '%s' "$msg" | grep -qi '\[bump:minor\]'; then lvl=2 + elif printf '%s' "$msg" | grep -qi '\[bump:patch\]'; then lvl=1 + else lvl=$default_lvl; fi + [ "$lvl" -gt "$rank" ] && rank=$lvl + done <<< "$commits" + case "$rank" in + 3) next="$((maj + 1)).0.0" ;; + 2) next="${maj}.$((min + 1)).0" ;; + 1) next="${maj}.${min}.$((pat + 1))" ;; + esac fi echo "tag=v${next}" >> "$GITHUB_OUTPUT" echo "Next version: v${next}" - name: Create repo archive id: archive + if: steps.version.outputs.skip != 'true' run: | # Pack the working tree (minus .git) into a zstd-compressed tarball. # The static toolchain lives in its own repo (yeet-src/toolchain) and @@ -59,6 +111,7 @@ jobs: echo "asset=${RUNNER_TEMP}/${repo}.tar.zst" >> "$GITHUB_OUTPUT" - name: Publish versioned release + if: steps.version.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -69,6 +122,9 @@ jobs: --target "${GITHUB_SHA}" - name: Update rolling "latest" release + # Only mainline moves "latest"; a release/* back-patch must not hijack + # the stable URL away from the newest master build. + if: steps.version.outputs.skip != 'true' && github.ref_name == 'master' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/README.md b/README.md new file mode 100644 index 0000000..b51c301 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# script-template + +Template for new yeet scripts. Scaffold one with: + +```sh +make new DEST= [NAME=] # or: scripts/new [name] +``` + +The static build toolchain (clang, bpftool, esbuild, make, git) lives in its own +repo, [yeet-src/toolchain](https://github.com/yeet-src/toolchain), vendored here +under `toolchain/` and pinned to a release tag. Generated projects don't read +`toolchain/` — they carry their own copies under `template/build/` (the embed +glue + a `toolchain.lock`). Refresh both from a toolchain release with: + +```sh +make sync-toolchain TOOLCHAIN_TAG=v0.8.0 # one rebase-mergeable commit; open a PR +``` + +## Releasing + +Every push to `master` publishes a versioned snapshot release and moves the +rolling **`latest`** release (so `.../releases/latest/download/` always tracks +the newest build). Versions are semver `vMAJOR.MINOR.PATCH`; CI picks the bump +from a `[bump:LEVEL]` marker in the commit **subject** (the body is free prose — +put the marker on the subject line, like `[skip ci]`; for squash merges that's +the PR title): + +| Marker | Bump | Notes | +| --- | --- | --- | +| *(none)* or `[bump:minor]` | **minor** `X.Y+1.0` | the default — a normal push | +| `[bump:patch]` | **patch** `X.Y.Z+1` | only if **every** commit in the release range is `[bump:patch]` | +| `[bump:major]` | **major** `X+1.0.0` | one such commit makes the whole release major | + +The release takes the highest level any commit asks for. A PR check +([`commit-convention.yml`](.github/workflows/commit-convention.yml)) rejects +malformed markers (e.g. `[bump:pacth]`) before merge, so a typo can't silently +mis-version a release. + +### Back-patching an older line + +To ship a fix on an older release while `master` has moved on, fork the line +from its tag and PR fixes into it: + +```sh +git branch release/v0.8 v0.8.0 && git push origin release/v0.8 +# open a PR against release/v0.8, then rebase-merge +``` + +A push to `release/*` runs the same workflow but scopes the version **by name** +to that line's tags, so a fix on `release/v0.8` publishes `v0.8.1` independent of +`master`. On `release/*` an unmarked commit defaults to a **patch** bump (not +minor) so a hotfix can't collide with a mainline tag. Maintenance branches are +**manual** here (not auto-opened) — master releases on every push, so +auto-opening a branch per minor would create one per push.