From 814b00f82b059c154578498712aaf6ebc8f784e8 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Tue, 3 Nov 2026 08:37:17 +0000 Subject: [PATCH] ci: release pull requests, and conventional-commit PR titles Pull requests only, squash-merged, titled as conventional commits (checked); release-bot keeps a chore(release) pull request open, worked out by git-cliff, and merging it tags the release. Co-Authored-By: Claude Opus 5.5 --- .../default_merge_message/SQUASH_TEMPLATE.md | 3 + .gitea/workflows/pull-request.yml | 30 +++++++ .gitea/workflows/release.yml | 86 +++++++++++++++++++ CHANGELOG.md | 3 + CLAUDE.md | 7 ++ cliff.toml | 45 ++++++++++ 6 files changed, 174 insertions(+) create mode 100644 .gitea/default_merge_message/SQUASH_TEMPLATE.md create mode 100644 .gitea/workflows/pull-request.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 cliff.toml diff --git a/.gitea/default_merge_message/SQUASH_TEMPLATE.md b/.gitea/default_merge_message/SQUASH_TEMPLATE.md new file mode 100644 index 0000000..97c80b4 --- /dev/null +++ b/.gitea/default_merge_message/SQUASH_TEMPLATE.md @@ -0,0 +1,3 @@ +${PullRequestTitle} (#${PullRequestIndex}) + +${PullRequestDescription} diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml new file mode 100644 index 0000000..f4083fe --- /dev/null +++ b/.gitea/workflows/pull-request.yml @@ -0,0 +1,30 @@ +# A pull request's title becomes its commit on main (squash merges, .gitea/default_merge_message), so it must be a +# conventional commit: the release workflow versions releases and writes the changelog from them. Required to merge +# (branch protection, dhkatz/infra modules/app-repo). +name: Pull request + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +jobs: + title: + runs-on: ubuntu-latest + steps: + - name: Conventional commit title + env: + TITLE: ${{ github.event.pull_request.title }} + BRANCH: ${{ github.head_ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + types="feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert" + conventional="^($types)(\([a-z0-9][a-z0-9./_-]*\))?!?: [^ ]" + if ! [[ "$TITLE" =~ $conventional ]]; then + echo "::error::The title becomes the commit on main, so it must be a conventional commit: type(scope): summary, e.g. \"feat(api): room bans\" or \"fix: crash on an empty catalog\" (! before the colon for a breaking change). Types: ${types//|/, }." + exit 1 + fi + if [[ "$TITLE" =~ ^chore\(release\) ]] && [[ "$BRANCH" != release || "$AUTHOR" != release-bot ]]; then + echo "::error::chore(release) is release-bot's: merging its release pull request is what tags a release." + exit 1 + fi + echo "OK: $TITLE" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..6bc5cb6 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,86 @@ +# Releases. release-bot keeps one pull request open from the `release` branch, "chore(release): vX.Y.Z": the next +# version and its CHANGELOG.md entry, which git-cliff (cliff.toml) works out from the conventional commits on main +# since the last tag. Merging it makes release-bot tag vX.Y.Z on main and publish the release notes; the tag builds the +# image (ci.yml) and Komodo deploys it to prod (dhkatz/infra). Only release-bot may create v* tags. It acts with its +# own token (the org secret RELEASE_TOKEN), not this workflow's: Gitea starts no workflows for what that one does, so +# the release pull request's checks and the tag's build would never run. +name: Release + +on: + push: + branches: [main] + +concurrency: + group: release + +jobs: + release: + runs-on: ubuntu-latest + env: + CLIFF_VERSION: 2.14.2 + CLIFF_SHA512: 95b0c55ab3e351ca7c0285fbbde8f1e8c42906466a173e6884f5c7ca814523bda44cbd4ab26b0aa28cace4c02c5ee6b553e9742bd9d7df47a73b9ef58ea429a9 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install git-cliff + run: | + archive="git-cliff-$CLIFF_VERSION-x86_64-unknown-linux-musl.tar.gz" + curl -fsSLO "https://github.com/orhun/git-cliff/releases/download/v$CLIFF_VERSION/$archive" + echo "$CLIFF_SHA512 $archive" | sha512sum -c - + mkdir -p "$HOME/.local/bin" + tar -xzf "$archive" --strip-components=1 -C "$HOME/.local/bin" "git-cliff-$CLIFF_VERSION/git-cliff" + rm "$archive" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Tag a merged release, or update the release pull request + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -euo pipefail + api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" + host="${GITHUB_SERVER_URL#https://}" + git config user.name release-bot + git config user.email "release-bot@${host#git.}" + basic=$(printf 'release-bot:%s' "$TOKEN" | base64 -w0) + push() { git -c http.extraHeader="Authorization: Basic $basic" push "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$@"; } + gitea() { curl -fsS -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$@"; } + + # The release pull request was merged: tag its commit and publish the release. + if [[ "$(git log -1 --format=%s)" =~ ^chore\(release\):\ (v[0-9]+\.[0-9]+\.[0-9]+) ]]; then + tag="${BASH_REMATCH[1]}" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "$tag exists already"; exit 0; fi + git tag "$tag" + push "refs/tags/$tag" + jq -n --arg tag "$tag" --arg body "$(git cliff --latest --strip all)" '{tag_name: $tag, name: $tag, body: $body}' | + gitea -o /dev/null -X POST "$api/releases" -d @- + echo "Released $tag" + exit 0 + fi + + # Otherwise: anything to release since the last tag? (Nothing printed when no commit counts.) + last=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true) + next=$(git cliff --bumped-version 2>/dev/null || true) + next="v${next#v}" + if [ "$next" = v ] || [ "$next" = "$last" ]; then echo "Nothing to release since ${last:-the start}"; exit 0; fi + + notes=$(git cliff --unreleased --bump --strip all) + git switch -C release + git cliff --bump --output CHANGELOG.md + git add CHANGELOG.md + git commit -q -m "chore(release): $next" + push --force HEAD:refs/heads/release + + title="chore(release): $next" + body=$(printf 'Merging this releases **%s**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.\n\n%s\n' "$next" "$notes") + payload=$(jq -n --arg title "$title" --arg body "$body" '{title: $title, body: $body}') + open=$(gitea "$api/pulls?state=open&limit=50" | jq -r '[.[] | select(.head.ref == "release")][0].number // empty') + if [ -n "$open" ]; then + gitea -o /dev/null -X PATCH "$api/pulls/$open" -d "$payload" + echo "Updated the release pull request #$open: $title" + else + jq '. + {head: "release", base: "main"}' <<<"$payload" | gitea -o /dev/null -X POST "$api/pulls" -d @- + echo "Opened the release pull request: $title" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eb804f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release. diff --git a/CLAUDE.md b/CLAUDE.md index 8133711..7d0cd76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,4 +9,11 @@ package (`docs/pack.md`). - Never commit IMVU's files: tests make their product files in code (a minimal `index.xml`, fake textures). - Never commit secrets (catalog keys, tokens). - Commits: author "dhkatz "; never the user's real name or email. +- Changes reach main only through pull requests, squash-merged: the PR's title becomes the commit on main, so it must + be a conventional commit (`feat(api): …`, `fix(web): …`, `docs: …`; `!` before the colon for a breaking change), + checked by `.gitea/workflows/pull-request.yml`. Commits on the branch can be anything. Push a branch and open the + pull request on git.t5de.dev (web UI or API); the user merges. +- Releases: release-bot keeps a `chore(release): vX.Y.Z` pull request open (`release.yml`, git-cliff from those + titles); merging it tags vX.Y.Z, which builds the image and deploys prod. Never tag by hand (v* tags are + release-bot's). - Follow `docs/products.md` exactly; change it there (in t5de-hub) first if the contract needs to change. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..247dd35 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,45 @@ +# git-cliff (https://git-cliff.org), for the release workflow (.gitea/workflows/release.yml): the next version and +# CHANGELOG.md, from the conventional commits on main (one per pull request, titled by it) since the last vX.Y.Z tag. +# feat → minor, fix/perf/revert → patch, a breaking change (`!` or BREAKING CHANGE:) → minor while 0.x, major after. + +[changelog] +header = """ +# Changelog + +Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release. +""" +body = """ +{% if commits | length > 0 %} +{% if version %}## {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }}){% else %}## Unreleased{% endif %} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim }} +{% for commit in commits %} +- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | upper_first }}{% endfor %} +{% endfor %} +{% endif %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +protect_breaking_commits = true +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^revert", group = "Reverts" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^(build|ci|chore|style|test)", skip = true }, +] +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" +sort_commits = "oldest" + +[bump] +features_always_bump_minor = true +breaking_always_bump_major = false +initial_tag = "v0.1.0" +# Matched against the commit's type: these alone don't make a release. +no_increment_regex = "^(chore|ci|docs|test|style|build|refactor)$" -- 2.54.0