Skip to content

Commit 34d5575

Browse files
feat(build): add TFLint reusable GitHub Actions workflow (#229)
# Pull Request ## Description <!-- Brief description of changes. Link related issues using Closes #123 --> Closes #201 Added **TFLint** as a reusable CI workflow for Terraform linting, with a PowerShell wrapper, Pester tests, and full integration into the PR validation and main pipelines. TFLint **v0.61.0** is pinned in both the devcontainer and the GitHub Actions workflow. A new reusable *terraform-lint.yml* workflow runs TFLint recursively against the *deploy/001-iac* directory using the shared *.tflint.hcl* config. The PowerShell wrapper *Invoke-TFLint.ps1* parses JSON output, emits CI annotations, and writes results to *logs/tflint-results.json*. The workflow supports a **soft-fail** input for gradual adoption. The `terraform-lint` job was wired into both *.github/workflows/pr-validation.yml* and *.github/workflows/main.yml*, with the main workflow gating releases on lint passing via the `release-please` job's `needs` array. All GitHub Actions references use SHA-pinned versions for supply chain security. Pester unit tests cover tool availability detection, clean runs, violation reporting, and output file creation, using existing mock infrastructure and new Terraform test fixtures. ## Type of Change <!-- Mark relevant options with [x] --> - [ ] 🐛 Bug fix (non-breaking change fixing an issue) - [x] ✨ New feature (non-breaking change adding functionality) - [ ] 💥 Breaking change (fix or feature causing existing functionality to change) - [ ] 📚 Documentation update - [x] 🏗️ Infrastructure change (Terraform/IaC) - [ ] ♻️ Refactoring (no functional changes) ## Component(s) Affected <!-- Mark all that apply --> - [ ] `deploy/000-prerequisites` - Azure subscription setup - [ ] `deploy/001-iac` - Terraform infrastructure - [ ] `deploy/002-setup` - OSMO control plane / Helm - [ ] `deploy/004-workflow` - Training workflows - [ ] `src/training` - Python training scripts - [ ] `docs/` - Documentation ## Testing Performed <!-- Describe testing. Check applicable items --> - [ ] Terraform `plan` reviewed (no unexpected changes) - [ ] Terraform `apply` tested in dev environment - [ ] Training scripts tested locally with Isaac Sim - [ ] OSMO workflow submitted successfully - [ ] Smoke tests passed (`smoke_test_azure.py`) ## Documentation Impact <!-- Select one --> - [x] No documentation changes needed - [ ] Documentation updated in this PR - [ ] Documentation issue filed ## Bug Fix Checklist *Complete this section for bug fix PRs. Skip for other contribution types.* - [ ] Linked to issue being fixed - [ ] Regression test included, OR - [ ] Justification for no regression test: ## Checklist - [x] My code follows the [project conventions](copilot-instructions.md) - [x] Commit messages follow [conventional commit format](instructions/commit-message.instructions.md) - [x] I have performed a self-review - [x] Documentation impact assessed above - [ ] No new linting warnings introduced
1 parent cd1d979 commit 34d5575

10 files changed

Lines changed: 259 additions & 3 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
"setup-dev": "./setup-dev.sh --disable-venv",
9393
"npm-install": "npm install",
9494
"apt": "sudo apt update && sudo apt install -y shellcheck jq unzip",
95-
"tflint": "curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash",
95+
"tflint": "TFLINT_VERSION=v0.61.0 && curl -sSfL \"https://raw.githubusercontent.com/terraform-linters/tflint/${TFLINT_VERSION}/install_linux.sh\" | bash && tflint --init",
9696
"actionlint": "ACTIONLINT_VERSION=1.7.10 && ACTIONLINT_SHA256=f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f && curl -sSfL \"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz\" -o /tmp/actionlint.tar.gz && echo \"${ACTIONLINT_SHA256} /tmp/actionlint.tar.gz\" | sha256sum -c --quiet - && sudo tar -xzf /tmp/actionlint.tar.gz -C /usr/local/bin actionlint && rm /tmp/actionlint.tar.gz",
9797
"update-bashrc": "echo 'export PATH=\"${containerWorkspaceFolder}/scripts:${containerWorkspaceFolder}/node_modules/.bin:$PATH\"' | sudo tee -a ~/.bashrc",
9898
"osmo-cli": "curl -fsSL https://raw.githubusercontent.com/NVIDIA/OSMO/refs/heads/main/install.sh | bash || true",

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Run `npm install` (or `npm ci`) before any `npm run` lint commands. `shellcheck`
187187

188188
### Linting
189189

190-
* `npm run lint:all` runs `lint:md` + `lint:ps` + `lint:links` + `lint:yaml` in sequence
190+
* `npm run lint:all` runs `lint:md` + `lint:ps` + `lint:links` + `lint:yaml` + `lint:tf` in sequence
191191
* `npm run spell-check` and `npm run format:tables` are NOT included in `lint:all` — run them separately
192192
* `npm run lint:md:fix` and `npm run format:tables` auto-fix markdown issues
193193
* `.copilot-tracking/` is excluded from markdown linting via `.markdownlint-cli2.jsonc`

.github/workflows/main.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ jobs:
113113
permissions:
114114
contents: read
115115

116+
# Terraform linting using TFLint
117+
terraform-lint:
118+
name: Terraform Lint
119+
uses: ./.github/workflows/terraform-lint.yml
120+
with:
121+
soft-fail: true
122+
permissions:
123+
contents: read
124+
116125
# CodeQL security analysis
117126
codeql-analysis:
118127
name: CodeQL Analysis
@@ -137,6 +146,7 @@ jobs:
137146
- pester-tests
138147
- pytest-tests
139148
- python-lint
149+
- terraform-lint
140150
- codeql-analysis
141151
name: Release Please
142152
runs-on: ubuntu-latest

.github/workflows/pr-validation.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ jobs:
138138
permissions:
139139
contents: read
140140

141+
# Terraform linting using TFLint
142+
terraform-lint:
143+
name: Terraform Lint
144+
uses: ./.github/workflows/terraform-lint.yml
145+
with:
146+
soft-fail: true
147+
permissions:
148+
contents: read
149+
141150
# CodeQL security analysis
142151
codeql-analysis:
143152
name: CodeQL Analysis
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Terraform Lint
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
soft-fail:
7+
description: 'Whether to continue on TFLint violations'
8+
required: false
9+
type: boolean
10+
default: false
11+
12+
permissions:
13+
contents: read
14+
15+
defaults:
16+
run:
17+
shell: pwsh
18+
19+
jobs:
20+
tflint:
21+
name: TFLint
22+
runs-on: ubuntu-latest
23+
permissions:
24+
contents: read
25+
steps:
26+
- name: Checkout code
27+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
28+
with:
29+
persist-credentials: false
30+
31+
- name: Create logs directory
32+
run: New-Item -ItemType Directory -Force -Path logs | Out-Null
33+
34+
- name: Setup TFLint
35+
uses: terraform-linters/setup-tflint@4cb9feea73331a35b422df102992a03a44a3bb33 # v6.2.1
36+
with:
37+
tflint_version: v0.61.0
38+
cache: true
39+
40+
- name: Init TFLint
41+
run: tflint --init --config (Resolve-Path .tflint.hcl) --recursive
42+
env:
43+
GITHUB_TOKEN: ${{ github.token }}
44+
45+
- name: Run TFLint
46+
continue-on-error: ${{ inputs.soft-fail }}
47+
run: shared/ci/linting/Invoke-TFLint.ps1
48+
49+
- name: Upload TFLint results
50+
if: always()
51+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
52+
with:
53+
name: tflint-results
54+
path: logs/tflint-results.json
55+
retention-days: 30

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"lint:ps": "pwsh -File shared/ci/linting/Invoke-PSScriptAnalyzer.ps1",
1515
"lint:links": "pwsh -File shared/ci/linting/Invoke-LinkLanguageCheck.ps1",
1616
"lint:yaml": "pwsh -File shared/ci/linting/Invoke-YamlLint.ps1",
17-
"lint:all": "npm run lint:md && npm run lint:ps && npm run lint:links && npm run lint:yaml",
17+
"lint:tf": "pwsh -File shared/ci/linting/Invoke-TFLint.ps1",
18+
"lint:all": "npm run lint:md && npm run lint:ps && npm run lint:links && npm run lint:yaml && npm run lint:tf",
1819
"format:tables": "markdown-table-formatter \"**/*.md\"",
1920
"test:ps": "pwsh -File ./shared/ci/tests/Invoke-PesterTests.ps1",
2021
"prepare": "husky",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env pwsh
2+
# Copyright (c) Microsoft Corporation.
3+
# SPDX-License-Identifier: MIT
4+
5+
#Requires -Version 7.0
6+
7+
<#
8+
.SYNOPSIS
9+
Runs TFLint across Terraform directories.
10+
.DESCRIPTION
11+
Wraps tflint --recursive with shared .tflint.hcl config. Reports violations
12+
via CI annotations and writes JSON results to logs/.
13+
.PARAMETER ConfigPath
14+
Path to .tflint.hcl. Defaults to repo root .tflint.hcl.
15+
.PARAMETER OutputPath
16+
Path for JSON results. Defaults to logs/tflint-results.json.
17+
.PARAMETER TerraformDir
18+
Directory containing Terraform files. Defaults to infrastructure/terraform.
19+
#>
20+
21+
[CmdletBinding()]
22+
param(
23+
[string]$ConfigPath,
24+
[string]$OutputPath,
25+
[string]$TerraformDir
26+
)
27+
28+
Set-StrictMode -Version Latest
29+
$ErrorActionPreference = 'Stop'
30+
31+
Import-Module (Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1") -Force
32+
Import-Module (Join-Path $PSScriptRoot "../../../scripts/lib/Modules/CIHelpers.psm1") -Force
33+
34+
function Invoke-TFLintCore {
35+
[CmdletBinding()]
36+
param(
37+
[string]$ConfigPath,
38+
[string]$OutputPath,
39+
[string]$TerraformDir
40+
)
41+
42+
$repoRoot = & git rev-parse --show-toplevel 2>$null
43+
if (-not $repoRoot) {
44+
$repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName
45+
}
46+
47+
if (-not $ConfigPath) { $ConfigPath = Join-Path $repoRoot '.tflint.hcl' }
48+
if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot 'logs/tflint-results.json' }
49+
if (-not $TerraformDir) { $TerraformDir = Join-Path $repoRoot 'infrastructure/terraform' }
50+
51+
$outputDir = Split-Path $OutputPath -Parent
52+
if (-not (Test-Path $outputDir)) {
53+
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
54+
}
55+
56+
if (-not (Get-Command tflint -ErrorAction SilentlyContinue)) {
57+
Write-CIAnnotation -Level Error -Message 'tflint is not installed or not in PATH'
58+
return 1
59+
}
60+
61+
$resolvedConfig = Resolve-Path $ConfigPath
62+
63+
# Run TFLint with JSON output for parsing
64+
$jsonOutput = & tflint --recursive --chdir="$TerraformDir" --config $resolvedConfig --format json 2>&1
65+
$exitCode = $LASTEXITCODE
66+
67+
$jsonOutput | Out-File -FilePath $OutputPath -Encoding utf8
68+
69+
# Parse results for CI annotations
70+
$results = $null
71+
try {
72+
$results = $jsonOutput | ConvertFrom-Json -ErrorAction Stop
73+
if ($results.issues) {
74+
foreach ($issue in $results.issues) {
75+
$level = if ($issue.rule.severity -eq 'error') { 'Error' } else { 'Warning' }
76+
Write-CIAnnotation -Level $level -Message $issue.message `
77+
-File $issue.range.filename -Line $issue.range.start.line
78+
}
79+
}
80+
}
81+
catch {
82+
Write-Warning "Failed to parse tflint JSON output: $($_.Exception.Message)"
83+
}
84+
85+
$issueCount = if ($results.issues) { $results.issues.Count } else { 0 }
86+
$summary = "TFLint: $issueCount issue(s) found in $TerraformDir"
87+
Write-CIStepSummary -Content $summary
88+
Write-Host $summary
89+
90+
return $exitCode
91+
}
92+
93+
#region Main Execution
94+
if ($MyInvocation.InvocationName -ne '.') {
95+
try {
96+
$exitCode = Invoke-TFLintCore @PSBoundParameters
97+
exit $exitCode
98+
}
99+
catch {
100+
Write-Error -ErrorAction Continue "Invoke-TFLint failed: $($_.Exception.Message)"
101+
Write-CIAnnotation -Level Error -Message $_.Exception.Message
102+
exit 1
103+
}
104+
}
105+
#endregion Main Execution
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
variable "MyVariable" {
2+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
terraform {
2+
required_version = ">= 1.0"
3+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# SPDX-License-Identifier: MIT
3+
4+
#Requires -Version 7.0
5+
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
6+
7+
BeforeAll {
8+
. $PSScriptRoot/../../linting/Invoke-TFLint.ps1
9+
$ErrorActionPreference = 'Continue'
10+
Import-Module (Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1') -Force
11+
function tflint { }
12+
}
13+
14+
Describe 'Invoke-TFLintCore' -Tag 'Unit' {
15+
BeforeAll { Save-CIEnvironment }
16+
AfterAll { Restore-CIEnvironment }
17+
18+
BeforeEach {
19+
$script:MockFiles = Initialize-MockCIEnvironment -Workspace $TestDrive
20+
$script:TestOutputPath = Join-Path $TestDrive 'logs/tflint-results.json'
21+
$script:TestTerraformDir = Join-Path $TestDrive 'infrastructure/terraform'
22+
$script:TestConfigPath = Join-Path $TestDrive '.tflint.hcl'
23+
24+
New-Item -ItemType Directory -Force -Path $script:TestTerraformDir | Out-Null
25+
'{}' | Out-File $script:TestConfigPath
26+
27+
Mock git { return $TestDrive } -ParameterFilter { $args[0] -eq 'rev-parse' }
28+
Mock tflint { return '{"issues":[],"errors":[]}' }
29+
}
30+
31+
AfterEach {
32+
Restore-CIEnvironment
33+
Remove-MockCIFiles -MockFiles $script:MockFiles
34+
}
35+
36+
Context 'tool availability' {
37+
It 'Returns 1 when tflint is not installed' {
38+
Mock Get-Command { return $null } -ParameterFilter { $Name -eq 'tflint' }
39+
$result = Invoke-TFLintCore -ConfigPath $script:TestConfigPath `
40+
-OutputPath $script:TestOutputPath -TerraformDir $script:TestTerraformDir
41+
$result | Should -Be 1
42+
}
43+
}
44+
45+
Context 'clean run' {
46+
It 'Returns 0 when no issues found' {
47+
Mock tflint { $global:LASTEXITCODE = 0; return '{"issues":[],"errors":[]}' }
48+
$result = Invoke-TFLintCore -ConfigPath $script:TestConfigPath `
49+
-OutputPath $script:TestOutputPath -TerraformDir $script:TestTerraformDir
50+
$result | Should -Be 0
51+
}
52+
}
53+
54+
Context 'violations found' {
55+
It 'Returns non-zero when violations found' {
56+
Mock tflint { $global:LASTEXITCODE = 2; return '{"issues":[{"rule":{"name":"terraform_naming_convention","severity":"warning","link":""},"message":"test violation","range":{"filename":"main.tf","start":{"line":1,"column":1},"end":{"line":1,"column":10}},"callers":[]}],"errors":[]}' }
57+
$result = Invoke-TFLintCore -ConfigPath $script:TestConfigPath `
58+
-OutputPath $script:TestOutputPath -TerraformDir $script:TestTerraformDir
59+
$result | Should -Not -Be 0
60+
}
61+
}
62+
63+
Context 'output file creation' {
64+
It 'Creates output file' {
65+
Mock tflint { $global:LASTEXITCODE = 0; return '{"issues":[],"errors":[]}' }
66+
Invoke-TFLintCore -ConfigPath $script:TestConfigPath `
67+
-OutputPath $script:TestOutputPath -TerraformDir $script:TestTerraformDir
68+
$script:TestOutputPath | Should -Exist
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)