Skip to content
Merged
Prev Previous commit
Next Next commit
Raise the minimum floor to a supported release, and simplify the parse
The clamp defaulted to 1.2.2, which closed one advisory and otherwise had
nothing to recommend it: that line stopped receiving fixes years ago. It now
defaults to 1.14.0, the oldest release still inside HashiCorp's support
window, and the README records the rule and the review it needs when a new
minor lands.

The docker invocation is one shlex.split of an interpolated string rather
than twenty lines of one argument each. read_floor.py writes the step output
itself, so the action step is a bare invocation with no shell logic.

just and uv in .tool-versions were both months behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Loading branch information
johnsca and claude committed Oct 8, 2026
commit 8522610efa2d0661e2b3de41f2743860838e6789
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ jobs:
prek-version: ${{ env.PREK_TOOL_VERSION }}
env:
# zizmor's five online audits skip silently without a token, and its
# summary line reads the same either way, so CI would look green with
# the supply-chain audits never having run.
# summary line reads the same either way, so CI would pass while the
# supply-chain audits never ran.
GH_TOKEN: ${{ github.token }}

test:
Expand Down
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
just 1.40.0
just 1.58.0
prek 0.5.5
uv 0.8.14
uv 0.12.23
58 changes: 27 additions & 31 deletions validate-min-terraform/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# validate-min-terraform

Runs `terraform validate` against a module at the oldest Terraform release the module says it supports.

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.
Runs `terraform validate` against a Terraform module at the oldest release its
`required_version` allows, so the bottom of the declared range gets tested
rather than assumed.

## Usage

Expand All @@ -21,12 +21,17 @@ jobs:
- uses: stacklet/github-tooling/validate-min-terraform@<commit-sha>
```

> [!WARNING]
> `terraform init` installs the providers the module names, and `validate` runs
> each provider binary to read its schema. On a pull request that configuration
> is untrusted, so give the job `contents: read`, and no secrets.

## Inputs

| Name | Default | Description |
| --- | --- | --- |
| `working-directory` | `.` | Directory holding the module to validate. |
| `minimum-floor` | `1.2.2` | Refuse to run below this release. |
| `working-directory` | `.` | Directory holding the module. One directory per call, with no descent into subdirectories. |
| `minimum-floor` | `1.14.0` | Refuse to run below this release. The default is the oldest release HashiCorp still ships fixes for. |
| `terraform-docs-image` | a digest-pinned terraform-docs 0.20.0 | Image used to parse the configuration. |

## Outputs
Expand All @@ -35,38 +40,29 @@ jobs:
| --- | --- |
| `version` | The Terraform release the module declares as its floor. |

## How the floor is derived

`terraform-docs` parses the configuration and reports every `required_version` in the directory. 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.

The action fails rather than guessing on anything else:

- A constraint with no lower bound, such as `< 2.0.0` alone, names no floor to test.
- `!=` and `=` can rule out the bound another term states, which would otherwise install a release the module excludes.
- `>` is exclusive, so it names no release to install, even though it permits everything above the one it names. Use the inclusive form with a real release: `>= 1.14.1` rather than `> 1.14.0`.

The action reads one directory. A repository with modules in subdirectories needs one call per directory, which a matrix over `working-directory` covers.

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".

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`.
## Requirements

## Why there is a floor on the floor
The runner needs Docker and `python3`. `ubuntu-latest` has both, and the macOS
runners have no Docker.

The release this installs comes out of the configuration under test, so on a pull request the branch decides which Terraform runs in CI. That is the point of the action, and it is also a way to reach an old release for the sake of its bugs rather than for support. `terraform init` installs providers and fetches module sources whatever `-backend=false` says, and Terraform 1.0.0 through 1.2.1 bundle a go-getter affected by HCSEC-2022-13, which HashiCorp never backported to those lines.
## Version constraints

`minimum-floor` defaults to 1.2.2, the first release past that. Raise it to your own support floor if you would rather not run old releases at all.
`required_version` must state a lower bound with `>=` or `~>`. Upper bounds are
allowed and ignored. The action rejects any constraint it cannot resolve to a
definite release, and fails when the floor it resolves falls below
`minimum-floor`.

Two things this does not solve. A pull request still chooses which providers `init` installs, and a provider plugin is a binary that `validate` executes to read its schema, so the job must hold no secrets and no write permission. The usage example above is the shape to keep. Committing `.terraform.lock.hcl` would pin provider checksums, but a pull request can edit the lock file in the same commit, so it narrows the window rather than closing it.
Every `required_version` in the directory counts, including one in a `.tf.json`
or split across lines. The floor is the highest lower bound among them, which
is what Terraform itself applies.

## Requirements
## Known limitation

The runner needs Docker, since the parse runs in a container, and `python3`, which reads the result. `ubuntu-latest` has both. The macOS runners have no Docker.
> [!NOTE]
> `terraform validate` treats every root variable as unknown, so it never
> evaluates `validation` blocks. A floor that is wrong only inside a variable
> validation still passes here.

## Tests

`just test` runs `test_read_floor.py`, and CI runs the same command. The constraint cases call the parser directly and need no container, so adding one for a constraint form the parser learns to read or to reject costs nothing. Only the cases that turn on what terraform-docs reports run the script end to end, against the fixtures under `testdata/`.

## Known limitation

`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`.
`just test` runs `test_read_floor.py`, and CI runs the same command.
20 changes: 11 additions & 9 deletions validate-min-terraform/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ inputs:
configuration under test, so on a pull request the branch decides which
Terraform CI installs.
required: false
# Terraform 1.2.2 is the first release past HCSEC-2022-13, which covers
# four go-getter issues reachable while init fetches modules. HashiCorp
# never backported the fix to the 1.0 or 1.1 lines. Raise this to your own
# support floor if you would rather not run old releases at all.
default: 1.2.2
# HashiCorp routes a version to a support regime by release date, and 1.14
# falls in the band still receiving fixes and security patches, so this is
# the oldest release with a published claim to them. It wants revisiting
# when a new minor lands. See the README.
default: 1.14.0
terraform-docs-image:
description: >-
Image used to parse the configuration. Pin by digest, not by tag.
required: false
# terraform-docs publishes no immutable releases, which leaves both a tag
# and a release checksum able to change underneath us. A digest is content
# addressed, so it cannot. This one is terraform-docs 0.20.0.
# addressed, so it cannot. This one is terraform-docs 0.20.0, the last
# release published as a multi-arch index. Later tags are per-architecture,
# so a digest pin would have to choose one.
default: quay.io/terraform-docs/terraform-docs@sha256:37329e2dc2518e7f719a986a3954b10771c3fe000f50f83fd4d98d489df2eae2

outputs:
Expand All @@ -48,9 +50,9 @@ runs:
env:
TERRAFORM_DOCS_IMAGE: ${{ inputs.terraform-docs-image }}
MINIMUM_FLOOR: ${{ inputs.minimum-floor }}
run: |
floor=$("$GITHUB_ACTION_PATH/read_floor.py")
echo "version=$floor" >> "$GITHUB_OUTPUT"
# The script writes the step output itself, so this step carries no shell
# logic. A composite step has to name a shell even so.
run: '"$GITHUB_ACTION_PATH/read_floor.py"'

- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
Expand Down
38 changes: 17 additions & 21 deletions validate-min-terraform/read_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import os
import re
import shlex
import subprocess
import sys

Expand Down Expand Up @@ -89,26 +90,12 @@ def read_constraints(image, directory):
into the mounted directory under a config-supplied output.file.
"""
result = subprocess.run(
[
"docker",
"run",
"--rm",
"--network",
"none",
"-u",
f"{os.getuid()}:{os.getgid()}",
"-v",
f"{directory}:/wd:ro",
"-w",
"/wd",
image,
"json",
".",
"--output-file",
"",
"--show",
"requirements",
],
shlex.split(
"docker run --rm --network none"
f" -u {os.getuid()}:{os.getgid()}"
f" -v {shlex.quote(f'{directory}:/wd:ro')} -w /wd {shlex.quote(image)}"
" json . --output-file '' --show requirements"
),
capture_output=True,
text=True,
check=False,
Expand All @@ -130,10 +117,19 @@ def main():
minimum = parse_version(os.environ["MINIMUM_FLOOR"])
try:
constraints = read_constraints(image, os.getcwd())
print(format_version(derive_floor(constraints, minimum)))
floor = format_version(derive_floor(constraints, minimum))
except FloorError as error:
print(error, file=sys.stderr)
return 1

print(floor)
# This keeps the shell logic out of the action. Outside a workflow the
# variable is unset and stdout carries the whole result, which is what the
# tests read.
output = os.environ.get("GITHUB_OUTPUT")
if output:
with open(output, "a") as handle:
handle.write(f"version={floor}\n")
return 0


Expand Down
32 changes: 26 additions & 6 deletions validate-min-terraform/test_read_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
import pathlib
import re
import subprocess
import tempfile
import unittest

import read_floor

HERE = pathlib.Path(__file__).parent
DEFAULT_MINIMUM = (1, 2, 2)
DEFAULT_MINIMUM = (1, 14, 0)

# The image the action defaults to, so the tests and the action cannot drift.
IMAGE = os.environ.get("TERRAFORM_DOCS_IMAGE") or re.search(
Expand All @@ -28,7 +29,7 @@
([">= 1.14.0, < 2.0.0"], "1.14.0"),
(["~> 1.14"], "1.14.0"),
([">= 1.9, >= 1.14.0, < 2.0.0"], "1.14.0"),
([">= 1.2.2"], "1.2.2"),
([">= 1.15.0"], "1.15.0"),
# Terraform applies every constraint in the directory at once.
([">= 1.99.0", ">= 1.2.0", ">= 1.14.0"], "1.99.0"),
# A short bound is the lowest release matching it.
Expand Down Expand Up @@ -68,18 +69,18 @@ def test_rejects(self):

def test_a_floor_below_the_minimum_is_refused(self):
with self.assertRaises(read_floor.FloorError) as caught:
read_floor.derive_floor([">= 1.1.0"], DEFAULT_MINIMUM)
read_floor.derive_floor([">= 1.13.0"], DEFAULT_MINIMUM)
self.assertIn("below the minimum", str(caught.exception))

def test_the_minimum_itself_is_allowed(self):
floor = read_floor.derive_floor([">= 1.2.2"], DEFAULT_MINIMUM)
floor = read_floor.derive_floor([">= 1.14.0"], DEFAULT_MINIMUM)
self.assertEqual(floor, DEFAULT_MINIMUM)


class ReadFloorScriptTest(unittest.TestCase):
"""End to end, including the terraform-docs run. These need Docker."""

def run_script(self, directory, minimum="1.2.2"):
def run_script(self, directory, minimum="1.14.0", output=None):
return subprocess.run(
[str(HERE / "read_floor.py")],
cwd=directory,
Expand All @@ -90,9 +91,21 @@ def run_script(self, directory, minimum="1.2.2"):
**os.environ,
"TERRAFORM_DOCS_IMAGE": IMAGE,
"MINIMUM_FLOOR": minimum,
**({"GITHUB_OUTPUT": str(output)} if output else {}),
},
)

def test_it_writes_the_step_output(self):
"""The action reads the floor from GITHUB_OUTPUT, not from stdout."""
with tempfile.TemporaryDirectory() as work:
output = pathlib.Path(work) / "output"
output.touch()
result = self.run_script(
HERE / "testdata" / "multiline-constraint", output=output
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(output.read_text(), "version=1.14.0\n")

def test_several_files_in_one_directory(self):
"""The highest bound wins, and here it is the one in the .tf.json."""
result = self.run_script(HERE / "testdata" / "several-files")
Expand All @@ -111,6 +124,13 @@ def test_a_config_hiding_requirements(self):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "1.14.0")

def test_a_floor_below_the_minimum_is_refused(self):
result = self.run_script(
HERE / "testdata" / "multiline-constraint", minimum="1.15.0"
)
self.assertEqual(result.returncode, 1)
self.assertIn("below the minimum", result.stderr)

def test_no_required_version(self):
result = self.run_script(HERE / "testdata" / "no-constraint")
self.assertEqual(result.returncode, 1)
Expand All @@ -128,7 +148,7 @@ def test_a_container_that_cannot_run_says_so(self):
env={
**os.environ,
"TERRAFORM_DOCS_IMAGE": "quay.io/terraform-docs/terraform-docs:no-such-tag",
"MINIMUM_FLOOR": "1.2.2",
"MINIMUM_FLOOR": "1.14.0",
},
)
self.assertEqual(broken.returncode, 1)
Expand Down