Skip to content

ci: enhance release logic and message formatting #14

ci: enhance release logic and message formatting

ci: enhance release logic and message formatting #14

Workflow file for this run

name: Pipeline
# Trunk-based: `main` is the only long-lived branch, and every merge to it that
# changes something under `src/` is released. Versions come from GitVersion.yaml.
#
# No cloud credentials anywhere — `validate -backend=false` resolves providers
# from the registry and checks configuration statically.
on:
push:
branches: [main]
pull_request:
branches: [main]
# Break glass: re-runs the pipeline against the current head of the selected
# branch. Deliberately takes no inputs — the version is always derived, and
# the tag check makes a dispatch on an already-released commit a no-op.
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pipeline-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Matches the `required_version = ">= 1.15"` floor every module declares, so
# CI proves the floor is honest rather than testing some newer version.
TERRAFORM_VERSION: "1.15.8"
TF_IN_AUTOMATION: "1"
TF_INPUT: "0"
jobs:
discovery:
name: discovery
runs-on: ubuntu-latest
permissions:
contents: read
# paths-filter reads the changed-file list from the pull request API. The
# top-level `contents: read` is not enough — without this it 403s on every
# pull request, which is the entire external-contribution path.
pull-requests: read
outputs:
version: ${{ steps.computed.outputs.version }}
tag: ${{ steps.computed.outputs.tag }}
release: ${{ steps.computed.outputs.release }}
# On a bootstrap run no baseline can see the whole tree, so the filters are
# forced rather than trusted. `steps.changes.outputs.modules || 'true'`
# would not work — the filter emits the *string* `false`, and a non-empty
# string is truthy in a GitHub expression, so the fallback never fires.
modules: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.modules }}
docs: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.docs }}
src: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.src }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
# GitVersion needs full history and every tag.
fetch-depth: 0
- name: Setup GitVersion
uses: gittools/actions/gitversion/setup@v4.7.0
with:
versionSpec: 6.x
preferLatestVersion: true
- name: Determine version
id: gitversion
uses: gittools/actions/gitversion/execute@v4.7.0
with:
configFilePath: GitVersion.yaml
# Change detection must cover the same range the release covers: everything
# since the last tag, not just the latest push. Diffing one push lets a
# merge whose `validate` failed get released by a later docs-only merge —
# that run sees `modules == false`, skips fmt and validate entirely, and
# tags a version containing modules nothing ever checked.
- name: Resolve release baseline
id: baseline
run: |
set -euo pipefail
bootstrap=false
if tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
sha=$(git rev-list -n1 "$tag")
echo "Baseline: ${tag} (${sha})"
else
# Nothing released yet, so everything is unreleased — and no commit
# can express that. A diff from the root commit cannot see the files
# that commit itself introduced, and on a squashed single-commit
# history the root *is* HEAD, so the diff comes back empty and build
# is skipped for the very release that publishes the whole tree.
# The empty tree is not the way out: paths-filter resolves `base`
# through `git cat-file -e <sha>^{commit}`, which rejects a tree
# object outright. So keep a baseline it can resolve and force the
# filters above instead. Dormant once v0.1.0 exists.
sha=$(git rev-list --max-parents=0 HEAD | tail -1)
bootstrap=true
echo "Baseline: no v* tag yet — bootstrap run, building everything"
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
echo "bootstrap=${bootstrap}" >> "$GITHUB_OUTPUT"
# `base` is ignored on pull_request — the action diffs against the PR base.
#
# Any change under src/ validates the whole tree. Modules source each
# other by relative path, so an edit to one breaks callers nobody touched.
- name: Detect changes
id: changes
uses: dorny/paths-filter@v4
with:
base: ${{ steps.baseline.outputs.sha }}
filters: |
modules:
- 'src/**'
- '.github/workflows/pipeline.yaml'
# Release gating only, and deliberately narrower than `modules`.
# `modules` also carries pipeline.yaml so a workflow change
# re-validates the tree — but re-validating is not a reason to
# publish, because nothing a consumer fetches at a ref has moved.
# Everything in the `docs` filter is repository machinery for the
# same reason: a consumer resolves `//src/modules/<name>`, so
# docs/, the root markdown and .github/ never reach them.
src:
- 'src/**'
# Broader than "files the consistency check reads": it must also
# cover every path the docs *link to*, or moving one of them skips
# build, merges green, and breaks CI for the next unrelated pull
# request. LICENSE, GitVersion.yaml, renovate.json, cliff.toml and
# the chore workflows are all link targets.
docs:
- 'docs/**'
- '*.md'
- 'LICENSE'
- 'GitVersion.yaml'
- 'renovate.json'
- 'cliff.toml'
- '.github/**'
- name: Evaluate release
id: computed
env:
DERIVED: ${{ steps.gitversion.outputs.GitVersion_MajorMinorPatch }}
# Same forcing as the job outputs, so the summary reports what build
# was actually gated on rather than what the filter returned.
MODULES: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.modules }}
DOCS: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.docs }}
SRC: ${{ steps.baseline.outputs.bootstrap == 'true' && 'true' || steps.changes.outputs.src }}
run: |
set -euo pipefail
version="$DERIVED"
tag="v${version}"
# The `v` prefix is the consumer contract — every pinned source in the
# READMEs is `?ref=vX.Y.Z`. GitVersion cannot enforce it: `tag-prefix`
# is a read-side pattern for scanning history, and every version it
# emits is bare. So assert it here, where the tag is actually built.
# Also catches an empty $DERIVED, which `set -u` does not — the
# variable is set, just empty, which would tag the release `v`.
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Refusing to release malformed tag '${tag}'."
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
release=true
note=""
if [ "${GITHUB_REF}" != "refs/heads/main" ] || [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
release=false
note="not a push to \`main\`, so this run validates only"
elif [ "${SRC}" != "true" ]; then
# A version is a statement about what a consumer resolves at a ref.
# Docs, CI and repository config never reach one, so numbering them
# publishes a release whose content is identical to the last. Those
# commits are not lost: they stay unreleased until the next `src/`
# change carries them out, and the notes cover the whole range since
# the last tag, so they appear there.
release=false
note="nothing under \`src/\` changed, so these commits ride out with the next module change"
else
# Check the remote rather than this checkout: another run may have
# tagged since the clone.
existing=$(git ls-remote --tags origin "refs/tags/${tag}" | awk '{print $1}')
if [ -n "$existing" ]; then
release=false
if [ "$existing" = "$GITHUB_SHA" ]; then
note="\`${tag}\` is already tagged at this commit"
else
note="\`${tag}\` already exists on a different commit, and a published tag is never moved"
echo "::warning::${tag} already exists at ${existing}, not ${GITHUB_SHA} — refusing to move it."
fi
fi
fi
echo "release=${release}" >> "$GITHUB_OUTPUT"
if [ "$release" = "true" ]; then
verdict="Yes — \`${tag}\` will be tagged and published"
else
verdict="No — ${note}"
fi
if [ "${MODULES:-}" = "true" ]; then
scope="Formatting, consistency checks, and \`validate\` across the module tree"
elif [ "${DOCS:-}" = "true" ]; then
scope="Consistency checks only"
else
scope="None — \`build\` is skipped"
fi
{
echo "## Discovery"
echo
echo "| | |"
echo "|---|---|"
echo "| **Version** | \`${tag}\` |"
echo "| **Release** | ${verdict} |"
echo "| **Module sources changed** | $([ "${SRC:-}" = "true" ] && echo Yes || echo No) |"
echo "| **Validation scope** | ${scope} |"
} >> "$GITHUB_STEP_SUMMARY"
# Steps are gated on their own flag and on `!cancelled()` so a fmt failure
# still reports the docs and validate results — one push surfaces every
# problem, not just the first.
build:
name: build
needs: discovery
if: needs.discovery.outputs.modules == 'true' || needs.discovery.outputs.docs == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
if: needs.discovery.outputs.modules == 'true'
with:
terraform_version: ${{ env.TERRAFORM_VERSION }}
terraform_wrapper: false
- name: Check formatting
if: needs.discovery.outputs.modules == 'true'
run: terraform fmt -recursive -check -diff
# Validates module structure as well as documentation, so it runs whenever
# build runs rather than only on doc changes — a new module under src/ with
# a missing outputs.tf or an undescribed variable must not slip past.
- name: Check consistency
if: ${{ !cancelled() }}
run: python3 .github/scripts/check-docs.py
- name: Validate modules
if: ${{ !cancelled() && needs.discovery.outputs.modules == 'true' }}
run: |
set -uo pipefail
# One shared plugin directory for all of the inits below. Not an
# optimisation: without it Terraform downloads a private copy of every
# provider into each module's .terraform/ — gigabytes, and a dozen
# copies of azurerm alone.
#
# Exported here rather than in `env:` — Terraform does not expand a
# leading `~` in TF_PLUGIN_CACHE_DIR, and `env:` values get no shell
# expansion, so the path has to be built at runtime.
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
# Every directory holding .tf files: modules, nested submodules, and
# any examples/.
mapfile -t dirs < <(
find src/modules -name '*.tf' -not -path '*/.terraform/*' -printf '%h\n' | sort -u
)
# A sweep that checks nothing must never report success.
if [ ${#dirs[@]} -eq 0 ]; then
echo "::error::No module directories found — refusing to pass."
exit 1
fi
echo "Validating ${#dirs[@]} directories."
failed=()
for dir in "${dirs[@]}"; do
if ! output=$(terraform -chdir="$dir" init -backend=false -no-color 2>&1); then
echo "::error file=$dir/versions.tf::terraform init failed"
echo "$output"
failed+=("$dir (init)")
continue
fi
if ! output=$(terraform -chdir="$dir" validate -no-color 2>&1); then
echo "::error file=$dir/main.tf::terraform validate failed"
echo "$output"
failed+=("$dir (validate)")
fi
done
{
echo "## Build"
echo
if [ ${#failed[@]} -eq 0 ]; then
echo "**${#dirs[@]}** module directories validated. No failures."
else
echo "**${#dirs[@]}** module directories validated, **${#failed[@]}** failed:"
echo
for entry in "${failed[@]}"; do echo "- \`$entry\`"; done
fi
} >> "$GITHUB_STEP_SUMMARY"
[ ${#failed[@]} -eq 0 ]
# The single required check for branch protection. `build` cannot serve that
# role: it is skipped when a change touches neither modules nor docs, and a
# required check that reports nothing leaves the pull request unmergeable.
# This job always runs, so it always reports.
gate:
name: gate
needs: [discovery, build]
if: always()
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Check results
run: |
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
echo "::error::One or more pipeline jobs failed"
exit 1
fi
if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "::error::One or more pipeline jobs were cancelled"
exit 1
fi
echo "All pipeline jobs passed or were skipped."
# `always()` because build is legitimately skipped when a merge touched
# neither modules nor docs. That no longer releases either — the `src` gate in
# `discovery` handles it — but the condition still has to tolerate a skipped
# build so a docs merge that DOES ride out with a module change is not blocked
# by it. A build that failed or was cancelled always blocks.
release:
name: release
needs: [discovery, build]
if: >-
always()
&& needs.discovery.result == 'success'
&& needs.discovery.outputs.release == 'true'
&& needs.build.result != 'failure'
&& needs.build.result != 'cancelled'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# git-cliff reads the commits since the last tag and needs every tag to
# find it, so the shallow default will not do. GitHub's `--generate-notes`
# computed this server-side and needed no history; git-cliff does it here
# so the grouping matches the commit convention this repo actually uses.
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Check for a concurrent tag
id: guard
env:
TAG: ${{ needs.discovery.outputs.tag }}
run: |
set -euo pipefail
# discovery checked this too, but the build ran in between and a
# concurrent run could have tagged since.
existing=$(git ls-remote --tags origin "refs/tags/${TAG}" | awk '{print $1}')
if [ -n "$existing" ]; then
echo "::warning::${TAG} appeared during the build — not releasing."
{
echo "## Release"
echo
echo "Skipped. \`${TAG}\` was tagged by another run while this build was in progress."
} >> "$GITHUB_STEP_SUMMARY"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
# `--unreleased` is everything since the last tag, which is exactly the
# range the version covers — the tag itself does not exist yet, so `--tag`
# supplies the heading and the pin snippet. Grouping lives in cliff.toml,
# whose breaking-change rules must stay in step with GitVersion.yaml.
- name: Generate release notes
if: steps.guard.outputs.skip != 'true'
uses: orhun/git-cliff-action@v4.8.0
with:
config: cliff.toml
args: --unreleased --tag ${{ needs.discovery.outputs.tag }} --strip header
env:
OUTPUT: release-notes.md
GITHUB_REPO: ${{ github.repository }}
# Read-only. Enriches each commit with its pull request number, title
# and author handle — and, more importantly, lifts the API rate limit
# from 60/hr per runner IP to 1,000/hr, since git-cliff pages the whole
# commit and closed-pull-request history on every run. Verified to fall
# back to bare commit subjects when the token is missing or rejected
# rather than failing the step.
GITHUB_TOKEN: ${{ github.token }}
- name: Create release
if: steps.guard.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.discovery.outputs.tag }}
run: |
set -euo pipefail
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$TAG" \
--notes-file release-notes.md
{
echo "## Release"
echo
echo "Published **[\`${TAG}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG})** from \`${GITHUB_SHA:0:7}\`."
} >> "$GITHUB_STEP_SUMMARY"