Skip to content

Commit 95f8186

Browse files
johnscaclaude
andcommitted
feat: add the validate-min-terraform action (ENG-8760)
[ENG-8760](https://stacklet.atlassian.net/browse/ENG-8760) What ---- - Adds `validate-min-terraform`, a composite action that reads a module's Terraform floor out of `required_version` and runs `terraform validate` at that release. - Sets the repository up for the tools that follow: one directory per tool, each with an `action.yml` and a `README.md`, plus a lint workflow, a prek config, a justfile and a README that records the layout. Why ---- Five of the AWS and Azure onboarding module repositories need this job, and the version of it that ENG-8760 wrote carries enough shell to be worth maintaining in one place. Each repository validates at the release pinned in `.tool-versions`, which sits near the top of the supported range, so nothing covered the bottom of it. That is how `required_version = ">= 1"` survived in a module that no release below 1.8 can even parse. The action derives the floor rather than taking it as an input, so editing the constraint moves what CI tests in the same commit. It runs terraform-docs to parse the configuration instead of matching a regex against one file, which is what the published actions do and what makes them wrong on a second `.tf`, a `.tf.json`, or a constraint split across lines. The terraform-docs image is pinned by digest because the project publishes no immutable releases, so both a tag and a release checksum can change underneath us. Callers pin by commit SHA. This repository publishes no release tags, and a floating ref would put the module repositories' CI under whatever lands here next. Testing ---- - [x] Ran the floor derivation against a module carrying three constraints at once, one of them in a `.tf.json` and one split across lines: it reports the highest lower bound, where the regex-based action reports an empty string that `semver` then reads as "any version". - [x] Ran it against all five onboarding modules, each of which resolves to the floor its `required_version` states. - [x] Checked the operator handling across a matrix: `>= 1.14.0`, `>= 1.14.0, < 2.0.0` and `~> 1.14` resolve; `!=`, a bare version and `>` are rejected; an upper bound on its own reports that the constraint states no lower bound. - [x] Checked that a container that fails to run prints docker's own error, rather than surfacing as an empty parse further down. - [x] `just lint` clean, with zizmor re-run under a token to confirm the five online audits schedule rather than skip. zizmor audits `action.yml` as well as the workflow. - [ ] Confirm the action runs end to end once this merges and the module repositories point at it. Docs ---- `README.md` lists the tools and says how to add one. The action carries its own `README.md` with usage, inputs, outputs and a note on the one case it cannot catch: `terraform validate` never evaluates `validation` blocks, so a floor that is only wrong inside one still passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8530322 commit 95f8186

7 files changed

Lines changed: 227 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
permissions: {}
12+
13+
jobs:
14+
lint:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21+
with:
22+
persist-credentials: false
23+
24+
- uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
25+
env:
26+
# zizmor's five online audits skip silently without a token, and its
27+
# summary line reads the same either way, so CI would look green with
28+
# the supply-chain audits never having run.
29+
GH_TOKEN: ${{ github.token }}

‎.pre-commit-config.yaml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
repos:
2+
- repo: https://github.com/rhysd/actionlint
3+
rev: 393031adb9afb225ee52ae2ccd7a5af5525e03e8 # frozen: v1.7.11
4+
hooks:
5+
- id: actionlint
6+
7+
- repo: https://github.com/zizmorcore/zizmor-pre-commit
8+
rev: ea2eb407b4cbce87cf0d502f36578950494f5ac9 # frozen: v1.23.1
9+
hooks:
10+
- id: zizmor
11+
12+
- repo: https://github.com/codespell-project/codespell
13+
rev: 2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a # frozen: v2.4.2
14+
hooks:
15+
- id: codespell

‎.tool-versions‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
just 1.40.0
2+
uv 0.8.14

‎README.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,23 @@
11
# github-tooling
2-
Reusable GitHub Actions and workflows shared across Stacklet repositories
2+
3+
Reusable GitHub Actions and workflows shared across Stacklet repositories.
4+
5+
Each tool lives in its own top-level directory with an `action.yml` and a `README.md`. Reference one by path:
6+
7+
```yaml
8+
- uses: stacklet/github-tooling/<tool>@<commit-sha>
9+
```
10+
11+
Pin by commit SHA. This repository publishes no release tags, so a SHA is the only reference that cannot move.
12+
13+
## Tools
14+
15+
| Tool | Purpose |
16+
| --- | --- |
17+
| [validate-min-terraform](validate-min-terraform/) | Runs `terraform validate` at the oldest release a module's `required_version` allows. |
18+
19+
## Adding a tool
20+
21+
1. Create a directory named for the tool.
22+
2. Add an `action.yml` and a `README.md` covering usage, inputs, and outputs.
23+
3. Add a row to the table above.

‎justfile‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# lint files
2+
lint:
3+
uvx prek run --all-files

‎validate-min-terraform/README.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# validate-min-terraform
2+
3+
Runs `terraform validate` against a module at the oldest Terraform release the module says it supports.
4+
5+
A repository's lint job validates at the release pinned in `.tool-versions`, which is near the top of the supported range. Nothing covers the bottom of the range, so a module can declare `required_version = ">= 1.0"` and use syntax that no release below 1.8 parses. This action closes that gap.
6+
7+
## Usage
8+
9+
```yaml
10+
jobs:
11+
validate-min-terraform:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18+
with:
19+
persist-credentials: false
20+
21+
- uses: stacklet/github-tooling/validate-min-terraform@<commit-sha>
22+
```
23+
24+
## Inputs
25+
26+
| Name | Default | Description |
27+
| --- | --- | --- |
28+
| `working-directory` | `.` | Directory holding the module to validate. |
29+
| `terraform-docs-image` | a digest-pinned terraform-docs 0.20.0 | Image used to parse the configuration. |
30+
31+
## Outputs
32+
33+
| Name | Description |
34+
| --- | --- |
35+
| `version` | The Terraform release the module declares as its floor. |
36+
37+
## How the floor is derived
38+
39+
`terraform-docs` parses the configuration and reports every `required_version` in the module. Terraform applies all of them at once, so the effective floor is the highest lower bound among them. Only `>=` and `~>` state that bound literally, and `<` and `<=` cannot move it.
40+
41+
The action fails rather than guessing on anything else. A constraint with no lower bound has no floor to test, and an operator such as `!=` or `=` can rule out the bound another term states, which would otherwise install a release the module excludes.
42+
43+
Running a real parser matters. A regex over `*.tf` misses a constraint in a `.tf.json`, misses a second `.tf` file, and reads a constraint split across lines as empty, which several published actions then treat as "any version".
44+
45+
The terraform-docs image is pinned by digest because the project publishes no immutable releases, so both a tag and a release checksum can change underneath us. Bumping the version means bumping the digest in `action.yml`.
46+
47+
## Known limitation
48+
49+
`terraform validate` run against a module directory treats every root variable as unknown, so it never evaluates `validation` blocks. A module whose floor is wrong only inside a variable validation still passes here. Closing that needs `terraform test` with `mock_provider`.

‎validate-min-terraform/action.yml‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
name: Validate the declared Terraform floor
2+
description: >-
3+
Reads the Terraform floor out of a module's required_version and runs
4+
terraform validate at that release.
5+
6+
inputs:
7+
working-directory:
8+
description: Directory holding the Terraform module to validate.
9+
required: false
10+
default: .
11+
terraform-docs-image:
12+
description: >-
13+
Image used to parse the configuration. Pin by digest, not by tag.
14+
required: false
15+
# terraform-docs publishes no immutable releases, which leaves both a tag
16+
# and a release checksum able to change underneath us. A digest is content
17+
# addressed, so it cannot. This one is terraform-docs 0.20.0.
18+
default: quay.io/terraform-docs/terraform-docs@sha256:37329e2dc2518e7f719a986a3954b10771c3fe000f50f83fd4d98d489df2eae2
19+
20+
outputs:
21+
version:
22+
description: The Terraform release the module declares as its floor.
23+
value: ${{ steps.floor.outputs.version }}
24+
25+
runs:
26+
using: composite
27+
steps:
28+
# The floor is read out of required_version rather than passed in, so that
29+
# editing the constraint moves what CI tests in the same commit.
30+
# terraform-docs parses the configuration instead of matching a regex
31+
# against one file, so it reports every required_version in the module,
32+
# including one in a .tf.json or split across lines.
33+
- name: Read the declared Terraform floor
34+
id: floor
35+
shell: bash
36+
working-directory: ${{ inputs.working-directory }}
37+
env:
38+
TERRAFORM_DOCS_IMAGE: ${{ inputs.terraform-docs-image }}
39+
run: |
40+
# terraform-docs writes its own diagnostics to stderr, and merging those
41+
# into the pipe below breaks the JSON parse. They are kept aside rather
42+
# than discarded so that a failed pull or a parse error still says what
43+
# went wrong instead of surfacing as empty output.
44+
stderr=$(mktemp)
45+
if ! parsed=$(docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/wd" -w /wd \
46+
"$TERRAFORM_DOCS_IMAGE" json . --output-file '' 2>"$stderr"); then
47+
echo "terraform-docs could not read this module:" >&2
48+
cat "$stderr" >&2
49+
exit 1
50+
fi
51+
constraints=$(printf '%s' "$parsed" \
52+
| jq -r '.requirements[] | select(.name == "terraform") | .version')
53+
if [ -z "$constraints" ]; then
54+
echo "this module declares no required_version" >&2
55+
exit 1
56+
fi
57+
58+
# Terraform applies every required_version in the module at once, so the
59+
# effective floor is the highest lower bound among them. Only >= and ~>
60+
# state that bound literally, and an operator such as != or = can rule
61+
# out the bound another term states. Resolving either needs the release
62+
# list, so reject what this cannot read rather than guess at it.
63+
bounds=""
64+
while IFS= read -r term; do
65+
term=$(printf '%s' "$term" | tr -d '[:space:]')
66+
[ -n "$term" ] || continue
67+
case "$term" in
68+
'>='*|'~>'*) bound=${term#??} ;;
69+
# An upper bound cannot move the floor, so it needs no reading.
70+
'<'*) continue ;;
71+
*)
72+
echo "cannot resolve a Terraform floor from: $term" >&2
73+
echo "required_version may use >=, ~>, < and <= only" >&2
74+
exit 1
75+
;;
76+
esac
77+
if ! printf '%s' "$bound" | grep -qE '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
78+
echo "not a version this can resolve: $term" >&2
79+
exit 1
80+
fi
81+
case "$bound" in *.*.*) ;; *) bound="$bound.0" ;; esac
82+
bounds=$(printf '%s\n%s' "$bounds" "$bound")
83+
done <<EOF
84+
$(printf '%s' "$constraints" | tr ',' '\n')
85+
EOF
86+
87+
# grep exits non-zero on no match, which under -e would end the step
88+
# before the message below gets to say why.
89+
floor=$(printf '%s' "$bounds" | grep -v '^$' | sort -V | tail -1) || true
90+
if [ -z "$floor" ]; then
91+
echo "required_version states no lower bound; found:" >&2
92+
printf ' %s\n' "$constraints" >&2
93+
exit 1
94+
fi
95+
echo "version=$floor" >> "$GITHUB_OUTPUT"
96+
97+
- name: Setup Terraform
98+
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
99+
with:
100+
terraform_version: ${{ steps.floor.outputs.version }}
101+
102+
- name: Validate at the declared floor
103+
shell: bash
104+
working-directory: ${{ inputs.working-directory }}
105+
run: |
106+
terraform init -backend=false
107+
terraform validate

0 commit comments

Comments
 (0)