Post

DevOps: Automating Release Tags

DevOps: Automating Release Tags

Recently, I was tasked with developing a consistent strategy for managing releases and tags in a GitHub repository that had grown organically without any version tracking system. The repository had been accumulating features and fixes for months, but there was no way to track versions, identify what changes belonged to which release, or even determine what was the current deployed “version”.

The Challenge

In our project, we wanted to:

  • Create clear versioned tags that followed semantic versioning (v1.0.0, v1.1.0, etc.)
  • Automatically generate GitHub Releases from these tags, with proper release notes
  • Avoid manual tagging or release creation
  • Include a flexible way to bump major versions

The Solution

To achieve this, a GitHub Actions Workflow was implemented to handle tagging, and release management seamlessly. Illustrated below is the flow of the automated release process:

flowchart TD
  A[PR Merged to Main] --> B[Get PR Metadata]
  B --> C{Check for Major Triggers}
  C -->|major label/keyword| D[Bump Major Version]
  C -->|no triggers| E[Bump Minor Version]
  D --> F[Create Git Tag]
  E --> F
  F --> G[Push Tag to Repo]
  G --> H[Generate Release Notes]
  H --> I[Create GitHub Release]

  style D fill:#e74c3c,stroke:#c0392b,stroke-width:2px,color:#fff
  style E fill:#27ae60,stroke:#229954,stroke-width:2px,color:#fff
  style I fill:#3498db,stroke:#2980b9,stroke-width:2px,color:#fff

Key Features of the Solution

🔄 Automated Trigger

  • Push to Main: Automatically runs after a pull request has been merged to the main branch

🎯 Versioning Logic

  • SemVer Compliant: Follows semantic versioning principles and creates tag 1.0.0 if no previous tags exist
  • Tagging: Reads the latest Git tag and increments the version, typically a minor bump but can also bump major or patch depending on context.

🏷️ Flexible Major Version Bumping

  • Labels: Bumps the major version if pull request includes a major label
  • PR Title/Description: Bumps the major version if the PR title or description contains [major]
  • Commit Message: Bumps the major version if a commit message includes [major]

📜 Release Notes Generation

  • Changelog: It auto-generates changelog content for a GitHub Release based on the PR title and description

Here’s the GitHub Action that was developed to automate our entire release process:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
name: Tag and Release on PR Merge

on:
  push:
    branches:
      - main

jobs:
  tag-and-release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Get latest PR merged
        id: pr
        uses: actions/github-script@v7
        with:
          script: |
            const prs = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'closed',
              sort: 'updated',
              direction: 'desc',
              per_page: 1
            });
            const pr = prs.data.find(pr => pr.merged_at && pr.merge_commit_sha === context.sha);
            if (!pr) throw new Error('No merged PR found for this commit.');
            core.setOutput('pr_number', pr.number);
            core.setOutput('pr_title', pr.title);
            core.setOutput('pr_body', pr.body);

      - name: Check for major release trigger
        id: major_trigger
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = Number(process.env.PR_NUMBER || '${{ steps.pr.outputs.pr_number }}');
            let isMajor = false;
            if (prNumber) {
              const pr = await github.rest.pulls.get({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: prNumber
              });
              const labels = pr.data.labels.map(l => l.name.toLowerCase());
              if (labels.includes('major-release')) isMajor = true;
              if (pr.data.title.includes('[major]') || (pr.data.body && pr.data.body.includes('[major]'))) isMajor = true;
            } else {
              // Fallback: check commit message
              const commit = await github.rest.repos.getCommit({
                owner: context.repo.owner,
                repo: context.repo.repo,
                ref: context.sha
              });
              if (commit.data.commit.message.includes('[major]')) isMajor = true;
            }
            core.setOutput('major', isMajor ? 'true' : 'false');

      - name: Get latest tag
        id: get_tag
        run: |
          git fetch --tags
          latest_tag=$(git tag --sort=-v:refname | head -n 1)
          echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT

      - name: Bump version and create tag
        id: bump_tag
        run: |
          latest_tag=${{ steps.get_tag.outputs.latest_tag }}
          is_major=${{ steps.major_trigger.outputs.major }}
          if [[ -z "$latest_tag" ]]; then
            new_tag="v1.0.0"
          else
            IFS='.' read -r major minor patch <<< "${latest_tag#v}"
            if [[ "$is_major" == "true" ]]; then
              new_tag="v$((major+1)).0.0"
            else
              new_tag="v$major.$minor.$((patch+1))"
            fi
          fi
          git config user.name "github-actions"
          git config user.email "github-actions@github.com"
          git tag "$new_tag"
          git push origin "$new_tag"
          echo "new_tag=$new_tag" >> $GITHUB_OUTPUT

      - name: Check if major version
        id: is_major
        run: |
          tag=${{ steps.bump_tag.outputs.new_tag }}
          major=$(echo $tag | cut -d'.' -f1 | tr -d 'v')
          minor=$(echo $tag | cut -d'.' -f2)
          patch=$(echo $tag | cut -d'.' -f3)
          if [[ "$minor" == "0" && "$patch" == "0" ]]; then
            echo "major=true" >> $GITHUB_OUTPUT
          else
            echo "major=false" >> $GITHUB_OUTPUT
          fi

      - name: Generate release summary
        id: release_notes
        if: steps.is_major.outputs.major == 'true'
        run: |
          pr_title="${{ steps.pr.outputs.pr_title }}"
          pr_body="${{ steps.pr.outputs.pr_body }}"
          echo -e "# Release Summary\n\n**PR Title:** $pr_title\n\n**PR Description:**\n$pr_body" > release-notes.txt

      - name: Create GitHub Release
        if: steps.is_major.outputs.major == 'true'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create "${{ steps.bump_tag.outputs.new_tag }}" \
            --title "Release ${{ steps.bump_tag.outputs.new_tag }}" \
            --notes-file release-notes.txt

Conclusion

The beauty of this solution lies in its simplicity and reliability. Once set up, it runs invisibly in the background, ensuring that every meaningful change to our codebase gets properly versioned, tagged, and documented.

Have you implemented automated release management in your projects? I’d love to hear about your experiences and any creative variations you’ve developed!

This post is licensed under CC BY 4.0 by the author.