name: release # Manual trigger: Actions → release → Run workflow. Always runs against main # (the workflow validates GITHUB_REF). Steps: # 1. Compute the new version from the `version` input (patch/minor/major # or explicit semver). The *current* version is read from the latest # git tag (`v*.*.*`) — NOT from __init__.py. This is robust to PRs that # pre-bump __init__.py: the bump always runs from the last released # version, not from whatever the working files happen to say. # 2. Update __version__ in __init__.py and version in plugin.yaml to match # the new tag — bringing the files in sync if a PR pre-bumped them. # 3. Commit as `chore(release): vX.Y.Z`, tag, push to main + push the tag. # 4. Publish a GitHub Release. Body is the matching `## [X.Y.Z]` block from # CHANGELOG.md when present; otherwise auto-generated release notes. # # Recommended flow: land a PR that adds a `## [X.Y.Z]` section to CHANGELOG.md # first, then run this workflow with the matching version so the release notes # are the hand-written changelog instead of commit-message-derived notes. on: workflow_dispatch: inputs: version: description: "Version bump (`patch`, `minor`, `major`) or explicit semver (`0.3.0`)" required: true default: "patch" permissions: contents: write concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: name: Tag and Publish GitHub Release runs-on: ubuntu-latest steps: - name: Validate trigger is main run: | if [ "$GITHUB_REF" != "refs/heads/main" ]; then echo "::error::release must run against main. Got $GITHUB_REF" exit 1 fi - uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Compute new version id: bump run: | set -euo pipefail VERSION_INPUT="${{ github.event.inputs.version }}" # Current = latest released tag (sort by version, descending; pick # the first v*.*.* tag). NOT __init__.py — a PR may have pre-bumped # the version files, and we don't want to double-bump on top of # that. The bump is computed from the last *released* version. LAST_TAG=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1 || true) if [ -z "$LAST_TAG" ]; then # First release in the repo. Seed with 0.0.0 so a `patch` bump # yields v0.0.1, `minor` yields v0.1.0, `major` yields v1.0.0. # Explicit semver inputs bypass the seed entirely. CURRENT="0.0.0" echo "No prior v*.*.* tag found; seeding current=0.0.0" else CURRENT="${LAST_TAG#v}" echo "Latest released tag: $LAST_TAG (current=$CURRENT)" fi if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then IFS=. read -r MAJ MIN PAT <<< "$CURRENT" case "$VERSION_INPUT" in major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;; minor) MIN=$((MIN + 1)); PAT=0 ;; patch) PAT=$((PAT + 1)) ;; esac NEW="${MAJ}.${MIN}.${PAT}" elif [[ "$VERSION_INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then NEW="$VERSION_INPUT" else echo "::error::Invalid version input: '$VERSION_INPUT'" echo "::error::Use patch|minor|major or explicit X.Y.Z" exit 1 fi if [ "$NEW" = "$CURRENT" ]; then echo "::error::New version equals last released ($NEW). Pick a different version." exit 1 fi # Sanity check: refuse if __init__.py is already ahead of the # version we're about to ship. Catches the "PR bumped to 0.5.0 but # workflow was asked for a patch that would land 0.1.8" foot-gun # before it overwrites the files. FILE_VERSION=$(grep -E '^__version__ = ' __init__.py \ | sed -E 's/^__version__ = "([^"]+)".*/\1/') if [ -n "$FILE_VERSION" ] && [ "$FILE_VERSION" != "$CURRENT" ] && [ "$FILE_VERSION" != "$NEW" ]; then echo "::error::__init__.py reports version $FILE_VERSION, but the release would ship $NEW (last tag: $CURRENT)." echo "::error::Reconcile by picking a version input that matches __init__.py, or roll __init__.py back to $CURRENT." exit 1 fi echo "New version: $NEW" echo "current=$CURRENT" >> "$GITHUB_OUTPUT" echo "version=$NEW" >> "$GITHUB_OUTPUT" echo "tag=v$NEW" >> "$GITHUB_OUTPUT" - name: Refuse if tag already exists run: | set -euo pipefail TAG="${{ steps.bump.outputs.tag }}" if git rev-parse --verify "refs/tags/$TAG" >/dev/null 2>&1; then echo "::error::Tag $TAG already exists locally. Pick a different version." exit 1 fi if git ls-remote --tags origin "$TAG" | grep -q "refs/tags/$TAG$"; then echo "::error::Tag $TAG already exists on origin. Pick a different version." exit 1 fi - name: Update version files run: | set -euo pipefail NEW="${{ steps.bump.outputs.version }}" sed -i -E "s/^__version__ = \"[^\"]+\"/__version__ = \"${NEW}\"/" __init__.py sed -i -E "s/^version: .*/version: ${NEW}/" plugin.yaml # Verify both files changed and that the new version is present. grep -q "^__version__ = \"${NEW}\"" __init__.py grep -q "^version: ${NEW}$" plugin.yaml echo "--- diff ---" git --no-pager diff -- __init__.py plugin.yaml - name: Configure Git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Commit and tag run: | set -euo pipefail TAG="${{ steps.bump.outputs.tag }}" git add __init__.py plugin.yaml if git diff --cached --quiet; then # PR already bumped the files to the target version. Tag the # existing HEAD rather than creating an empty release commit. echo "Version files already at ${TAG}; tagging current HEAD." else git commit -m "chore(release): ${TAG}" fi git tag -a "${TAG}" -m "${TAG}" - name: Push commit and tag run: | set -euo pipefail # HEAD push is a no-op when nothing was committed in this run. git push origin HEAD:main git push origin "${{ steps.bump.outputs.tag }}" - name: Extract CHANGELOG section for this version id: changelog run: | set -euo pipefail VERSION="${{ steps.bump.outputs.version }}" # Pull lines between `## [VERSION]` and the next `## [` heading. SECTION=$(awk -v ver="$VERSION" ' $0 ~ "^## \\[" ver "\\]" { found=1; next } found && /^## \[/ { exit } found { print } ' CHANGELOG.md) if [ -z "$SECTION" ]; then echo "::warning::No CHANGELOG.md section found for v${VERSION} — falling back to auto-generated release notes." echo "has_section=false" >> "$GITHUB_OUTPUT" else echo "has_section=true" >> "$GITHUB_OUTPUT" { echo 'body<> "$GITHUB_OUTPUT" fi - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.bump.outputs.tag }} name: ${{ steps.bump.outputs.tag }} body: ${{ steps.changelog.outputs.body }} generate_release_notes: ${{ steps.changelog.outputs.has_section == 'false' }} token: ${{ secrets.GITHUB_TOKEN }}