diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..87bb17c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + contents: write + +jobs: + tag-and-release: + # Chains off the same trusted CI-on-main signal as CD, so a release only + # ever follows a green build of a commit that actually landed on main. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + env: + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.RELEASE_SHA }} + fetch-depth: 0 + + - name: Determine the next version from conventional commits + id: version + run: | + set -euo pipefail + last_tag="$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)" + if [[ -n "$last_tag" ]]; then + range="${last_tag}..${RELEASE_SHA}" + base="${last_tag#v}" + else + range="${RELEASE_SHA}" + base="0.0.0" + fi + + if [[ -z "$(git log "$range" --oneline 2>/dev/null)" ]]; then + echo "No commits since ${last_tag:-the start of history}; nothing to release." + echo "bump=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + subjects="$(git log "$range" --pretty=%s)" + bodies="$(git log "$range" --pretty=%b)" + + # Conventional Commits: a feature earns a minor release, a fix a + # patch, and a declared breaking change (either `type!:` or a + # BREAKING CHANGE footer) always wins regardless of what else is in + # the range. Anything else (docs/ci/build/test/chore) is + # deliberately not release-worthy on its own. + bump=none + if grep -qE '^[a-z]+(\([^)]*\))?!:' <<<"$subjects" || grep -q 'BREAKING CHANGE' <<<"$bodies"; then + bump=major + elif grep -qE '^feat(\([^)]*\))?:' <<<"$subjects"; then + bump=minor + elif grep -qE '^fix(\([^)]*\))?:' <<<"$subjects"; then + bump=patch + fi + + if [[ "$bump" == none ]]; then + echo "No feat/fix/breaking change since ${last_tag:-the start of history}; skipping the release." + echo "bump=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + IFS=. read -r major minor patch <<<"$base" + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + + echo "bump=$bump" >> "$GITHUB_OUTPUT" + echo "version=v${major}.${minor}.${patch}" >> "$GITHUB_OUTPUT" + + - name: Tag and publish the release + if: steps.version.outputs.bump != 'none' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "$VERSION already exists; skipping." + exit 0 + fi + git tag -a "$VERSION" "$RELEASE_SHA" -m "$VERSION" + git push origin "$VERSION" + gh release create "$VERSION" \ + --repo "${{ github.repository }}" \ + --title "$VERSION" \ + --target "$RELEASE_SHA" \ + --generate-notes diff --git a/README.md b/README.md index d6c536d..2f9f520 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,18 @@ Successful PR CI runs are merged automatically only for trusted same-repository and Dependabot. Forks, drafts, and untrusted author associations are deliberately skipped; repository branch-protection and review requirements continue to apply. +### Releases + +Every push to `main` that passes CI is scanned for +[Conventional Commits](https://www.conventionalcommits.org/) since the last tag. A `feat:` +commit earns a minor release, `fix:` a patch release, and a declared breaking change +(`type!:` or a `BREAKING CHANGE` footer) always wins with a major release. A range with none +of those — only `docs:`, `ci:`, `build:`, `test:`, or `chore:` commits, as with most +Dependabot bumps — is deliberately left unreleased. When a release is warranted, the +workflow tags `main` (`vMAJOR.MINOR.PATCH`) and publishes a GitHub Release with +auto-generated notes. Tags are never created by hand, and nothing is ever tagged off a +branch other than `main`. + ## Serving backends `ROUTER_BACKEND=mock` (the default) keeps CI deterministic and GPU-free.