name: Release on: push: # Only REL-prefixed marker tags create a public release. Version # tags themselves (v0.4.0, v0.4.0rc1) trigger the four packaging # workflows (.deb / RPM / OBS / AUR) via their `v*.*.*` globs but # NOT this Release workflow — that's deliberate. kernalix7 found # that rapid-iteration tags between v0.1.9 and v0.2.2.1 shipped # unverified releases that broke users' working installs # (2026-04-29). Releasing now requires pushing a SECOND tag pointed # at the same commit as the version tag, namespaced with REL- so # the version scheme itself stays clean (PEP 440 / SemVer): # # git tag v0.4.0 # version, triggers packaging # git tag REL-v0.4.0 # marker, triggers this workflow # git push origin v0.4.0 REL-v0.4.0 # # The previous `*RTM*` glob was retired 2026-05-05 alongside the # PEP 440 versioning move (current versions are `0.4.0` / # `0.4.0rc1`, no RTM suffix anywhere). Manual workflow_dispatch # still works for emergency one-offs. tags: - "REL-*" workflow_dispatch: permissions: contents: write # id-token: write # reserved for future PyPI trusted publisher jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: "3.13" - run: pip install ruff - name: Run ruff check run: ruff check src/ tests/ - name: Run ruff format check run: ruff format --check src/ tests/ - name: Install project run: pip install -e ".[dev]" - name: Run tests run: pytest tests/ -v # Skip tag/pyproject version parity check on manual dispatch - name: Verify tag matches pyproject version if: github.event_name == 'push' run: | # Tag layout: REL-v. Strip the REL- marker # prefix and the leading 'v' to get pyproject.toml's version # field (e.g., REL-v0.4.0rc1 -> 0.4.0rc1, REL-v0.4.0 -> 0.4.0). tag="${GITHUB_REF_NAME#REL-}" tag="${tag#v}" pyver=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") if [ "$tag" != "$pyver" ]; then echo "Tag version ($tag) does not match pyproject.toml version ($pyver)" >&2 exit 1 fi build: needs: validate runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: "3.13" - run: python -m pip install build twine - name: Build distribution run: python -m build - name: Check distribution metadata run: python -m twine check dist/* - uses: actions/upload-artifact@v4 with: name: dist path: dist/ release: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Extract version from REL tag id: version run: | # REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. Used by all # subsequent steps so the public Release page reads # "v0.4.0" / "0.4.0" instead of the internal "REL-v0.4.0" # marker tag name. tag="${GITHUB_REF_NAME#REL-}" tag="${tag#v}" echo "value=$tag" >> "$GITHUB_OUTPUT" - name: Extract changelog section id: changelog run: | # REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. tag="${GITHUB_REF_NAME#REL-}" tag="${tag#v}" # Determine the previous published version so we can include # any intermediate unreleased sections in the notes (sections # the user wrote in CHANGELOG but never tagged & released). # On 2026-05-05, v0.3.1 was such an unreleased section: it # collected Mic92's PRs #62-#65 but no v0.3.1 tag was ever # pushed; v0.4.0rc1's release page would have lost those # contributor credits if it only matched ## [0.4.0]. # Strategy: find the most recent CHANGELOG section that # corresponds to a published GitHub Release (i.e., has its own # tag). Capture from the current section back to (but not # including) that already-published one. prev="" while IFS= read -r section; do sver="${section#\#\# \[}" sver="${sver%%\] *}" # Skip the in-flight version itself and the unreleased header. [ "$sver" = "$tag" ] && continue [ "$sver" = "Unreleased" ] && continue # First section we hit that has a corresponding tag is the # previous published release. CHANGELOG is in newest-first # order so this is correct on the first match. if git rev-parse --verify "v${sver}^{commit}" >/dev/null 2>&1; then prev="$sver" break fi done < <(grep -E '^## \[' CHANGELOG.md) notes=$(awk -v ver="$tag" -v prev="$prev" ' $0 ~ "^## \\[" ver "\\]" { flag=1 } prev != "" && $0 ~ "^## \\[" prev "\\]" { exit } prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit } flag { print } ' CHANGELOG.md) # PEP 440 prereleases (0.4.0rc1) usually share notes with the # final (0.4.0) section in CHANGELOG. Fall back to the base # version section if the prerelease-specific one is missing. if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then base=$(echo "$tag" | sed -E 's/(rc|alpha|beta)[0-9]*$//') if [ "$base" != "$tag" ]; then notes=$(awk -v ver="$base" -v prev="$prev" ' $0 ~ "^## \\[" ver "\\]" { flag=1 } prev != "" && $0 ~ "^## \\[" prev "\\]" { exit } prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit } flag { print } ' CHANGELOG.md) fi fi if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then notes="See CHANGELOG.md for details." fi { echo "notes<> "$GITHUB_OUTPUT" - name: Detect prerelease id: prerelease run: | # Match both PEP 440 ("0.4.0rc1", no separator) and SemVer- # hyphen ("0.4.0-rc1") prerelease forms after the REL- prefix # is stripped. tag="${GITHUB_REF_NAME#REL-}" tag="${tag#v}" if echo "$tag" | grep -qE -- '(rc|beta|alpha)[0-9]*'; then echo "value=true" >> "$GITHUB_OUTPUT" else echo "value=false" >> "$GITHUB_OUTPUT" fi - name: Create release uses: softprops/action-gh-release@v2 with: # tag_name + name override the default github.ref_name # (REL-v0.4.0 marker) with the clean version tag (v0.4.0) so # the public release surface is namespace-free. The marker # tag still exists in git as the trigger record. tag_name: v${{ steps.version.outputs.value }} name: v${{ steps.version.outputs.value }} prerelease: ${{ steps.prerelease.outputs.value }} # The hand-written CHANGELOG section is the entire release body — # including its "### Contributors" subsection, which credits the # issue reporters and external PR authors for the release (the repo's # house convention; see CONTRIBUTING.md). generate_release_notes is # deliberately NOT enabled: on this maintainer-authored repo it dumps # a ~120-line "What's Changed" list that credits the owner on nearly # every PR and buries the real contributor credits behind it. This # step is the SOLE writer of the release body: the packaging # workflows (.deb / RPM / OBS / AppImage) only attach assets, never # touch the notes, so the release isn't edited/rewritten repeatedly. body: ${{ steps.changelog.outputs.notes }} files: | dist/*.tar.gz dist/*.whl