Skip to content

feat: enforce Terraform version floor in CI - #94

Merged
bryce-lynn-nttd merged 2 commits into
mainfrom
feat/wire-version-floor-check
Aug 4, 2026
Merged

feat: enforce Terraform version floor in CI#94
bryce-lynn-nttd merged 2 commits into
mainfrom
feat/wire-version-floor-check

Conversation

@bryce-lynn-nttd

Copy link
Copy Markdown
Contributor

Summary

Runs the Terraform version-floor guard in reusable-terraform-check.yml, after make 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_version floor 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.sh and 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#93 is in: provider downloads dominate terraform 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

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>
@github-actions github-actions Bot added the CI/CD label Jul 31, 2026
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>
@bryce-lynn-nttd bryce-lynn-nttd changed the title ci: verify each module's declared Terraform version floor feat: verify each module's declared Terraform version floor Jul 31, 2026
@github-actions github-actions Bot added feature and removed CI/CD labels Jul 31, 2026
@bryce-lynn-nttd

Copy link
Copy Markdown
Contributor Author

Two fixes from reviewing this PR against itself, both now verified

1. The floor Terraform was never cached

The check installs the oldest Terraform its constraint admits, but nothing cached 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 — every affected run would have re-downloaded it. This is the cost this whole approach was supposed to avoid, and --print-floor existed precisely to key a cache entry, so not wiring it up was an omission rather than a decision.

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

update-lint-status keyed only on steps.lint.outcome, so a version-floor failure surfaced as Terraform Lint: success beside a failed Lint Module job. Merge was never actually at risk — the required Check Azure Terraform Code status is gated through needs: [validate-inputs, lint, tests] on the legacy job, so a failed lint job means it's skipped and the required check is never posted — but the status was actively misleading to a reviewer. It now reflects both steps.

Verified end to end

Nothing in this repo invokes reusable-terraform-check.yml, so this PR's CI never executes what it changes. I exercised it against tf-azurerm-module_primitive-eventhub via a throwaway draft PR (since closed, branch deleted), in both rollout modes.

Guard absent — the state every converted repo is in until the skeleton update propagates:

Step Result
Resolve declared Terraform version floor success (does not fail on the missing guard)
Cache floor-version Terraform skipped (empty version)
Verify declared Terraform version floor success

and the skip is visible rather than silent:

##[notice].github/scripts/check-terraform-version-floor.sh is not present in this
repository yet. It arrives with the next launch-terraform-skeleton update.

Guard present — simulated by adding the script and Make target to the test branch. eventhub declares >= 1.5.0, < 2.0, so the floor resolved to 1.5.0, which is not in its .tool-versions (pinned at 1.10.4) — so this also exercised the asdf install path:

Step Result
Resolve declared Terraform version floor success
Cache floor-version Terraform success (ran, not skipped)
Verify declared Terraform version floor success
Post Cache floor-version Terraform success

The cache is genuinely populated, not an empty archive:

Linux-tf-floor-1.5.0   18 MiB

And the status now carries both outcomes:

Terraform Lint: success — Terraform lint success, version floor success

Known limitation

The guard checks the root module only, not examples/*. Example versions.tf drifting from the root's constraint would not be caught. That is a real gap — I hit it by hand this morning across 12 example directories while fixing the floor PRs — but widening scope belongs in a follow-up rather than here.

Generated with Cursor Agent (Opus 5)

@bryce-lynn-nttd bryce-lynn-nttd changed the title feat: verify each module's declared Terraform version floor feat: enforce Terraform version floor in CI Jul 31, 2026
@github-actions github-actions Bot added feature and removed feature labels Jul 31, 2026

@aarti-joshi-nttd aarti-joshi-nttd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ::notice rather 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-eventhub in both rollout modes is the right substitute for CI here, since this repo doesn't invoke its own reusable workflow.

Non-blocking notes

  1. 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.
  2. Rollout coordination — Once skeleton #38 propagates, the seven modules with incorrect floors will start failing until their fix PRs merge. Expected, but worth tracking.
  3. Known scope gap — Root module only, not examples/*. Fine for a follow-up as documented.

Recommended merge sequence

  1. launch-workflows #93
  2. launch-terraform-skeleton #38
  3. launch-workflows #94 (this PR)
  4. Module floor-fix PRs

Comment thread .github/workflows/reusable-terraform-check.yml
Comment thread .github/workflows/reusable-terraform-check.yml

@rakesh-gorige-nttd rakesh-gorige-nttd left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lint and 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 success in 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.

@bryce-lynn-nttd
bryce-lynn-nttd merged commit 3a32c1c into main Aug 4, 2026
7 checks passed
@bryce-lynn-nttd
bryce-lynn-nttd deleted the feat/wire-version-floor-check branch August 4, 2026 20:48
bryce-lynn-nttd added a commit that referenced this pull request Aug 4, 2026
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.
bryce-lynn-nttd added a commit that referenced this pull request Aug 6, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants