feat: enforce Terraform version floor in CI - #94
Conversation
Runs the version-floor guard after `make lint`, so a module whose required_version claims support for a version it cannot actually be loaded with fails its own PR rather than a consumer's apply. The guard itself ships via launch-terraform-skeleton, so it will not exist in a repository until that update propagates. The step therefore skips when the script is absent -- but emits a workflow notice while doing so, so a skipped check is visible rather than looking like a pass. Requires launch-terraform-skeleton#38. Co-authored-by: Cursor <cursoragent@cursor.com>
Two corrections found while reviewing this PR against itself. The check installs the oldest Terraform its constraint admits, but nothing was caching that binary. The asdf tool cache is keyed on .tool-versions and only saved on a miss, so a version installed after that restore is discarded -- meaning every affected run re-downloaded it. Resolving the floor first (via the guard's --print-floor mode, which exists for this) allows a cache entry keyed on the resolved version. The resolve step emits an empty value when the guard has not propagated yet or when the constraint declares no lower bound, so the cache is skipped and the real diagnostic still comes from the check itself. The posted "Terraform Lint" status keyed only on make lint, so a version-floor failure showed as "Terraform Lint: success" beside a red job. Merge was still blocked -- the legacy required check is gated through `needs` on the lint job -- but the status was misleading. It now reflects both steps. Step ids use underscores so they can be referenced in expressions without bracket syntax. Co-authored-by: Cursor <cursoragent@cursor.com>
Two fixes from reviewing this PR against itself, both now verified1. The floor Terraform was never cachedThe check installs the oldest Terraform its constraint admits, but nothing cached that binary. The asdf tool cache is keyed on Now: resolve the floor first, then cache keyed on the resolved version. The resolve step emits an empty value in the two cases where there is nothing to cache — the guard hasn't propagated to the repo yet, or the constraint declares no lower bound — so the cache is skipped and the real diagnostic still comes from the check itself rather than a cryptic early failure. 2. The posted status could report success while the job was red
Verified end to endNothing in this repo invokes Guard absent — the state every converted repo is in until the skeleton update propagates:
and the skip is visible rather than silent: Guard present — simulated by adding the script and Make target to the test branch.
The cache is genuinely populated, not an empty archive: And the status now carries both outcomes: Known limitationThe guard checks the root module only, not Generated with Cursor Agent (Opus 5) |
aarti-joshi-nttd
left a comment
There was a problem hiding this comment.
Review summary
Verdict: Approve with minor notes — focused, well-documented CI change. The two issues caught during self-review (missing floor-Terraform cache, misleading lint status) were real and are now fixed.
What looks good
- Safe rollout: skip when the guard script is absent, with a visible
::noticerather than a silent pass. - Smart ordering: version-floor check runs after
make lint, so we don't pay for an extra Terraform install when lint already failed. - Separate cache keyed on the resolved floor version correctly addresses the asdf save timing (floor binary is installed after the main asdf cache save).
- Manual end-to-end verification on
tf-azurerm-module_primitive-eventhubin both rollout modes is the right substitute for CI here, since this repo doesn't invoke its own reusable workflow.
Non-blocking notes
- Merge order — As noted in the PR body, #93 (provider plugin cache) should land first to avoid doubling provider downloads once this check runs a second
terraform init. - Rollout coordination — Once skeleton #38 propagates, the seven modules with incorrect floors will start failing until their fix PRs merge. Expected, but worth tracking.
- Known scope gap — Root module only, not
examples/*. Fine for a follow-up as documented.
Recommended merge sequence
rakesh-gorige-nttd
left a comment
There was a problem hiding this comment.
LGTM — second approval.
Ready to merge: #93 and skeleton #38 are merged, so the provider-cache prerequisite and guard script path are in place.
What works well
- Floor resolve → cache → check ordering fixes the asdf save timing issue.
- Lint status now reflects both
make lintand version-floor outcome. - Loud skip (
::notice) when the guard is absent — better than a silent pass.
Non-blocking (agree with @aarti-joshi-nttd)
- Skipped guard still reports
version floor successin the status description; worth a follow-up to distinguish skipped vs passed. examples/*not in scope yet — track separately.
Merge sequence item 3 — good to land once this gets a second approval.
Exiting 0 when the guard has not propagated made steps.version_floor.outcome report success, so the commit status read "version floor success" without anything being verified. During rollout that is most of the fleet. Also surfaces stderr from --print-floor: resolution failing stays non-fatal, but an unexpected failure no longer goes unlogged. Both raised by @aarti-joshi-nttd on #94.
Exiting 0 when the guard has not propagated made steps.version_floor.outcome report success, so the commit status read "version floor success" without anything being verified. During rollout that is most of the fleet. Also surfaces stderr from --print-floor: resolution failing stays non-fatal, but an unexpected failure no longer goes unlogged. Both raised by @aarti-joshi-nttd on #94.
Summary
Runs the Terraform version-floor guard in
reusable-terraform-check.yml, aftermake lint.Depends on
launch-terraform-skeleton#38, which ships the guard itself. Safe to merge before that propagates — see below.Why
Modules across the fleet declare a
required_versionfloor lower than their configuration actually supports. A consumer on a version inside the declared range satisfies the constraint and then hits a hard parse error, so the constraint misleads rather than protects.Measured across all 322
tf-*-module_*repositories: of the 93 copier-converted ones, 7 declared a floor their own configuration rejects — confirmed by loading each with the oldest Terraform its constraint permitted and watching Terraform refuse it. Fix PRs for all seven are open, so this should arrive green.Rollout safety
The guard lives at
.github/scripts/check-terraform-version-floor.shand arrives via the skeleton update, so it will not exist in a repository until that propagates. The step therefore skips when the script is absent.It skips loudly — emitting a
::notice— rather than passing silently. A check that quietly no-ops is worse than no check, since it reads as green without having verified anything. The notice makes the transitional state visible in the job summary and gives it an obvious end condition.Approach
Deliberately not a table of "which feature needs which Terraform version" — that list grows indefinitely and silently passes anything unlisted. The guard resolves the oldest version the constraint admits and asks Terraform to load the module with it, so it stays correct as new language features ship without anyone maintaining it.
Cost
Near zero once
launch-workflows#93is in: provider downloads dominateterraform init, and provider binaries are independent of Terraform core version, so this second init reuses the same cached providers. Without that cache this would double provider downloads, so #93 should merge first.Generated with Cursor Agent (Opus 5)
Made with Cursor