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.'
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: ''
outputs:
version:
description: 'The new version number'
value: ${{ steps.bump.outputs.version }}
previous-version:
description: 'The previous version number'
value: ${{ steps.bump.outputs.previous_version }}
bump-type:
description: 'The type of version bump (major, minor, patch, none)'
value: ${{ steps.bump.outputs.bump_type }}
branch:
description: 'The branch that was pushed to (auto-generated or user-specified)'
value: ${{ steps.bump.outputs.branch }}
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: Get latest release
id: release
shell: bash
run: |
RELEASE_URL="https://api.github.com/repos/davegarvey/grubble/releases/latest"
RELEASE_DATA=$(curl -sS "$RELEASE_URL")
VERSION=$(echo "$RELEASE_DATA" | jq -r '.tag_name')
if [ "$VERSION" = "null" ] || [ -z "$VERSION" ]; then
echo "::error::Failed to fetch latest release version"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using grubble version: $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
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
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, we auto-generate
# the branch name from the new version. The binary commits locally
# (no --push), then the action creates the branch and pushes it.
PUSHING_TO_BRANCH=false
if [ "${{ inputs.push }}" = "true" ]; then
if [ "${{ inputs.create-pr }}" = "true" ] && [ -z "${{ inputs.branch }}" ]; then
PUSHING_TO_BRANCH=true
else
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
- name: Create release PR
id: pr
if: inputs.create-pr == 'true' && steps.bump.outputs.bump_type != 'none'
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.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
# Auto-generated branch: create branch from the local commit and push it.
if [ "$PUSHING_TO_BRANCH" = "true" ] && [ "$NEW_VERSION" != "$PREV" ]; then
BRANCH="release/v${NEW_VERSION}"
git checkout -b "${BRANCH}"
git push --set-upstream origin "${BRANCH}" 2>&1
git push --tags --force 2>&1
echo "branch=${BRANCH}" >> $GITHUB_OUTPUT
fi
if [ -n "${{ inputs.branch }}" ]; then
echo "branch=${{ inputs.branch }}" >> $GITHUB_OUTPUT
fi