Ditch the Manual Chore: Automating Releases and Versioning with release-please


I love coding, I love seeing new features come to life, and I genuinely enjoy writing documentation (I know, weird right?). But if there’s one part of the development lifecycle that consistently makes me want to scream into my pillow, it’s the tedious, error-prone chore of creating a new release.

  • Updating the version number manually? Check.
  • Scouring Git history to draft a meaningful CHANGELOG.md? Check.
  • Forgetting a fix and having to immediately push a v1.2.1-fix-for-the-fix patch? Absolutely check.

If you’ve been following my journey on automating my workflows, you’ll know that I’m a huge believer in moving the mundane to the machine. I previously discussed streamlining development using Semantic Release and Commitizen in my article, Automated release with Semantic Release and Commitizen. This approach dramatically cleaned up commit messages and helped determine the correct version bump.

However, over time I have found that with semantic-release, there are some limitations if you are trying to move over to trunk based development and are not in a position to use feature flags.

The missing piece? release-please.




What is release-please and How Does It Improve the Workflow?

release-please is an open-source tool developed by Google, primarily implemented as a GitHub Action, that takes over the final, messy steps of the release process.

The fundamental improvement over a self-hosted Semantic Release setup is simple:

Approach Old Way (Semantic Release/Custom Script) New Way (release-please Action)
Trigger Manual command or a final merge/tag event. Continuously tracks changes on the main branch.
Release Artifacts Generated by script, then pushed/tagged manually or via subsequent CI job. Manages a Perpetual Release PR for instant preview.
Human Action Manually run a command/pipeline and hope it works. Merge the Release PR to confirm and publish.

The core of release-please is that it constantly maintains a living, breathing view of your next release right inside your GitHub Pull Requests tab, operating entirely on the principles of Conventional Commits.



The Core Logic: Commits to Versions

The tool reads the commit history on your main branch to determine the exact nature of the change:

Commit Type Semantic Version Impact Example of Change
fix: PATCH (0.0.x) A backward-compatible bug fix.
feat: MINOR (0.x.0) A new, backward-compatible feature.
feat!: or BREAKING CHANGE: in footer MAJOR (x.0.0) An incompatible API change.

You stop having to worry about which version number to apply; you just commit changes with accurate commit messages, and release-please figures out the rest.



How does it work?

First off, it’s important to know that release-please will not trigger a release if there is nothing to release! If no new feat or fix commits have landed, it just sits tight.

When it does have work to do, here’s the flow:

  1. Detect Changes: The action runs (either on a schedule or when I push to the main branch) and scans the repository’s history to find all the changes since the last release tag.

  2. Analyse Commits: It then analyzes all the commit messages, looking for Conventional Commit keywords (like feat, fix, chore, or BREAKING CHANGE) to categorize everything that’s happened.

  3. Determine Version Bump: Based on those commits, it automatically determines the correct semantic version bump (major, minor, or patch).

  4. Generate Release PR: This is the best part. The action creates (or updates) a special Pull Request. This PR contains the new version number bumped in files like package.json and, crucially, a fully generated changelog based on all the commits it found.

  5. Merge Release PR: Once I’ve reviewed that PR and I’m happy it looks correct, I just merge it into the main branch. This merge event is the trigger for the next step.

  6. Publish Release: Merging the PR kicks off another workflow that creates the official Git tag, drafts the formal GitHub release (using the changelog), and publishes the new version to package registries, like npm for my JavaScript projects.




Implementation: Configuration and Action

Getting started is surprisingly simple and requires minimal configuration for most projects.



1. Configuration File (release-please-config.json)

For most projects, defining a single file to tell the tool what type of project it is, is enough. This example sets up a generic project, ensuring the tool knows where to look for version updates.

{
  "release-type": "simple",
  "include-v-in-tag": true
}
Enter fullscreen mode

Exit fullscreen mode



2. Manifest file (.release-please-manifest.json)

The manifest file tracks the version that the project is currently on, for existing projects enter the latest release tag, for a new project use 0.0.0 to tell release-please its a new project.

{
  ".": "0.0.0"
}
Enter fullscreen mode

Exit fullscreen mode



3. The Github Action

The core of the implementation lives in your repository’s CI/CD pipeline, often in a file named .github/workflows/release-please.yaml. This action needs the appropriate permissions to create the release artifacts (PRs, Tags, and GitHub Releases).

name: Release Please

on:
  push:
    branches:
      - main

permissions:
  # The 'write' permission is essential for creating/updating the release PR,
  # creating the Git tag, and publishing the GitHub release.
  contents: write
  pull-request: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/release-please-action@v4
        with:
          # Use your configuration file
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json
          # a PAT is needed if you need to re-run tests against an existing PR
          token: ${secrets.MY_PAT}
Enter fullscreen mode

Exit fullscreen mode

Once this action is merged and runs, your release automation will be live. Say goodbye to the manual version bump chore. 👋



4. Hooks with release-please

When a release is created its likely you would want to run additional jobs after it.

If you would like to trigger another job after release is created, you need to propagate outputs from the step to the job:

jobs:
  release-please:
    runs-on: ubuntu-latest
    outputs:
      tag_name: ${{ steps.release.outputs.tag_name}}
      release_created: ${{ steps.release.outputs.release_created}}
    steps:
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          token: ${{ secrets.MY_PAT }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json
Enter fullscreen mode

Exit fullscreen mode



5. Trigger additional workflows

Creating the release PR is only half the battle. The real value comes from using it to trigger our staging pipeline, allowing for proper user testing before the release is finalised.

We want to build and push a new ‘release candidate’ (RC) image to our container registry every time the release-please PR is opened or updated. This ensures our staging environment always has the very latest proposed code.

To do this, we can create a separate GitHub Actions workflow that listens to pull_request events on the main branch. The magic lies in adding a condition to ensure it only runs for PRs created by release-please.

Here is the complete workflow file:

name: Create Staging artifact from Release PR

on:
  pull_request:
    types: [opened, synchronize]
    branches:
      - "main"

jobs:
  build-and-deploy-staging:
    runs-on: ubuntu-latest
    # This 'if' condition is the key:
    # It checks that the source branch name starts with the prefix
    # used by release-please.
    if: startsWith(github.head_ref, 'release-please--')
    permissions:
      contents: read
      id-token: write # Assuming auth with GCP/AWS/Azure
    steps:
      - name: Checkout code from PR
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - name: 📝 Extract version from manifest file
        id: extract_version
        run: |
          # Example output: 1.1.0
          VERSION=$(jq -r '.["."]' .github/release-please-manifest.json)
          echo "version=${VERSION}" >> $GITHUB_OUTPUT

      - name: 🔢 Calculate Next Staging Tag
        id: calculate-staging-tag
        run: |
          set -eo pipefail
          # The base version (e.g., 1.1.0) from the previous step
          BASE_VERSION="${{ steps.extract_version.outputs.version }}"
          # Assumes IMG env var is set (e.g., ghcr.io/my-org/my-repo)
          IMAGE_NAME="${{ env.IMG }}"

          echo "Base version detected: $BASE_VERSION"

          # 1. Query registry for existing tags matching the pattern X.Y.Z-rc.N
          # We search for tags like '1.1.0-rc.1', '1.1.0-rc.2', etc.
          # This example uses gcloud, adjust for your registry (e.g., 'az acr', 'docker search')
          TAGS=$(gcloud container images list-tags "$IMAGE_NAME" \
            --format="json(tags)" \
            --filter="tags~^${BASE_VERSION}-rc\.[0-9]+$" 2>/dev/null || echo "[]")

          # 2. Determine the next sequential number
          if [ "$TAGS" == "[]" ]; then
              # No matching tags found, start at .1
              NEXT_NUM=1
              echo "No previous rc tags found. Starting at rc.1."
          else
              # Extract the number from tags, sort numerically, and take the highest
              MAX_NUM=$(echo "$TAGS" | jq -r --arg V "$BASE_VERSION" '.[] | .tags[] | select(startswith($V + "-rc.")) | split(".")[-1] | tonumber' | sort -rn | head -n 1)

              if [ -z "$MAX_NUM" ] || [ "$MAX_NUM" -eq 0 ]; then
                  NEXT_NUM=1
              else
                  # Increment the highest number found
                  NEXT_NUM=$((MAX_NUM + 1))
              fi
              echo "Highest previous rc number found: ${MAX_NUM}. Next number is: ${NEXT_NUM}"
          fi

          # 3. Output the final tag (e.g., 1.1.0-rc.2)
          NEW_TAG="${BASE_VERSION}-rc.${NEXT_NUM}"
          echo "NEW_TAG=${NEW_TAG}"
          echo "staging_tag=$NEW_TAG" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and Push Staging Image
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ env.IMG }}:${{ steps.calculate-staging-tag.outputs.staging_tag }}
          platforms: linux/amd64
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode

Exit fullscreen mode

Let’s quickly break down the most important parts:

  1. The if condition: if: startsWith(github.head_ref, 'release-please--') is the controller. It ensures this expensive build job only runs on pull requests opened by release-please, not on regular developer feature branches.

  2. Extract version: This step reads the .github/release-please-manifest.json file to find out what version release-please is proposing (e.g., 1.1.0).

  3. Calculate Next Staging Tag: This is the core logic. It asks the container registry “What is the highest rc tag you have for version 1.1.0?” If it finds 1.1.0-rc.1, it will output 1.1.0-rc.2. If it finds nothing, it starts at 1.1.0-rc.1. This ensures that every push to the PR (e.g., a fix) generates a new, unique, and sequential artifact.

With this workflow in place, we now have a fully automated process: a developer merges a feature, release-please creates a PR, and that PR automatically triggers a new staging build, ready for testing.



The Great Debate: To Squash or Not to Squash?

When you automate releases based on commit history, the question of how you merge feature branches becomes critically important.

The goal is a version history that is both clean for maintainers (Google’s reasoning) and descriptive for developers (my reasoning).



The Case for Linear History (Squash-Merge)

Many organisations, including Google, advocate for using squash-merges to maintain a linear Git history. Their reasons often centre on maintenance efficiency:

  • Clean History: The main branch remains uncluttered, showing only one logically grouped commit per feature or bug fix.
  • Simpler Tooling: Tools like git bisect (for tracking down which change introduced a bug) work flawlessly because every commit is guaranteed to be a passing, production-ready state.
  • Changelog Control: Internal commits like a small fix introduced during feature development are irrelevant to the release notes as they were never experienced on the main branch. Squashing hides this noise from the public-facing CHANGELOG.md.



The Counter-Argument (Logical Merging)

While the logic for a clean history is sound, many developers, myself included, find monolithic squash commits challenging to work with:

  • Context is King: When you squash all feature branch commits into one, you risk losing the detailed context of where the fix or feature was applied. This forces one to scroll a lot to find context, which is “not good.”
  • Descriptive History: A small number of logically grouped commits provides a history that is both informative and manageable. We should strive for fewer commits, but use “common sense” to combine them into fewer, more meaningful entries.



The Recommended Hybrid Approach

The great news is that release-please is flexible enough to accommodate both philosophies.

  1. For maximum simplicity and tool compatibility (The Squash Style): Use squash-merge. Crucially, leverage the commit message’s body to include all relevant feat: and fix: messages using footers. release-please is smart enough to parse multiple changes from a single monolithic commit:

    feat: adds v4 UUID to crypto
    
    This adds support for v4 UUIDs to the library.
    
    fix(utils): unicode no longer throws exception
      BREAKING-CHANGE: encode method no longer throws.
    

The additional messages must be added to the bottom of the commit body.

  1. For maximum contextual integrity (The Logical Style): Use a merge commit or a rebase merge that retains your carefully crafted atomic commits. As long as every commit landing on main strictly follows the Conventional Commit specification, release-please will aggregate all of them when generating the Release PR.

This small automation is a huge step forward. It completely eliminates the non-coding task of versioning and changelog management, allowing you to focus on developing features and fixing bugs.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *