name: 'Grubble - Semantic Version Bump'
description: 'Automatically bump semantic version based on conventional commits'
author: 'David Garvey'
branding:
icon: 'arrow-up'
color: 'orange'
inputs:
push:
description: 'Push changes to remote'
required: false
default: 'false'
tag:
description: 'Create git tag for the version'
required: false
default: 'false'
release-notes:
description: 'Include release notes in the git tag annotation'
required: false
default: 'false'
raw:
description: 'Output only the new version string (dry run, no changes)'
required: false
default: 'false'
quiet:
description: 'Suppress commit list output'
required: false
default: 'false'
preset:
description: 'Versioning strategy (node, rust, git)'
required: false
default: ''
tag-prefix:
description: 'Prefix for git tags'
required: false
default: ''
commit-prefix:
description: 'Prefix for commit messages'
required: false
default: ''
package-files:
description: 'Comma-separated list of files to update'
required: false
default: ''
git-user-name:
description: 'Git user name for commits'
required: false
default: 'github-actions[bot]'
git-user-email:
description: 'Git user email for commits'
required: false
default: '41898282+github-actions[bot]@users.noreply.github.com'
update-major-tag:
description: 'Update major version tag (e.g., v4 -> v4.x.x)'
required: false
default: 'false'
update-minor-tag:
description: 'Update minor version tag (e.g., v4.1 -> v4.1.x)'
required: false
default: 'false'
changelog:
description: 'Generate and maintain a CHANGELOG.md file'
required: false
default: 'false'
dry-run:
description: 'Check if bump is needed without making changes. Does not modify any files; exit code 0 in all non-error cases. Use --bump-type to detect whether a bump would occur.'
required: false
default: 'false'
bump-type-only:
description: 'Output only the bump type and exit (major, minor, patch, or none)'
required: false
default: 'false'
branch:
description: 'Push the bump to this branch instead of HEAD (e.g., release/v0.35.0). When set, uses `git push --set-upstream origin <branch>`. The branch is created locally if it does not exist.'
required: false
default: ''
create-pr:
description: 'After pushing, automatically open a pull request from the branch to the default branch. Requires `push: true`. When `branch` is not set, it is auto-generated as `release/v<version>`.'
required: false
default: 'false'
auto-merge:
description: 'Enable auto-merge with squash on the created PR. Requires `create-pr` to be set. The PR will merge when branch protection requirements (status checks, reviews) pass. WARNING: GitHub does not allow a bot-created PR to approve itself, so if your branch requires review approval the PR will sit in `Waiting for approval` indefinitely. Prefer the release-please flow (no `auto-merge`; a human merges the release PR) when reviews are required — see the README.'
required: false
default: 'false'
token:
description: 'Optional: a GitHub token (PAT or GitHub App installation token) to use for git push authentication. Only needed if you must bypass branch protection. When set, the remote URL is rewritten with the token before grubble runs. Prefer the `branch` + `create-pr` flow instead.'
required: false
default: ''
release-from-pr:
description: 'Run `grubble --release-from-pr <NUMBER>` instead of the bump. When set, the bump-related steps are skipped and the resolved tag spec is emitted as the `release-spec` output. Use this in a post-merge workflow step after a release PR has been merged. Requires `GH_TOKEN` or `GITHUB_TOKEN` in the env. The Action is read-only in this mode (no commits, tags, or pushes).'
required: false
default: ''
outputs:
version:
description: 'The new version number (empty in `release-from-pr` mode)'
value: ${{ steps.bump.outputs.version }}
previous-version:
description: 'The previous version number (empty in `release-from-pr` mode)'
value: ${{ steps.bump.outputs.previous_version }}
bump-type:
description: 'The type of version bump (major, minor, patch, none; empty in `release-from-pr` mode)'
value: ${{ steps.bump.outputs.bump_type }}
branch:
description: 'The branch that was pushed to (auto-generated or user-specified; empty in `release-from-pr` mode)'
value: ${{ steps.bump.outputs.branch }}
release-spec:
description: 'JSON tag spec from `grubble --release-from-pr`. Contains `version`, `tag_name`, `major_tag_name`, `merge_commit_sha`, `title`, and `body`. Empty when `release-from-pr` is not set.'
value: ${{ steps.resolve_release.outputs.spec }}
runs:
using: 'composite'
steps:
- name: Detect OS and Architecture
id: detect
shell: bash
run: |
OS="${RUNNER_OS}"
ARCH="${RUNNER_ARCH}"
if [ "$OS" = "Linux" ]; then
if [ "$ARCH" = "X64" ]; then
BINARY="grubble-linux-x86_64"
elif [ "$ARCH" = "ARM64" ]; then
BINARY="grubble-linux-aarch64"
else
echo "::error::Unsupported architecture: $ARCH"
exit 1
fi
ARCHIVE="${BINARY}.tar.gz"
elif [ "$OS" = "macOS" ]; then
if [ "$ARCH" = "X64" ]; then
BINARY="grubble-macos-x86_64"
elif [ "$ARCH" = "ARM64" ]; then
BINARY="grubble-macos-aarch64"
else
echo "::error::Unsupported architecture: $ARCH"
exit 1
fi
ARCHIVE="${BINARY}.tar.gz"
elif [ "$OS" = "Windows" ]; then
if [ "$ARCH" = "X64" ]; then
BINARY="grubble-windows-x86_64.exe"
else
echo "::error::Unsupported architecture: $ARCH"
exit 1
fi
ARCHIVE="${BINARY}.zip"
else
echo "::error::Unsupported operating system: $OS"
exit 1
fi
echo "binary=$BINARY" >> $GITHUB_OUTPUT
echo "archive=$ARCHIVE" >> $GITHUB_OUTPUT
echo "os=$OS" >> $GITHUB_OUTPUT
- name: Resolve release version
id: release
env:
ACTION_REF: ${{ github.action_ref }}
shell: bash
run: |
REF="$ACTION_REF"
REF="${REF#refs/tags/}"
REF="${REF#refs/heads/}"
# gh is pre-installed on GitHub-hosted runners. When GITHUB_TOKEN is
# set, use authenticated API calls to avoid the 60 req/hr rate limit.
USE_AUTH=false
if command -v gh &>/dev/null && [ -n "${GITHUB_TOKEN:-}" ]; then
USE_AUTH=true
else
echo "::warning::GITHUB_TOKEN not available — using unauthenticated API (rate-limited to 60 req/hr)"
fi
# Fetch a single resource via the GitHub API.
api_get() {
local url="$1" jq_expr="$2"
if [ "$USE_AUTH" = "true" ]; then
gh api "$url" --jq "$jq_expr" 2>/dev/null || echo ""
else
curl -sS "https://api.github.com$url" | jq -r "$jq_expr" 2>/dev/null || echo ""
fi
}
# Paginate through a list resource, returning newline-separated values.
api_list() {
local url="$1" jq_expr="$2"
if [ "$USE_AUTH" = "true" ]; then
gh api "$url" --jq "$jq_expr" --paginate 2>/dev/null || true
else
curl -sS "https://api.github.com${url}?per_page=100" | jq -r "$jq_expr" 2>/dev/null || true
fi
}
# Check whether a given release tag has binary assets.
release_has_assets() {
local tag="$1" count
count=$(api_get "/repos/davegarvey/grubble/releases/tags/$tag" '(.assets | length) // 0')
[ "${count:-0}" -gt 0 ] 2>/dev/null
}
RESOLVED_VERSION=""
# 1. Direct semver tag (vX.Y.Z)
if echo "$REF" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
RESOLVED_VERSION=$(api_get "/repos/davegarvey/grubble/releases/tags/$REF" '.tag_name')
if [ -z "$RESOLVED_VERSION" ] || [ "$RESOLVED_VERSION" = "null" ]; then
echo "::warning::Release tag '$REF' not found on GitHub"
RESOLVED_VERSION=""
elif ! release_has_assets "$REF"; then
echo "::error::Release '$REF' has no downloadable binary assets."
echo "::error::Pin to a version with assets (e.g., @v5.3.0) or use @v5 to track the latest."
exit 1
fi
fi
# 2. Floating minor tag (vX.Y) — resolve to latest matching semver
if [ -z "$RESOLVED_VERSION" ] && echo "$REF" | grep -qE '^v[0-9]+\.[0-9]+$'; then
RESOLVED_VERSION=$(api_list "/repos/davegarvey/grubble/releases" '[.[] | select(.assets | length > 0) | .tag_name] | .[]' | grep -E "^${REF//./\\.}\." | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
fi
# 3. Floating major tag (vX) — resolve to latest matching semver
if [ -z "$RESOLVED_VERSION" ] && echo "$REF" | grep -qE '^v[0-9]+$'; then
RESOLVED_VERSION=$(api_list "/repos/davegarvey/grubble/releases" '[.[] | select(.assets | length > 0) | .tag_name] | .[]' | grep -E "^${REF//./\\.}\." | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)
fi
# 4. Fallback: latest release
if [ -z "$RESOLVED_VERSION" ]; then
RESOLVED_VERSION=$(api_get "/repos/davegarvey/grubble/releases/latest" '.tag_name')
if [ -z "$RESOLVED_VERSION" ] || [ "$RESOLVED_VERSION" = "null" ]; then
echo "::error::Failed to fetch latest release version from GitHub API"
exit 1
fi
if ! release_has_assets "$RESOLVED_VERSION"; then
echo "::error::Latest release '$RESOLVED_VERSION' has no binary assets."
echo "::error::No downloadable grubble release is available."
echo "::error::Install via 'cargo install grubble' or open an issue."
exit 1
fi
fi
echo "version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
echo "Using grubble version: $RESOLVED_VERSION"
- name: Download binary
shell: bash
run: |
echo "::group::Download grubble binary"
DOWNLOAD_URL="https://github.com/davegarvey/grubble/releases/download/${{ steps.release.outputs.version }}/${{ steps.detect.outputs.archive }}"
if ! curl -sSL -f -o "${{ steps.detect.outputs.archive }}" "$DOWNLOAD_URL"; then
echo "::error::Failed to download binary from $DOWNLOAD_URL"
exit 1
fi
echo "Downloaded ${{ steps.detect.outputs.archive }}"
echo "::endgroup::"
- name: Verify checksum
shell: bash
run: |
echo "::group::Verify checksum"
CHECKSUM_URL="https://github.com/davegarvey/grubble/releases/download/${{ steps.release.outputs.version }}/${{ steps.detect.outputs.archive }}.sha256"
if ! curl -sSL -f -o "${{ steps.detect.outputs.archive }}.sha256" "$CHECKSUM_URL"; then
echo "::error::Failed to download checksum file from $CHECKSUM_URL"
exit 1
fi
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
# Windows verification
EXPECTED=$(cat "${{ steps.detect.outputs.archive }}.sha256" | awk '{print $1}')
ACTUAL=$(certutil -hashfile "${{ steps.detect.outputs.archive }}" SHA256 | findstr /v "hash" | findstr /v "CertUtil")
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Checksum verification failed"
exit 1
fi
else
# Unix verification
if ! shasum -a 256 -c "${{ steps.detect.outputs.archive }}.sha256"; then
echo "::error::Checksum verification failed"
exit 1
fi
fi
echo "✓ Checksum verified"
echo "::endgroup::"
- name: Extract binary (Unix)
if: steps.detect.outputs.os != 'Windows'
shell: bash
run: |
echo "::group::Extract binary"
tar xzf "${{ steps.detect.outputs.archive }}"
chmod +x grubble
echo "✓ Binary ready"
echo "::endgroup::"
- name: Extract binary (Windows)
if: steps.detect.outputs.os == 'Windows'
shell: pwsh
run: |
Write-Output "::group::Extract binary"
Expand-Archive -Path "${{ steps.detect.outputs.archive }}" -DestinationPath . -Force
Write-Output "✓ Binary ready"
Write-Output "::endgroup::"
- name: Validate inputs
if: inputs.create-pr == 'true' || inputs.auto-merge == 'true'
shell: bash
run: |
if [ "${{ inputs.create-pr }}" = "true" ] && [ "${{ inputs.push }}" != "true" ]; then
echo "::error::create-pr requires push to be set."
exit 1
fi
if [ "${{ inputs.auto-merge }}" = "true" ] && [ "${{ inputs.create-pr }}" != "true" ]; then
echo "::error::auto-merge requires create-pr to be set."
exit 1
fi
- name: Set up push credentials
if: inputs.token != ''
shell: bash
run: |
echo "::add-mask::${{ inputs.token }}"
git remote set-url origin "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}"
echo "✓ Remote URL updated with custom token"
- name: Get current version
if: inputs.release-from-pr == ''
id: current
shell: bash
run: |
# --raw honors --preset (v5+). Pass through the preset; the binary
# resolves the current version from the correct source.
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
CURRENT_VERSION=$(./grubble.exe --raw --preset "${{ inputs.preset }}" 2>/dev/null) || true
else
CURRENT_VERSION=$(./grubble --raw --preset "${{ inputs.preset }}" 2>/dev/null) || true
fi
if [ -z "$CURRENT_VERSION" ]; then
CURRENT_VERSION="0.0.0"
fi
echo "previous_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Run bump
if: inputs.release-from-pr == ''
id: bump
shell: bash
run: |
ARGS=""
# Handle bump-type-only mode first
if [ "${{ inputs.bump-type-only }}" = "true" ]; then
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
BUMP_TYPE=$(./grubble.exe --bump-type)
else
BUMP_TYPE=$(./grubble --bump-type)
fi
echo "$BUMP_TYPE"
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
exit 0
fi
# Handle dry-run mode
if [ "${{ inputs.dry-run }}" = "true" ]; then
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
./grubble.exe --dry-run
else
./grubble --dry-run
fi
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
BUMP_TYPE=$(./grubble.exe --bump-type)
else
BUMP_TYPE=$(./grubble --bump-type)
fi
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
exit 0
fi
# When create-pr is set without an explicit branch, the binary commits
# locally (no --push), then the "Push release branch" step creates the
# branch and pushes it.
if [ "${{ inputs.push }}" = "true" ]; then
if [ "${{ inputs.create-pr }}" != "true" ] || [ -n "${{ inputs.branch }}" ]; then
ARGS="$ARGS --push"
fi
fi
if [ "${{ inputs.tag }}" = "true" ]; then
ARGS="$ARGS --tag"
fi
if [ "${{ inputs.release-notes }}" = "true" ]; then
ARGS="$ARGS --release-notes"
fi
if [ "${{ inputs.raw }}" = "true" ]; then
ARGS="$ARGS --raw"
fi
if [ "${{ inputs.quiet }}" = "true" ]; then
ARGS="$ARGS --quiet"
fi
if [ -n "${{ inputs.preset }}" ]; then
ARGS="$ARGS --preset ${{ inputs.preset }}"
fi
if [ -n "${{ inputs.tag-prefix }}" ]; then
ARGS="$ARGS --tag-prefix ${{ inputs.tag-prefix }}"
fi
if [ -n "${{ inputs.commit-prefix }}" ]; then
ARGS="$ARGS --commit-prefix \"${{ inputs.commit-prefix }}\""
fi
if [ -n "${{ inputs.package-files }}" ]; then
ARGS="$ARGS --package-files ${{ inputs.package-files }}"
fi
if [ -n "${{ inputs.git-user-name }}" ]; then
ARGS="$ARGS --git-user-name \"${{ inputs.git-user-name }}\""
fi
if [ -n "${{ inputs.git-user-email }}" ]; then
ARGS="$ARGS --git-user-email \"${{ inputs.git-user-email }}\""
fi
if [ "${{ inputs.update-major-tag }}" = "true" ]; then
ARGS="$ARGS --update-major-tag"
fi
if [ "${{ inputs.update-minor-tag }}" = "true" ]; then
ARGS="$ARGS --update-minor-tag"
fi
if [ "${{ inputs.changelog }}" = "true" ]; then
ARGS="$ARGS --changelog"
fi
if [ -n "${{ inputs.branch }}" ]; then
ARGS="$ARGS --git-branch ${{ inputs.branch }}"
fi
# Run grubble. v5 contract: exit 0 = success (bumped or clean no-op),
# non-zero = error. set -e (default in composite action steps) handles
# the failure case automatically.
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
./grubble.exe $ARGS
else
./grubble $ARGS
fi
# Get the new version — forward config flags so --raw uses the same preset and files.
# Only version-relevant flags (not action flags like --push/--tag) to keep --raw a pure read.
# v5 contract: --raw exits 0 whenever a version is produced, so no exit-code handling needed.
READ_ARGS="--raw"
if [ -n "${{ inputs.preset }}" ]; then
READ_ARGS="$READ_ARGS --preset ${{ inputs.preset }}"
fi
if [ -n "${{ inputs.package-files }}" ]; then
READ_ARGS="$READ_ARGS --package-files ${{ inputs.package-files }}"
fi
if [ -n "${{ inputs.tag-prefix }}" ]; then
READ_ARGS="$READ_ARGS --tag-prefix ${{ inputs.tag-prefix }}"
fi
if [ -n "${{ inputs.commit-prefix }}" ]; then
READ_ARGS="$READ_ARGS --commit-prefix \"${{ inputs.commit-prefix }}\""
fi
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
NEW_VERSION=$(./grubble.exe $READ_ARGS)
else
NEW_VERSION=$(./grubble $READ_ARGS)
fi
if [ -z "$NEW_VERSION" ]; then
NEW_VERSION="${{ steps.current.outputs.previous_version }}"
fi
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
# Determine bump type
PREV="${{ steps.current.outputs.previous_version }}"
if [ "$NEW_VERSION" != "$PREV" ]; then
PREV_MAJOR=$(echo $PREV | cut -d. -f1)
PREV_MINOR=$(echo $PREV | cut -d. -f2)
PREV_PATCH=$(echo $PREV | cut -d. -f3)
NEW_MAJOR=$(echo $NEW_VERSION | cut -d. -f1)
NEW_MINOR=$(echo $NEW_VERSION | cut -d. -f2)
NEW_PATCH=$(echo $NEW_VERSION | cut -d. -f3)
if [ "$NEW_MAJOR" != "$PREV_MAJOR" ]; then
echo "bump_type=major" >> $GITHUB_OUTPUT
elif [ "$NEW_MINOR" != "$PREV_MINOR" ]; then
echo "bump_type=minor" >> $GITHUB_OUTPUT
elif [ "$NEW_PATCH" != "$PREV_PATCH" ]; then
echo "bump_type=patch" >> $GITHUB_OUTPUT
else
echo "bump_type=none" >> $GITHUB_OUTPUT
fi
else
echo "bump_type=none" >> $GITHUB_OUTPUT
fi
echo "previous_version=$PREV" >> $GITHUB_OUTPUT
# Branch output: explicit input > auto-generated release/v<NEW_VERSION> when create-pr is on
# and a bump happened > empty. The "Push release branch" and "Create release PR" steps read this.
if [ -n "${{ inputs.branch }}" ]; then
echo "branch=${{ inputs.branch }}" >> $GITHUB_OUTPUT
elif [ "${{ inputs.create-pr }}" = "true" ] && [ "$NEW_VERSION" != "$PREV" ]; then
echo "branch=release/v${NEW_VERSION}" >> $GITHUB_OUTPUT
else
echo "branch=" >> $GITHUB_OUTPUT
fi
- name: Push release branch
if: inputs.release-from-pr == '' && steps.bump.outputs.bump_type != 'none' && inputs.create-pr == 'true' && inputs.branch == '' && steps.bump.outputs.branch != ''
shell: bash
run: |
BRANCH="${{ steps.bump.outputs.branch }}"
git checkout -B "${BRANCH}"
git push --set-upstream origin "${BRANCH}" --force
git push --tags --force
- name: Create release PR
id: pr
if: inputs.release-from-pr == '' && inputs.create-pr == 'true' && steps.bump.outputs.bump_type != 'none'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
VERSION="${{ steps.bump.outputs.version }}"
PREV="${{ steps.bump.outputs.previous_version }}"
BUMP_TYPE="${{ steps.bump.outputs.bump_type }}"
BRANCH="${{ steps.bump.outputs.branch }}"
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
BODY="Automated release PR created by grubble.
- Version: v${VERSION}
- Previous: v${PREV}
- Bump type: ${BUMP_TYPE}"
PR_URL=$(gh pr create \
--base "${DEFAULT_BRANCH}" \
--head "${BRANCH}" \
--title "Release v${VERSION}" \
--body "${BODY}")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge
if: inputs.release-from-pr == '' && inputs.auto-merge == 'true' && inputs.create-pr == 'true' && steps.bump.outputs.bump_type != 'none'
shell: bash
run: |
if [ -n "${{ steps.pr.outputs.pr_url }}" ]; then
gh pr merge "${{ steps.pr.outputs.pr_url }}" --auto --squash
fi
- name: Resolve release from PR
if: inputs.release-from-pr != ''
id: resolve_release
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
# Read-only resolution: query the GitHub API for the PR, validate the
# head branch matches ^release/v\d+\.\d+\.\d+$ and the PR is merged,
# and emit the tag spec as a JSON output. No commits, tags, or pushes.
if [ "${{ steps.detect.outputs.os }}" = "Windows" ]; then
SPEC=$(./grubble.exe --release-from-pr "${{ inputs.release-from-pr }}" --output json)
else
SPEC=$(./grubble --release-from-pr "${{ inputs.release-from-pr }}" --output json)
fi
echo "$SPEC"
# Emit the spec as a multi-line output. The trailing newline on
# `spec<<EOF` is required by GitHub's output format spec.
{
echo "spec<<EOF"
echo "$SPEC"
echo "EOF"
} >> "$GITHUB_OUTPUT"