release #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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<<EOF_CHANGELOG' | |
| echo "$SECTION" | |
| echo 'EOF_CHANGELOG' | |
| } >> "$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 }} |