Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/commit-convention.yml
Original file line number Diff line number Diff line change
@@ -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:<level> 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:<word>]. 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"
84 changes: 70 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -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: |
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# script-template

Template for new yeet scripts. Scaffold one with:

```sh
make new DEST=<dir> [NAME=<name>] # or: scripts/new <dir> [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.
Loading