GitHub Actions can automate semantic versioned releases by orchestrating the process of bumping versions, creating Git tags, and publishing release artifacts.

Let’s look at a real-world example of how this might play out. Imagine a Node.js project where the package.json file holds the current version.

{
  "name": "my-awesome-package",
  "version": "1.2.0",
  "description": "A package that does awesome things",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

When a new feature is merged into the main branch, we want to trigger a release. This release should be a "minor" version bump because we’re adding a new, backward-compatible feature. The version should go from 1.2.0 to 1.3.0.

Here’s a simplified GitHub Actions workflow that accomplishes this:

name: Semantic Release

on:
  push:
    branches:
      - main

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Crucial for git history

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Get latest tag
        id: get_tag
        run: |
          LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
          echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT

      - name: Determine next version and tag
        id: semantic_release
        env:

          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


          LAST_TAG: ${{ steps.get_tag.outputs.latest_tag }}

        run: |
          # This script would contain logic to analyze commits since LAST_TAG
          # and determine if it's a patch, minor, or major bump based on commit messages (e.g., conventional commits).
          # For demonstration, let's assume a minor bump.
          NEXT_VERSION="1.3.0" # In a real scenario, this is dynamically calculated
          NEW_TAG="v${NEXT_VERSION}"

          echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT
          echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT

      - name: Update package.json version
        run: |

          npm version ${{ steps.semantic_release.outputs.next_version }} --no-git-tag-version

          git add package.json

          git commit -m "chore(release): bump version to ${{ steps.semantic_release.outputs.next_version }}"


      - name: Create Git Tag
        run: |

          git tag ${{ steps.semantic_release.outputs.new_tag }}


          git push origin ${{ steps.semantic_release.outputs.new_tag }}


      - name: Publish Release Notes
        uses: softprops/action-gh-release@v1
        with:

          tag_name: ${{ steps.semantic_release.outputs.new_tag }}

          body: |

            Release ${{ steps.semantic_release.outputs.new_tag }}


            Changes:
            - Added new feature (minor bump)
          # draft: true # Uncomment to create a draft release
          # prerelease: true # Uncomment for pre-release versions
        env:

          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

This workflow breaks down the release process:

  1. Checkout code: Fetches the repository’s content. fetch-depth: 0 is essential because we need the full Git history to analyze commits.
  2. Set up Node.js: Configures the environment to run Node.js commands.
  3. Get latest tag: Uses git describe to find the most recent Git tag. If no tags exist, it defaults to v0.0.0. This is the baseline for determining what has changed.
  4. Determine next version and tag: This is where the core semantic versioning logic lives. In a real system, you’d integrate a tool like semantic-release or a custom script that inspects commit messages (following conventions like Conventional Commits) since the LAST_TAG. It then calculates whether the next version should be a patch, minor, or major bump and constructs the new tag (e.g., v1.3.0).
  5. Update package.json version: The npm version command is used to update the version field in package.json. We use --no-git-tag-version because we’re handling the tag creation manually later. A commit is made to record this version bump in the history.
  6. Create Git Tag: A Git tag is created pointing to the commit that bumped the version. This tag is then pushed to the remote repository.
  7. Publish Release Notes: The softprops/action-gh-release action creates a release on GitHub, using the generated tag and providing release notes. These notes would typically be generated dynamically based on the commit messages since the last release.

The power of this approach lies in its automation and adherence to a defined versioning scheme. By analyzing commit messages, the system can automatically determine the type of change (bug fix, new feature, breaking change) and apply the corresponding version bump (patch, minor, major). This provides predictability for consumers of your library or application.

A common pitfall is not fetching enough Git history. If fetch-depth is not set to 0 (or a sufficiently large number), the git describe command might not find the last tag, leading to incorrect version calculations or failures.

The next concept you’ll likely encounter is how to handle different types of releases, such as pre-releases (alpha, beta, rc) or how to integrate this with dependency management tools beyond npm, like Maven for Java or pip for Python.

Want structured learning?

Take the full Github-actions course →