|
| 1 | +# ------------------------------------------------------------------------------- |
| 2 | +# |
| 3 | +#-- Version Date: 25-02-2026 -- (dd-mm-eeyy) |
| 4 | +# |
| 5 | +# GitHub Actions workflow: Auto tag and manage GitHub Releases based on a version |
| 6 | +# string found in your PlatformIO firmware source file. |
| 7 | +# |
| 8 | +# Intended use |
| 9 | +# - PlatformIO/embedded projects where the firmware version is defined in source |
| 10 | +# code (example format below). |
| 11 | +# - On every push to the main/master branch, the workflow reads the version, compares it |
| 12 | +# to the latest existing semver tag (vX.Y.Z), and (if newer) creates a new tag. |
| 13 | +# |
| 14 | +# Version format expected in source file |
| 15 | +# - The workflow extracts the version from: |
| 16 | +# <PROGRAM_DIR>/<PROGRAM_SRC> |
| 17 | +# by searching for a PROG_VERSION line and extracting only: |
| 18 | +# vX.Y.Z |
| 19 | +# from examples like: |
| 20 | +# const char* PROG_VERSION = "v1.2.3 <and more text ..>"; |
| 21 | +# const char* PROG_VERSION = v1.2.3 <and more text ..>; |
| 22 | +# |
| 23 | +# Tagging rules |
| 24 | +# - If the extracted version is newer than the latest semver tag in the repo: |
| 25 | +# - Create and push a Git tag "vX.Y.Z". |
| 26 | +# - If the version change is: |
| 27 | +# - Major (X increases) OR Minor (Y increases): create a GitHub Release. |
| 28 | +# - Patch-only (Z increases): create only a tag, no GitHub Release. |
| 29 | +# |
| 30 | +# Release-notes rule (important) |
| 31 | +# - When a GitHub Release is created, its body is composed of commit messages |
| 32 | +# since the previous GitHub Release tag (not since the previous tag). |
| 33 | +# - Example: |
| 34 | +# - v1.0.0 Release contains commit messages 1..4 |
| 35 | +# - v2.0.0 Release contains commit messages 5..N |
| 36 | +# - Implementation detail: |
| 37 | +# - The workflow queries GitHub Releases to find the previous release tag, |
| 38 | +# then runs: |
| 39 | +# git log <previousReleaseTag>..HEAD --pretty=format:"- %s" |
| 40 | +# |
| 41 | +# Release retention rules |
| 42 | +# - "Main" releases (vX.0.0) are kept for every major. |
| 43 | +# - For each major, only one "minor" release (vX.Y.0 where Y>0) is kept: |
| 44 | +# - When creating a new minor release v3.3.0, it deletes GitHub Releases |
| 45 | +# v3.1.0 and v3.2.0 (if they exist), but keeps all tags. |
| 46 | +# - When creating a new major release v4.0.0, it deletes all minor releases |
| 47 | +# from older majors (e.g. deletes v3.2.0 release), but keeps tags. |
| 48 | +# - Patch releases are not created by this workflow. If patch releases exist |
| 49 | +# for some reason, this workflow leaves them untouched. |
| 50 | +# |
| 51 | +# Required repository settings |
| 52 | +# - Settings -> Actions -> General -> Workflow permissions: |
| 53 | +# - Set to "Read and write permissions" (contents: write). |
| 54 | +# ------------------------------------------------------------------------------- |
| 55 | + |
| 56 | +name: Auto Tag Release |
| 57 | + |
| 58 | +env: |
| 59 | + PROGRAM_NAME: "DSMRloggerAPI" |
| 60 | + PROGRAM_SRC: "DSMRloggerAPI.cpp" |
| 61 | + PROGRAM_DIR: "src" |
| 62 | + |
| 63 | +on: |
| 64 | + push: |
| 65 | + branches: |
| 66 | + - main |
| 67 | + - master |
| 68 | + |
| 69 | +jobs: |
| 70 | + tag: |
| 71 | + runs-on: ubuntu-latest |
| 72 | + |
| 73 | + permissions: |
| 74 | + contents: write |
| 75 | + |
| 76 | + steps: |
| 77 | + - name: Checkout code |
| 78 | + uses: actions/checkout@v3 |
| 79 | + |
| 80 | + - name: Get version from ${{ env.PROGRAM_SRC }} |
| 81 | + id: getVersion |
| 82 | + run: | |
| 83 | + versionLine=$(grep -m1 'PROG_VERSION' ${{ env.PROGRAM_DIR }}/${{ env.PROGRAM_SRC }} || true) |
| 84 | + version=$(echo "$versionLine" | grep -Eo 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 || true) |
| 85 | + parseError="" |
| 86 | +
|
| 87 | + if [ -z "$versionLine" ]; then |
| 88 | + parseError="No PROG_VERSION line found in ${{ env.PROGRAM_DIR }}/${{ env.PROGRAM_SRC }}." |
| 89 | + elif [ -z "$version" ]; then |
| 90 | + parseError="PROG_VERSION line found, but no semantic version (vX.Y.Z) was detected." |
| 91 | + fi |
| 92 | +
|
| 93 | + echo "Detected PROG_VERSION line: $versionLine" |
| 94 | + echo "Extracted tag version: $version" |
| 95 | + if [ -n "$parseError" ]; then |
| 96 | + echo "::error::$parseError" |
| 97 | + fi |
| 98 | +
|
| 99 | + echo "VERSION=$version" >> $GITHUB_OUTPUT |
| 100 | + echo "VERSION=$version" >> $GITHUB_ENV |
| 101 | + echo "VERSION_PARSE_ERROR=$parseError" >> $GITHUB_OUTPUT |
| 102 | + echo "VERSION_PARSE_ERROR=$parseError" >> $GITHUB_ENV |
| 103 | +
|
| 104 | + - name: Fetch all tags |
| 105 | + run: git fetch --tags |
| 106 | + |
| 107 | + - name: Get latest semver tag |
| 108 | + id: latestTag |
| 109 | + run: | |
| 110 | + latest=$(git tag --sort=-v:refname | grep -E '^v([0-9]+\.){2}[0-9]+$' | head -n 1 || true) |
| 111 | + echo "Latest tag detected: $latest" |
| 112 | + echo "LATEST=$latest" >> $GITHUB_OUTPUT |
| 113 | +
|
| 114 | + - name: Compare version |
| 115 | + id: compareVersion |
| 116 | + run: | |
| 117 | + version="${{ steps.getVersion.outputs.VERSION }}" |
| 118 | + versionParseError="${{ steps.getVersion.outputs.VERSION_PARSE_ERROR }}" |
| 119 | + latest="${{ steps.latestTag.outputs.LATEST }}" |
| 120 | +
|
| 121 | + echo "Comparing source version $version with latest tag $latest" |
| 122 | +
|
| 123 | + if [ -n "$versionParseError" ]; then |
| 124 | + echo "::error::$versionParseError" |
| 125 | + echo "PROCEED=false" >> $GITHUB_ENV |
| 126 | + exit 0 |
| 127 | + fi |
| 128 | +
|
| 129 | + if [ -z "$version" ]; then |
| 130 | + echo "::error::No version found in ${{ env.PROGRAM_DIR }}/${{ env.PROGRAM_SRC }}. Skipping." |
| 131 | + echo "PROCEED=false" >> $GITHUB_ENV |
| 132 | + exit 0 |
| 133 | + fi |
| 134 | +
|
| 135 | + if [ -z "$latest" ]; then |
| 136 | + echo "No existing tag found. Proceeding." |
| 137 | + echo "PROCEED=true" >> $GITHUB_ENV |
| 138 | + echo "MAKE_RELEASE=true" >> $GITHUB_ENV |
| 139 | + elif [ "$version" = "$latest" ]; then |
| 140 | + echo "::warning::Version $version already exists. Skipping." |
| 141 | + echo "PROCEED=false" >> $GITHUB_ENV |
| 142 | + elif [ "$(printf '%s\n' "$latest" "$version" | sort -V | head -n 1)" = "$version" ]; then |
| 143 | + echo "::warning::Version $version is not greater than $latest. Skipping." |
| 144 | + echo "PROCEED=false" >> $GITHUB_ENV |
| 145 | + else |
| 146 | + echo "Version $version is newer than $latest. Proceeding." |
| 147 | + echo "PROCEED=true" >> $GITHUB_ENV |
| 148 | +
|
| 149 | + versionMajor=$(echo "$version" | sed -E 's/^v([0-9]+)\..*/\1/') |
| 150 | + versionMinor=$(echo "$version" | sed -E 's/^v[0-9]+\.([0-9]+)\..*/\1/') |
| 151 | + latestMajor=$(echo "$latest" | sed -E 's/^v([0-9]+)\..*/\1/') |
| 152 | + latestMinor=$(echo "$latest" | sed -E 's/^v[0-9]+\.([0-9]+)\..*/\1/') |
| 153 | +
|
| 154 | + echo "Parsed version: $versionMajor.$versionMinor, latest: $latestMajor.$latestMinor" |
| 155 | +
|
| 156 | + # Treat major 0 as "always release" (SemVer: 0.y.z is unstable; any bump can be breaking) |
| 157 | + if [ "$versionMajor" -eq 0 ]; then |
| 158 | + echo "Major is 0. Any new version under v0.*.* will create a GitHub Release." |
| 159 | + echo "MAKE_RELEASE=true" >> $GITHUB_ENV |
| 160 | + elif [ "$versionMajor" -gt "$latestMajor" ] || [ "$versionMajor" -eq "$latestMajor" -a "$versionMinor" -gt "$latestMinor" ]; then |
| 161 | + echo "Major or minor version changed. A GitHub Release will be created." |
| 162 | + echo "MAKE_RELEASE=true" >> $GITHUB_ENV |
| 163 | + else |
| 164 | + echo "Only patch version changed. Only a tag will be created." |
| 165 | + echo "MAKE_RELEASE=false" >> $GITHUB_ENV |
| 166 | + fi |
| 167 | + fi |
| 168 | +
|
| 169 | + - name: Create and push tag |
| 170 | + if: env.PROCEED == 'true' && env.VERSION != '' |
| 171 | + run: | |
| 172 | + echo "Creating and pushing tag $VERSION" |
| 173 | + git config user.name "GitHub Actions" |
| 174 | + git config user.email "actions@github.com" |
| 175 | + git tag "$VERSION" |
| 176 | + git push origin "$VERSION" |
| 177 | +
|
| 178 | + - name: Determine previous release tag |
| 179 | + if: env.MAKE_RELEASE == 'true' && env.PROCEED == 'true' |
| 180 | + id: previousRelease |
| 181 | + env: |
| 182 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 183 | + run: | |
| 184 | + set -euo pipefail |
| 185 | +
|
| 186 | + version="${{ env.VERSION }}" |
| 187 | + repo="${GITHUB_REPOSITORY}" |
| 188 | +
|
| 189 | + echo "Finding previous GitHub Release tag (excluding $version)." |
| 190 | +
|
| 191 | + releasesJson=$(curl -sS \ |
| 192 | + -H "Authorization: Bearer $GITHUB_TOKEN" \ |
| 193 | + -H "Accept: application/vnd.github+json" \ |
| 194 | + "https://api.github.com/repos/$repo/releases?per_page=100") |
| 195 | +
|
| 196 | + previousReleaseTag=$( |
| 197 | + echo "$releasesJson" \ |
| 198 | + | jq -r '.[].tag_name' \ |
| 199 | + | grep -E '^v([0-9]+\.){2}[0-9]+$' \ |
| 200 | + | grep -v -F "$version" \ |
| 201 | + | sort -V \ |
| 202 | + | tail -n 1 \ |
| 203 | + || true |
| 204 | + ) |
| 205 | +
|
| 206 | + if [ -z "$previousReleaseTag" ]; then |
| 207 | + echo "No previous GitHub Release tag found. This will be treated as the first release." |
| 208 | + else |
| 209 | + echo "Previous GitHub Release tag: $previousReleaseTag" |
| 210 | + fi |
| 211 | +
|
| 212 | + echo "PREVIOUS_RELEASE_TAG=$previousReleaseTag" >> $GITHUB_OUTPUT |
| 213 | + echo "PREVIOUS_RELEASE_TAG=$previousReleaseTag" >> $GITHUB_ENV |
| 214 | +
|
| 215 | + - name: Cleanup old minor releases (keep tags) |
| 216 | + if: env.MAKE_RELEASE == 'true' && env.PROCEED == 'true' |
| 217 | + env: |
| 218 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 219 | + run: | |
| 220 | + set -euo pipefail |
| 221 | +
|
| 222 | + version="${{ env.VERSION }}" |
| 223 | + repo="${GITHUB_REPOSITORY}" |
| 224 | +
|
| 225 | + versionMajor=$(echo "$version" | sed -E 's/^v([0-9]+)\..*/\1/') |
| 226 | + versionMinor=$(echo "$version" | sed -E 's/^v[0-9]+\.([0-9]+)\..*/\1/') |
| 227 | + versionPatch=$(echo "$version" | sed -E 's/^v[0-9]+\.[0-9]+\.([0-9]+)$/\1/') |
| 228 | +
|
| 229 | + isMajorRelease=false |
| 230 | + isMinorRelease=false |
| 231 | +
|
| 232 | + if [ "$versionMinor" -eq 0 ] && [ "$versionPatch" -eq 0 ]; then |
| 233 | + isMajorRelease=true |
| 234 | + elif [ "$versionMinor" -gt 0 ] && [ "$versionPatch" -eq 0 ]; then |
| 235 | + isMinorRelease=true |
| 236 | + fi |
| 237 | +
|
| 238 | + echo "Current version is $version (major=$versionMajor minor=$versionMinor patch=$versionPatch)" |
| 239 | + echo "Major release: $isMajorRelease" |
| 240 | + echo "Minor release: $isMinorRelease" |
| 241 | +
|
| 242 | + if [ "$isMajorRelease" = "false" ] && [ "$isMinorRelease" = "false" ]; then |
| 243 | + echo "This is not a major/minor .0 release. No cleanup needed." |
| 244 | + exit 0 |
| 245 | + fi |
| 246 | +
|
| 247 | + releasesJson=$(curl -sS \ |
| 248 | + -H "Authorization: Bearer $GITHUB_TOKEN" \ |
| 249 | + -H "Accept: application/vnd.github+json" \ |
| 250 | + "https://api.github.com/repos/$repo/releases?per_page=100") |
| 251 | +
|
| 252 | + releaseCount=$(echo "$releasesJson" | jq 'length') |
| 253 | + echo "Fetched $releaseCount releases." |
| 254 | +
|
| 255 | + releaseIdsToDelete=() |
| 256 | +
|
| 257 | + if [ "$isMajorRelease" = "true" ]; then |
| 258 | + echo "Deleting minor releases from older majors." |
| 259 | + while IFS=$'\t' read -r tagName releaseId; do |
| 260 | + if [[ "$tagName" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then |
| 261 | + tagMajor="${BASH_REMATCH[1]}" |
| 262 | + tagMinor="${BASH_REMATCH[2]}" |
| 263 | + if [ "$tagMinor" -gt 0 ] && [ "$tagMajor" -lt "$versionMajor" ]; then |
| 264 | + echo "Scheduled for deletion: release tag $tagName (releaseId=$releaseId)" |
| 265 | + releaseIdsToDelete+=("$releaseId") |
| 266 | + fi |
| 267 | + fi |
| 268 | + done < <(echo "$releasesJson" | jq -r '.[] | [.tag_name, .id] | @tsv') |
| 269 | +
|
| 270 | + elif [ "$isMinorRelease" = "true" ]; then |
| 271 | + echo "Deleting older minor releases within the same major." |
| 272 | + while IFS=$'\t' read -r tagName releaseId; do |
| 273 | + if [[ "$tagName" =~ ^v([0-9]+)\.([0-9]+)\.0$ ]]; then |
| 274 | + tagMajor="${BASH_REMATCH[1]}" |
| 275 | + tagMinor="${BASH_REMATCH[2]}" |
| 276 | + if [ "$tagMajor" -eq "$versionMajor" ] && [ "$tagMinor" -gt 0 ] && [ "$tagMinor" -lt "$versionMinor" ]; then |
| 277 | + echo "Scheduled for deletion: release tag $tagName (releaseId=$releaseId)" |
| 278 | + releaseIdsToDelete+=("$releaseId") |
| 279 | + fi |
| 280 | + fi |
| 281 | + done < <(echo "$releasesJson" | jq -r '.[] | [.tag_name, .id] | @tsv') |
| 282 | + fi |
| 283 | +
|
| 284 | + if [ "${#releaseIdsToDelete[@]}" -eq 0 ]; then |
| 285 | + echo "No releases matched the cleanup rules." |
| 286 | + exit 0 |
| 287 | + fi |
| 288 | +
|
| 289 | + for releaseId in "${releaseIdsToDelete[@]}"; do |
| 290 | + echo "Deleting GitHub Release id=$releaseId (tags will be kept)." |
| 291 | + curl -sS -X DELETE \ |
| 292 | + -H "Authorization: Bearer $GITHUB_TOKEN" \ |
| 293 | + -H "Accept: application/vnd.github+json" \ |
| 294 | + "https://api.github.com/repos/$repo/releases/$releaseId" >/dev/null |
| 295 | + done |
| 296 | +
|
| 297 | + echo "Cleanup completed. All tags were preserved." |
| 298 | +
|
| 299 | + - name: Generate release notes |
| 300 | + if: env.MAKE_RELEASE == 'true' && env.PROCEED == 'true' |
| 301 | + id: releaseNotes |
| 302 | + run: | |
| 303 | + version="${{ env.VERSION }}" |
| 304 | + previousReleaseTag="${{ env.PREVIOUS_RELEASE_TAG }}" |
| 305 | +
|
| 306 | + echo "Generating release notes for $version" |
| 307 | + echo "## Changes" > release_notes.txt |
| 308 | + echo "" >> release_notes.txt |
| 309 | +
|
| 310 | + if [ -z "$previousReleaseTag" ]; then |
| 311 | + echo "No previous GitHub Release tag found. Including recent commits (max 20)." |
| 312 | + git log --pretty=format:"- %s" | head -n 20 >> release_notes.txt |
| 313 | + echo "" >> release_notes.txt |
| 314 | + echo "" >> release_notes.txt |
| 315 | + echo "_First release_" >> release_notes.txt |
| 316 | + else |
| 317 | + echo "Including commits since previous GitHub Release tag $previousReleaseTag." |
| 318 | + echo "### Changes since $previousReleaseTag" >> release_notes.txt |
| 319 | + echo "" >> release_notes.txt |
| 320 | + git log ${previousReleaseTag}..HEAD --pretty=format:"- %s" >> release_notes.txt |
| 321 | + fi |
| 322 | +
|
| 323 | + echo "Release notes file created." |
| 324 | +
|
| 325 | + - name: Create GitHub Release |
| 326 | + if: env.MAKE_RELEASE == 'true' && env.PROCEED == 'true' |
| 327 | + uses: actions/create-release@v1 |
| 328 | + env: |
| 329 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 330 | + with: |
| 331 | + tag_name: ${{ env.VERSION }} |
| 332 | + release_name: Release ${{ env.VERSION }} |
| 333 | + body_path: release_notes.txt |
| 334 | + draft: false |
| 335 | + prerelease: false |
0 commit comments