- Python 100%
| .github/workflows | ||
| scripts | ||
| .gitignore | ||
| action.yml | ||
| README.md | ||
Terraform Semver Release — How It Works & Deployment Guide
Table of Contents
- How the Analysis Works
- Semver Decision Rules
- Limitations
- Publishing to Your Org
- Versioning the Action Itself
- Usage Pattern A — Composite Action
- Usage Pattern B — Reusable Workflow
- Configuration Reference
How the Analysis Works
The system consists of two parts that run in sequence inside GitHub Actions.
1. Diff extraction (semver-release.yml or reusable-semver-release.yml)
The workflow:
- Finds the latest
vMAJOR.MINOR.PATCHtag in the repo (falls back tov0.0.0on first run). - Runs
git diff <tag>..HEAD -- '*.tf'to produce a unified diff of every Terraform file that changed between the last release and the current commit. - Passes that diff to the analysis script.
2. Static analysis (scripts/analyze_terraform_diff.py)
The script reads the unified diff line by line using a state machine:
- It detects block headers — lines matching
variable,output,resource,data,module,provider,terraform, orlocalsfollowed by{. - For each block header it records the sign of that line in the diff:
+(added),-(removed), or(unchanged, meaning the block exists in both versions). - It then reads all lines inside that block — tracking brace depth to know where the block ends — and records the sign of each inner line.
- Based on the header sign and inner-line signs it applies the semver rules below and records reasons.
The highest-priority rule that fires wins. After processing the entire diff the script prints major, minor, or patch (plus a JSON reasons array with --json).
3. Version arithmetic
Plain bash in the workflow strips the v prefix, splits on ., increments the right component, and zeroes the lower ones:
major: MAJOR+1, MINOR=0, PATCH=0
minor: MINOR+1, PATCH=0
patch: PATCH+1
4. Release creation
gh release create tags the current commit and publishes a GitHub Release with auto-generated notes that include the analysis reasons and the git log since the previous tag.
Semver Decision Rules
MAJOR — breaking changes
| What changed in the diff | Example |
|---|---|
A variable block is removed |
-variable "region" { |
A new variable block has no default line |
+variable "cluster_size" { type = number } |
A variable's type = line is replaced with a different value |
- type = number / + type = string |
A variable's default = line is removed without a new one added |
makes the variable required |
An output block is removed |
-output "bucket_arn" { |
A resource block is removed |
-resource "aws_s3_bucket" "logs" { |
A data block is removed |
-data "aws_iam_policy" "boundary" { |
A module block is removed |
-module "vpc" { |
MINOR — backwards-compatible additions
| What changed in the diff | Example |
|---|---|
A new variable block that has a default = line |
optional — callers don't have to set it |
A variable gains a default = (was required, now optional) |
existing callers unaffected |
A new output block is added |
|
A new resource block is added |
|
A new data block is added |
|
A new module block is added |
PATCH — everything else
- Changes to resource property values (AMI IDs, instance sizes, tags, etc.)
variabledescription or validation changes- Provider version constraint bumps
- Comment and whitespace changes
- Changes to
locals,provider, orterraformblocks (e.g.,required_providers) - Files that are not
.tfat all
Limitations
Know these before relying on it in production.
| Limitation | Impact | Workaround |
|---|---|---|
| Diff-based, not plan-based | Cannot detect semantic changes that aren't visible in the HCL source (e.g., a provider update that changes defaults) | Run terraform plan separately and gate on the plan output |
| Rename = remove + add | Renaming a resource address is correctly flagged as major (state break) even if the underlying infra is unchanged | Acceptable — rename is a breaking Terraform change |
| Multi-hunk blocks | A block that spans two diff hunks (rare on large files) may be split and partially missed | Avoid large reformats in the same commit as functional changes |
| Nested blocks | Changes inside nested blocks (e.g., lifecycle, dynamic) are currently treated as patch |
File a feature request or extend _analyze_block |
| Moved-between-files | Moving a variable from one .tf file to another shows as remove + add → major |
Use terraform state mv in a separate commit before moving HCL |
| No conventional commit support | Commit messages like feat: or BREAKING CHANGE: are not read |
Could be added as a pre-analysis step before the diff |
Publishing to Your Org
Step 1 — Create the action repo
Create a new public repository in your GitHub org. Public is required for GitHub Actions to reference it from other repos without an enterprise license. A common name is semver-static-analysis or terraform-semver-release.
github.com/YOUR_ORG/semver-static-analysis
Step 2 — Push the files
git init
git remote add origin git@github.com:YOUR_ORG/semver-static-analysis.git
# Copy the files from this directory into a clean repo, or push directly:
git add .
git commit -m "Initial release of terraform semver action"
git push -u origin main
The repo should contain:
action.yml ← composite action entry point
scripts/
analyze_terraform_diff.py ← analysis engine
.github/
workflows/
semver-release.yml ← tags THIS repo when it changes
reusable-semver-release.yml ← called FROM other repos
Step 3 — Replace the placeholder org name
In reusable-semver-release.yml, replace YOUR_ORG with your actual org name:
sed -i 's/YOUR_ORG/myorg/g' .github/workflows/reusable-semver-release.yml
git add .github/workflows/reusable-semver-release.yml
git commit -m "Set org name in reusable workflow"
git push
Step 4 — Create the initial release
The semver-release.yml workflow will auto-tag the action repo on every push to main. For the very first release, trigger it manually or create the tag directly:
git tag v1.0.0
git push origin v1.0.0
Then create a GitHub Release from that tag (or let the workflow do it on next push).
Versioning the Action Itself
GitHub Actions are pinned by tag. The standard convention is to maintain both a full tag (v1.0.0) and a moving major-version tag (v1) that always points to the latest patch of that major version. This lets callers use @v1 and get bug fixes automatically without changing their workflow files.
The semver-release.yml workflow creates the full tag automatically. You need to move the major-version tag manually after each release, or add this step to the workflow:
- name: Move major version tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
MAJOR=$(echo "${{ steps.version.outputs.version }}" | cut -d. -f1)
git tag -f "$MAJOR" "${{ github.sha }}"
git push origin "$MAJOR" --force
Add that step at the end of semver-release.yml (after the gh release create step) to keep v1, v2, etc. always up to date.
Usage Pattern A — Composite Action
Use this when you already have a workflow in your Terraform repo and just want to add the analysis + release as a step.
In your Terraform repo's workflow (e.g., .github/workflows/release.yml):
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Terraform semver release
uses: YOUR_ORG/semver-static-analysis@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# dry-run: 'true' # uncomment to test without creating a release
Using the outputs in subsequent steps:
- name: Terraform semver release
id: release
uses: YOUR_ORG/semver-static-analysis@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Notify Slack
run: |
echo "Released ${{ steps.release.outputs.new-version }} (${{ steps.release.outputs.bump }} bump)"
When GitHub downloads YOUR_ORG/semver-static-analysis@v1 it gets the full repo at that tag, so scripts/analyze_terraform_diff.py is available to the composite action steps via ${{ github.action_path }}. The calling repo's files are untouched.
Usage Pattern B — Reusable Workflow
Use this when you want the calling repo to have no workflow logic at all — just a single uses: line that delegates everything.
In your Terraform repo (e.g., .github/workflows/release.yml):
name: Release
on:
push:
branches:
- main
jobs:
release:
uses: YOUR_ORG/semver-static-analysis/.github/workflows/reusable-semver-release.yml@v1
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}
# with:
# dry-run: true # optional
Using the outputs from the reusable workflow in a downstream job:
jobs:
release:
uses: YOUR_ORG/semver-static-analysis/.github/workflows/reusable-semver-release.yml@v1
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}
notify:
needs: release
runs-on: ubuntu-latest
steps:
- run: echo "Released ${{ needs.release.outputs.new-version }}"
Pattern A vs Pattern B — when to use which
| Pattern A (Composite Action) | Pattern B (Reusable Workflow) | |
|---|---|---|
| Flexibility | High — mix with other steps freely | Low — the workflow is the whole job |
| Permissions | Inherited from the calling job | Set inside the reusable workflow |
| Secret passing | Via with: (action inputs) |
Via secrets: (explicit) |
| Concurrency control | Caller controls | Caller controls |
| Best for | Teams who already have release workflows | Teams who want zero boilerplate |
Configuration Reference
Action inputs (action.yml)
| Input | Required | Default | Description |
|---|---|---|---|
github-token |
Yes | ${{ github.token }} |
Token with contents: write on the repo |
base-tag |
No | auto-detected | Override the tag to diff from |
dry-run |
No | 'false' |
If 'true', analyze without creating a release |
Action outputs
| Output | Description | Example |
|---|---|---|
bump |
Determined bump type | minor |
new-version |
New version string | v1.3.0 |
previous-version |
Tag used as diff base | v1.2.0 |
Reusable workflow inputs
Same as the action inputs above, but typed as workflow inputs (boolean instead of string for dry-run).
Required permissions
The workflow (or calling job) must declare:
permissions:
contents: write # to push the release tag
GITHUB_TOKEN with contents: write is sufficient. No PAT is required.