From fbf1e013144b04597f8b73c7c4e4d7edaada29c8 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Tue, 3 Nov 2026 08:40:38 +0000 Subject: [PATCH] ci: release pull requests, and conventional-commit PR titles (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases as pull requests, the same in all five app repos (hub, catalog, live, riftbound, karaoke). The Gitea side (PRs only into main, squash merges, required checks, `v*` tags only by release-bot, its `RELEASE_TOKEN` org secret) is dhkatz/infra `modules/app-repo`. - **`.gitea/workflows/pull-request.yml`**: the PR title must be a conventional commit (`feat(api): …`, `fix: …`, `feat!: …`); it becomes the commit on main. `chore(release)` only from release-bot's `release` branch. Required to merge (`Pull request / title`). - **`.gitea/default_merge_message/SQUASH_TEMPLATE.md`**: the squash commit is ` (#)` plus the description. - **`.gitea/workflows/release.yml`**, on every push to main, as release-bot (`RELEASE_TOKEN`): - if the commit is a merged `chore(release): vX.Y.Z`: tag it vX.Y.Z (lightweight), push the tag (CI builds the image, Komodo deploys prod), publish the release with git-cliff's notes; - otherwise, if anything releasable landed since the last tag: rewrite the `release` branch (CHANGELOG.md) and open or update `chore(release): vX.Y.Z`. - **`cliff.toml`** (git-cliff 2.14.2, pinned by sha512): feat → minor, fix/perf/revert → patch, breaking → minor while 0.x; chore/ci/docs/test/style/build/refactor alone don't release. Only conventional commits count, so the history before this is left out. - **`CHANGELOG.md`**: header only; release PRs fill it. - Agent notes (CLAUDE.md / AGENTS.md): the PR flow and title format; never tag by hand. After merging, `Release` runs on main and should report nothing to release (this PR is `ci:`); the first `feat:`/`fix:` PR merged after it opens the first release PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://git.t5de.dev/t5de/t5de-catalog/pulls/1 Co-authored-by: dhkatz --- .../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)$"