ci: release pull requests, and conventional-commit PR titles (#1)
Release / release (push) Successful in 2s
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s

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 `<PR title> (#<n>)` 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: #1
Co-authored-by: dhkatz <[email protected]>
This commit was merged in pull request #1.
This commit is contained in:
dhkatz authored and dhkatz committed 2026-11-03 08:40:29 +00:00
1 parent 21e1bd8790
commit f8ab3f965e
6 files changed
+174

No files matched your search

@@ -0,0 +1,3 @@
${PullRequestTitle} (#${PullRequestIndex})
${PullRequestDescription}
+30
View File
@@ -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"
+86
View File
@@ -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
+3
View File
@@ -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.
+7
View File
@@ -10,6 +10,13 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
in our own words are fine.
- Test with a throwaway IMVU account, never a real one.
- Commits: author "dhkatz <dev@t5de.dev>"; 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).
- IMVU session keys never leave the user's machine; the hub never asks for them.
## Notes
+45
View File
@@ -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 = "<!-- 0 -->Features" },
{ message = "^fix", group = "<!-- 1 -->Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^revert", group = "<!-- 3 -->Reverts" },
{ message = "^refactor", group = "<!-- 4 -->Refactoring" },
{ message = "^docs", group = "<!-- 5 -->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)$"