No description
Find a file
meander d54b882da6
Some checks failed
reusable-semver-release.yml / Initial Commit (push) Failing after 0s
Semver Release / release (push) Has been cancelled
Initial Commit
2026-05-07 12:34:55 -07:00
.github/workflows Initial Commit 2026-05-07 12:34:55 -07:00
scripts Initial Commit 2026-05-07 12:34:55 -07:00
.gitignore Initial commit 2026-05-07 19:29:34 +00:00
action.yml Initial Commit 2026-05-07 12:34:55 -07:00
README.md Initial Commit 2026-05-07 12:34:55 -07:00

Terraform Semver Release — How It Works & Deployment Guide

Table of Contents

  1. How the Analysis Works
  2. Semver Decision Rules
  3. Limitations
  4. Publishing to Your Org
  5. Versioning the Action Itself
  6. Usage Pattern A — Composite Action
  7. Usage Pattern B — Reusable Workflow
  8. 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:

  1. Finds the latest vMAJOR.MINOR.PATCH tag in the repo (falls back to v0.0.0 on first run).
  2. 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.
  3. 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, or locals followed 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.)
  • variable description or validation changes
  • Provider version constraint bumps
  • Comment and whitespace changes
  • Changes to locals, provider, or terraform blocks (e.g., required_providers)
  • Files that are not .tf at 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.