Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

DEV Community

Cover image for Automate Semantic Versioning and Tagging with a Git Hook
Eduardo Delmoral
Eduardo Delmoral

Posted on

Automate Semantic Versioning and Tagging with a Git Hook

Managing version numbers and creating Git tags are essential parts of the software release cycle. Manually updating version files, creating commits, and tagging releases can be tedious, inconsistent, and prone to human error. This process can be automated using Git hooks based on commit message conventions.

This article presents a Git post-commit hook script written in Bash that automates semantic version bumping and GPG-signed tag creation, triggered by keywords in commit messages. Initially designed for Flutter projects, its core logic is adaptable to numerous other tech stacks.

The Problem: Manual Versioning Friction

In many workflows, developers manually perform several steps for versioning:

  1. Determine the version bump (Patch, Minor, Major).
  2. Update a version file (e.g., pubspec.yaml, package.json, pom.xml).
  3. Commit this change, often as a separate "Bump version" commit.
  4. Create a Git tag (preferably signed) pointing to the release commit.
  5. Optionally push the tag.

This multi-step process can interrupt development flow and introduce risks, such as forgetting to tag or using an incorrect version number.

The Solution: An Automated Post-Commit Hook

This Bash script utilizes Git's post-commit hook, executing automatically after a successful commit. Its workflow is as follows:

  1. Reads Commit Message: It inspects the message of the completed commit.
  2. Determines Bump Type: It searches for MAJOR or MINOR keywords (case-sensitive) in the message. If found, it prepares the corresponding bump; otherwise, it defaults to a PATCH bump.
  3. Reads & Bumps Version: It reads the current version from a designated project file (initially pubspec.yaml).
  4. Updates Version File: It increments the version according to the determined bump type and SemVer rules (e.g., resetting Minor/Patch on a Major bump) and writes the new version back.
  5. Amends Commit: It uses git commit --amend --no-edit --no-verify to include the version file update into the commit that just occurred without altering the commit message. A lock file mechanism prevents infinite loops.
  6. Creates Signed Tag: For MAJOR or MINOR bumps, it automatically creates a GPG-signed annotated Git tag (e.g., v1.2.0) pointing to the amended commit. Tagging for PATCH bumps is skipped.
  7. Optional Push: Includes a commented-out section to automatically push the new tag to a remote repository.

Here is the script (note the comments indicating sections to adjust for different project types):

#!/bin/bash

# Post-commit hook for semantic versioning and SIGNED tagging in Flutter projects
# v8 - Added commented-out section to push MAJOR/MINOR tags to remote.
#      Concise logging. Uses lock file for loop prevention.

# --- Configuration ---
set -e # Enabled by default: Exit immediately if a command exits with a non-zero status.
# set -x # Keep commented out unless extreme debugging is needed.

LOCK_FILE=".git/.post_commit_hook.lock" # Lock file to prevent infinite loops

# --- INFINITE LOOP PREVENTION ---
# Check if the lock file exists. If it does, this script was likely called
# recursively by the 'git commit --amend' below.
if [ -f "$LOCK_FILE" ]; then
    # Silently exit if this is the recursive call
    exit 0
fi
# --- END LOOP PREVENTION ---

# --- Ensure Lock File Cleanup ---
# The trap command registers cleanup actions to be performed when the script exits,
# regardless of the exit reason (success, error via 'set -e', or interrupt).
# This ensures the lock file is removed even if the script fails mid-execution.
trap 'rm -f "$LOCK_FILE"' EXIT SIGINT SIGTERM
# --- END CLEANUP ---

# Start logging only if this is not the recursive call
echo "--- [AutoVersion] Hook started ---"

PUBSPEC_FILE="pubspec.yaml" # ADJUST FILENAME FOR YOUR PROJECT TYPE

# 1. Check if pubspec.yaml exists and is readable/writable
if [ ! -f "$PUBSPEC_FILE" ]; then
    echo "[AutoVersion] Warning: $PUBSPEC_FILE not found. Skipping versioning."
    exit 0 # Exit cleanly, maybe commit didn't involve relevant files
fi
if [ ! -r "$PUBSPEC_FILE" ] || [ ! -w "$PUBSPEC_FILE" ]; then
    echo "[AutoVersion] Error: Cannot read/write $PUBSPEC_FILE. Check permissions. Aborting."
    exit 1 # Exit with error, trap will cleanup lock file if created (shouldn't be yet)
fi

# 2. Get the last commit message
COMMIT_MSG=$(git log -1 --pretty=%B)
if [ $? -ne 0 ]; then
    echo "[AutoVersion] Error: Failed to get commit message."
    exit 1 # trap cleans up
fi

# 3. Read the current version from the project file --- ADJUST THIS SECTION ---
# echo "[AutoVersion] Reading version from $PUBSPEC_FILE..." # Optional: uncomment for more verbosity
VERSION_LINE=$(grep -E '^[[:space:]]*version:[[:space:]]*' "$PUBSPEC_FILE" | head -n 1) # Read first match only
if [ -z "$VERSION_LINE" ]; then
    echo "[AutoVersion] Error: Could not find 'version:' line pattern in $PUBSPEC_FILE."
    exit 1 # trap cleans up
fi
# Extract full version (e.g., 1.2.3+4) - ADJUST PARSING AS NEEDED
CURRENT_VERSION_FULL=$(echo "$VERSION_LINE" | sed -E 's/^[[:space:]]*version:[[:space:]]*//')
# Extract semantic part (e.g., 1.2.3) - ADJUST PARSING AS NEEDED
CURRENT_VERSION_SEMANTIC=$(echo "$CURRENT_VERSION_FULL" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
# Extract build number part if present (e.g., +4) - ADJUST PARSING AS NEEDED
BUILD_NUMBER_PART=$(echo "$CURRENT_VERSION_FULL" | sed -n -E 's/^[0-9]+\.[0-9]+\.[0-9]+(\+.*)/\1/p') # Capture the '+'

# Validate semantic version format
if ! [[ "$CURRENT_VERSION_SEMANTIC" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "[AutoVersion] Error: Invalid semantic version format ('$CURRENT_VERSION_SEMANTIC') read from $PUBSPEC_FILE."
    exit 1 # trap cleans up
fi
echo "[AutoVersion] Current version: $CURRENT_VERSION_FULL."
# --- END ADJUSTABLE SECTION ---

# Parse semantic version components
MAJOR=$(echo "$CURRENT_VERSION_SEMANTIC" | cut -d. -f1)
MINOR=$(echo "$CURRENT_VERSION_SEMANTIC" | cut -d. -f2)
PATCH=$(echo "$CURRENT_VERSION_SEMANTIC" | cut -d. -f3)

# 4. Determine the bump type based on commit message keywords (MAJOR, MINOR)
NEW_MAJOR=$MAJOR
NEW_MINOR=$MINOR
NEW_PATCH=$PATCH
BUMP_TYPE="PATCH" # Default bump type
if echo "$COMMIT_MSG" | grep -q "MAJOR"; then
    NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0; BUMP_TYPE="MAJOR"
elif echo "$COMMIT_MSG" | grep -q "MINOR"; then
    NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0; BUMP_TYPE="MINOR"
else
    NEW_PATCH=$((PATCH + 1))
fi
echo "[AutoVersion] Detected bump type: $BUMP_TYPE."

# 5. Construct the new version string
NEW_VERSION_SEMANTIC="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
NEW_VERSION_FULL="${NEW_VERSION_SEMANTIC}${BUILD_NUMBER_PART}" # Keep original build number if present
NEW_TAG_NAME="v${NEW_VERSION_SEMANTIC}" # Tag name uses semantic part only
echo "[AutoVersion] New version: $NEW_VERSION_FULL."

# Compare versions before proceeding
if [ "$CURRENT_VERSION_FULL" == "$NEW_VERSION_FULL" ]; then
    echo "[AutoVersion] Warning: Calculated version is the same as current version. No bump needed."
    exit 0 # Exit cleanly, trap will cleanup
fi

# 6. Update the project file --- ADJUST THIS SECTION ---
echo "[AutoVersion] Updating $PUBSPEC_FILE..."
# This sed command assumes a simple 'key: value' line. Replace with appropriate command.
# Use sed -i without backup extension (common on Linux/GNU sed). macOS/BSD sed might require -i ''.
SED_COMMAND="sed -i 's@^\([[:space:]]*version:[[:space:]]*\).*@\1$NEW_VERSION_FULL@' \"$PUBSPEC_FILE\""
eval "$SED_COMMAND" # Use eval to handle potential complexities
SED_EXIT_CODE=$?
if [ $SED_EXIT_CODE -ne 0 ]; then
    echo "[AutoVersion] Error: Failed to update $PUBSPEC_FILE (Exit code: $SED_EXIT_CODE)."
    exit 1 # trap cleans up
fi
# --- END ADJUSTABLE SECTION ---

# 7. Stage the updated file
git add "$PUBSPEC_FILE"
if [ $? -ne 0 ]; then echo "[AutoVersion] Error: Failed to stage $PUBSPEC_FILE."; exit 1; fi # trap cleans up

# 8. Create lock file BEFORE amend, then amend the commit
touch "$LOCK_FILE"
if [ $? -ne 0 ]; then
    echo "[AutoVersion] Error: Failed to create lock file '$LOCK_FILE'! Aborting before amend."
    exit 1 # trap cleans up
fi
echo "[AutoVersion] Amending commit to include version bump..."
git commit --amend --no-edit --no-verify > /dev/null 2>&1 # Suppress output
GIT_AMEND_EXIT_CODE=$?
if [ $GIT_AMEND_EXIT_CODE -ne 0 ]; then
    echo "[AutoVersion] Error: Failed to amend commit (Exit code: $GIT_AMEND_EXIT_CODE)."
    exit 1 # trap cleans up
fi

# 9. Create and Optionally Push SIGNED Tag (ONLY for MAJOR/MINOR bumps!)
if [ "$BUMP_TYPE" = "MAJOR" ] || [ "$BUMP_TYPE" = "MINOR" ]; then
    echo "[AutoVersion] $BUMP_TYPE bump detected, processing tag..."
    if git rev-parse "$NEW_TAG_NAME" >/dev/null 2>&1; then
        echo "[AutoVersion] Warning: Tag '$NEW_TAG_NAME' already exists locally. Skipping creation and push."
    else
        echo "[AutoVersion] Creating signed tag locally: $NEW_TAG_NAME..."
        TAG_MESSAGE=$(printf "Version %s\n\nCommit: %s" "$NEW_TAG_NAME" "$COMMIT_MSG")
        git tag -s "$NEW_TAG_NAME" -m "$TAG_MESSAGE" > /dev/null 2>&1 # Suppress GPG output
        GIT_TAG_EXIT_CODE=$?
        if [ $GIT_TAG_EXIT_CODE -ne 0 ]; then
            echo "[AutoVersion] Error: Failed to create SIGNED tag '$NEW_TAG_NAME' (Exit code: $GIT_TAG_EXIT_CODE)."
            echo "[AutoVersion]         Check GPG setup ('user.signingkey') and passphrase availability (gpg-agent?)."
            exit 1 # Fail if tag creation fails, trap cleans up lock file
        fi
        echo "[AutoVersion] SIGNED tag '$NEW_TAG_NAME' created locally."

        # --- Optional Section: Push Tag to Remote ---
        # Instructions:
        # 1. Ensure you have a remote repository configured (e.g., 'origin'). Check with: git remote -v
        # 2. Uncomment the following lines to enable automatic tag pushing.
        # --------------------------------------------------
        # echo "[AutoVersion] Attempting to push tag $NEW_TAG_NAME to origin..."
        # git push origin "$NEW_TAG_NAME"
        # GIT_PUSH_EXIT_CODE=$?
        # if [ $GIT_PUSH_EXIT_CODE -ne 0 ]; then
        #     # Warn but do not fail the hook if only push fails
        #     echo "[AutoVersion] Warning: Failed to push tag '$NEW_TAG_NAME' to origin (Exit code: $GIT_PUSH_EXIT_CODE)."
        #     echo "[AutoVersion]            You may need to push it manually: git push origin $NEW_TAG_NAME"
        #     # exit 1 # Uncomment if push failure should stop the process
        # else
        #     echo "[AutoVersion] Tag '$NEW_TAG_NAME' pushed to origin successfully."
        # fi
        # --- End Optional Section ---
    fi
else
    echo "[AutoVersion] $BUMP_TYPE bump does not require tag creation."
fi

echo "--- [AutoVersion] Hook finished successfully ---"
exit 0
Enter fullscreen mode Exit fullscreen mode

Key Advantages

Using this hook provides several benefits:

  • Automation & Consistency: Ensures versioning and tagging occur predictably.
  • Reduced Error: Minimizes manual mistakes in version numbers or tagging.
  • Semantic Versioning Convention: Links version bumps directly to commit message intent.
  • Integrated Signed Tagging: Automatically creates secure, verifiable tags for MINOR/MAJOR increments.
  • Streamlined Workflow: Reduces manual steps for developers.
  • Atomic Version Bumps: Includes the version bump within the relevant commit via amend, avoiding separate "Bump version" commits.

Adapting to Your Project

This script relies on standard Git commands and Bash scripting, making it adaptable. To use it with other project types (Node.js, Python, Java, PHP, Ruby, .NET, Go, etc.), focus on adjusting how the script interacts with your project's version file:

  1. Update PUBSPEC_FILE Variable: Change pubspec.yaml to your project's version file (e.g., package.json, pom.xml, _version.py).
  2. Modify Version Reading Logic (Section 3): Replace the grep/sed commands tailored for pubspec.yaml with tools appropriate for your file format.
    • JSON (package.json, composer.json): Use jq (e.g., jq -r .version package.json).
    • XML (pom.xml, .csproj): Use xmlstarlet (e.g., xmlstarlet sel -t -v '/_:project/_:version' pom.xml).
    • Python (__version__ = "..."): Adapt grep/sed patterns.
    • Others: Use relevant command-line tools or call helper scripts.
  3. Modify Version Writing Logic (Section 6): Replace the sed update command with the correct method for your format.
    • JSON: jq ".version = \"$NEW_VERSION_FULL\"" package.json > tmp && mv tmp package.json
    • XML: xmlstarlet ed -L -u '/_:project/_:version' -v "$NEW_VERSION_FULL" pom.xml
    • Python: Adapt sed patterns. While some ecosystems offer built-in tools like npm version, this hook provides a GPG-signed, commit-message-driven workflow that can be integrated across different environments with these specific modifications.

Implementation

  1. Save the script content to .git/hooks/post-commit in your repository.
  2. Make it executable: chmod +x .git/hooks/post-commit.
  3. Ensure GPG is configured with Git for signed tags (git config --global user.signingkey YOUR_KEY_ID).
  4. Commit using messages like feat: New feature MINOR, fix: Bug fix, or refactor: API change MAJOR.
  5. (Optional) Edit the script to uncomment the git push lines when a remote is configured. (Note: Git hooks are not typically shared via the repository. Each developer needs to place the hook in their local .git/hooks directory.)

Conclusion

Automating tasks like version bumping and tagging improves release process reliability and efficiency. This adaptable post-commit hook offers a straightforward, convention-driven method using standard tools. By tailoring the file interaction logic to your specific project, you can streamline your workflow and ensure consistent, semantically versioned, and securely tagged releases.

Top comments (0)