Skip to content

Commit 79a4ade

Browse files
committed
feat(terraform-docs): monorepo-aware doc generation with tests
Find directories where Terraform files have changed via `git diff` and run terraform-docs in each, honoring a root .terraform-docs.yaml when present. Skips quietly when no Terraform files changed. Refactors the script into testable functions and adds a unit test suite covering directory discovery, config-vs-default command selection, and unstaged-README detection (including newly-created untracked READMEs). Builds on #1070 with these fixes: - Restore executable bit on terraform-docs.py. - Use `markdown-table` (single subcommand) for the no-config fallback. - Use `subprocess(cwd=...)` instead of os.chdir to avoid global side effects. - Prefer `git diff --cached` (the pre-commit source of truth), then union unstaged changes. - Skip directories that no longer exist (e.g. when a module was deleted) so the script doesn't crash mid-run. - Detect untracked README.md files in addition to modified ones.
1 parent 1c1e4c8 commit 79a4ade

3 files changed

Lines changed: 286 additions & 27 deletions

File tree

.trunk/trunk.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ actions:
104104

105105
enabled:
106106
# enabled actions inherited from github.com/trunk-io/configs plugin
107+
- terraform-docs
107108
- linter-test-helper
108109
- npm-check-pre-push
109110
- remove-release-snapshots

actions/terraform-docs/terraform-docs.py

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,38 @@
44
55
This script acts as a pre-commit hook to ensure terraform documentation is up to date.
66
It performs the following:
7-
1. Runs terraform-docs to update documentation
8-
2. Checks if any README.md files show up in the unstaged changes
9-
3. Exits with failure if there are unstaged README changes, success otherwise
7+
1. Finds directories where Terraform files have changed
8+
2. Runs terraform-docs in each directory containing changed Terraform files
9+
3. Checks if any README.md files show up in the unstaged changes
10+
4. Exits with failure if there are unstaged README changes, success otherwise
1011
"""
1112

12-
# trunk-ignore(bandit/B404)
13-
import subprocess
13+
import os
14+
import subprocess # trunk-ignore(bandit/B404)
1415
import sys
1516

17+
TERRAFORM_EXTENSIONS = (".tf", ".tofu", ".tfvars")
18+
CONFIG_FILENAME = ".terraform-docs.yaml"
1619

17-
def run_command(cmd):
20+
21+
def run_command(cmd, cwd=None):
1822
"""
1923
Execute a shell command and return its exit code, stdout, and stderr.
2024
2125
Args:
2226
cmd: List of command arguments to execute
27+
cwd: Optional working directory in which to run the command
2328
2429
Returns:
2530
Tuple containing (return_code, stdout, stderr)
2631
"""
2732
try:
28-
2933
process = subprocess.Popen(
3034
cmd,
3135
stdout=subprocess.PIPE,
3236
stderr=subprocess.PIPE,
3337
universal_newlines=True,
38+
cwd=cwd,
3439
# trunk-ignore(bandit/B603)
3540
shell=False, # Explicitly disable shell to prevent command injection
3641
)
@@ -46,28 +51,98 @@ def run_command(cmd):
4651
sys.exit(1)
4752

4853

49-
# First, run terraform-docs to update documentation
50-
update_cmd = ["terraform-docs", "."]
51-
return_code, stdout, stderr = run_command(update_cmd)
54+
def terraform_dirs_from_paths(paths, repo_root="."):
55+
"""
56+
Given an iterable of repo-relative file paths, return the set of directories
57+
that contain Terraform files and still exist on disk.
58+
59+
Deleted files are skipped because their parent directory may no longer exist
60+
(or the module may have been removed entirely), and there's nothing for
61+
terraform-docs to document there.
62+
"""
63+
dirs = set()
64+
for file_path in paths:
65+
file_path = file_path.strip()
66+
if not file_path or not file_path.endswith(TERRAFORM_EXTENSIONS):
67+
continue
68+
dir_path = os.path.dirname(file_path) or "."
69+
abs_dir = dir_path if os.path.isabs(dir_path) else os.path.join(repo_root, dir_path)
70+
if os.path.isdir(abs_dir):
71+
dirs.add(dir_path)
72+
return dirs
73+
74+
75+
def get_changed_terraform_directories():
76+
"""
77+
Return the set of directories containing Terraform files that are part of
78+
this commit (staged) or have been modified in the working tree.
79+
80+
The hook runs pre-commit, so staged changes are the primary source of truth;
81+
we also include unstaged edits so that a developer iterating in the working
82+
tree sees their docs regenerated.
83+
"""
84+
paths = set()
85+
for diff_args in (["--cached", "--name-only"], ["--name-only"]):
86+
cmd = ["git", "diff", *diff_args]
87+
return_code, stdout, _stderr = run_command(cmd)
88+
if return_code != 0:
89+
continue
90+
paths.update(stdout.splitlines())
91+
return terraform_dirs_from_paths(paths)
92+
93+
94+
def build_terraform_docs_cmd(repo_root):
95+
"""Pick the terraform-docs invocation based on whether a config file exists."""
96+
config_file_path = os.path.join(repo_root, CONFIG_FILENAME)
97+
if os.path.exists(config_file_path):
98+
return ["terraform-docs", "--config", config_file_path, "."]
99+
return ["terraform-docs", "markdown-table", "."]
100+
101+
102+
def find_unstaged_readmes(porcelain_output):
103+
"""
104+
Parse `git status --porcelain` output and return README.md paths that are
105+
either modified-but-unstaged or untracked. Both states block the commit
106+
because the developer needs to `git add` the regenerated docs.
107+
"""
108+
unstaged = []
109+
for line in porcelain_output.splitlines():
110+
if len(line) < 3:
111+
continue
112+
status = line[:2]
113+
path = line[3:].strip()
114+
# `_M` = unstaged modification (any X), `??` = untracked.
115+
if (status[1] == "M" or status == "??") and path.endswith("README.md"):
116+
unstaged.append(path)
117+
return unstaged
118+
119+
120+
def main():
121+
repo_root = os.getcwd()
122+
terraform_dirs = get_changed_terraform_directories()
123+
124+
if not terraform_dirs:
125+
print("terraform-docs: No Terraform files changed, skipping documentation update")
126+
return 0
127+
128+
update_cmd = build_terraform_docs_cmd(repo_root)
52129

53-
if stderr:
54-
print(f"terraform-docs error: Warning during execution:\n{stderr}", file=sys.stderr)
130+
for directory in sorted(terraform_dirs):
131+
print(f"terraform-docs: Updating documentation in {directory}")
132+
target = directory if directory != "." else repo_root
133+
_return_code, _stdout, stderr = run_command(update_cmd, cwd=target)
134+
if stderr:
135+
print(f"terraform-docs warning in {directory}: {stderr}", file=sys.stderr)
55136

56-
# Check git status for unstaged README changes
57-
status_cmd = ["git", "status", "--porcelain"]
58-
return_code, stdout, stderr = run_command(status_cmd)
137+
_return_code, stdout, _stderr = run_command(["git", "status", "--porcelain"])
138+
unstaged_readmes = find_unstaged_readmes(stdout)
139+
if unstaged_readmes:
140+
print("terraform-docs error: Please stage any README changes before committing.")
141+
return 1
59142

60-
# Look for any README.md files in the unstaged changes
61-
unstaged_readmes = [
62-
line.split()[-1]
63-
for line in stdout.splitlines()
64-
if line.startswith(" M") and line.endswith("README.md")
65-
]
143+
print("terraform-docs: Documentation is up to date")
144+
return 0
66145

67-
# Check if we found any unstaged README files
68-
if len(unstaged_readmes) > 0:
69-
print("terraform-docs error: Please stage any README changes before committing.")
70-
sys.exit(1)
71146

72-
print("terraform-docs: Documentation is up to date")
73-
sys.exit(0)
147+
if __name__ == "__main__":
148+
sys.exit(main())
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Unit tests for terraform-docs.py.
4+
5+
These tests cover the directory-discovery and README-status parsing logic that
6+
underpins the monorepo-aware behaviour of the action. They do not require
7+
terraform-docs or git to be installed.
8+
9+
Run with: python3 -m unittest actions/terraform-docs/test_terraform_docs.py
10+
"""
11+
12+
import importlib.util
13+
import os
14+
import pathlib
15+
import tempfile
16+
import unittest
17+
18+
HERE = pathlib.Path(__file__).resolve().parent
19+
SCRIPT = HERE / "terraform-docs.py"
20+
21+
22+
def _load_script_module():
23+
spec = importlib.util.spec_from_file_location("terraform_docs_action", SCRIPT)
24+
module = importlib.util.module_from_spec(spec)
25+
spec.loader.exec_module(module)
26+
return module
27+
28+
29+
terraform_docs = _load_script_module()
30+
31+
32+
class TerraformDirsFromPathsTest(unittest.TestCase):
33+
def setUp(self):
34+
self.tmp = tempfile.TemporaryDirectory()
35+
self.addCleanup(self.tmp.cleanup)
36+
self.root = self.tmp.name
37+
38+
def _touch(self, *parts):
39+
path = os.path.join(self.root, *parts)
40+
os.makedirs(os.path.dirname(path), exist_ok=True)
41+
pathlib.Path(path).touch()
42+
43+
def test_groups_files_by_directory(self):
44+
for rel in ("modules/a/main.tf", "modules/a/vars.tf", "modules/b/main.tf"):
45+
self._touch(*rel.split("/"))
46+
47+
dirs = terraform_docs.terraform_dirs_from_paths(
48+
[
49+
"modules/a/main.tf",
50+
"modules/a/vars.tf",
51+
"modules/b/main.tf",
52+
],
53+
repo_root=self.root,
54+
)
55+
56+
self.assertEqual(dirs, {"modules/a", "modules/b"})
57+
58+
def test_root_level_files_use_dot(self):
59+
self._touch("main.tf")
60+
61+
dirs = terraform_docs.terraform_dirs_from_paths(["main.tf"], repo_root=self.root)
62+
63+
self.assertEqual(dirs, {"."})
64+
65+
def test_picks_up_all_terraform_extensions(self):
66+
for rel in ("a/x.tf", "b/y.tofu", "c/z.tfvars"):
67+
self._touch(*rel.split("/"))
68+
69+
dirs = terraform_docs.terraform_dirs_from_paths(
70+
["a/x.tf", "b/y.tofu", "c/z.tfvars"], repo_root=self.root
71+
)
72+
73+
self.assertEqual(dirs, {"a", "b", "c"})
74+
75+
def test_ignores_non_terraform_files(self):
76+
self._touch("modules/a/main.tf")
77+
# README.md and other files should never trigger a re-run.
78+
dirs = terraform_docs.terraform_dirs_from_paths(
79+
["modules/a/README.md", "src/main.go", "modules/a/main.tf"],
80+
repo_root=self.root,
81+
)
82+
83+
self.assertEqual(dirs, {"modules/a"})
84+
85+
def test_skips_deleted_directories(self):
86+
# `modules/gone/main.tf` was deleted along with its directory; the
87+
# function should silently drop it instead of crashing later when we
88+
# try to chdir/run there.
89+
self._touch("modules/here/main.tf")
90+
91+
dirs = terraform_docs.terraform_dirs_from_paths(
92+
["modules/here/main.tf", "modules/gone/main.tf"],
93+
repo_root=self.root,
94+
)
95+
96+
self.assertEqual(dirs, {"modules/here"})
97+
98+
def test_handles_empty_input(self):
99+
self.assertEqual(
100+
terraform_docs.terraform_dirs_from_paths([], repo_root=self.root),
101+
set(),
102+
)
103+
104+
def test_strips_whitespace_and_blank_lines(self):
105+
self._touch("modules/a/main.tf")
106+
dirs = terraform_docs.terraform_dirs_from_paths(
107+
["", " ", "modules/a/main.tf\n"], repo_root=self.root
108+
)
109+
self.assertEqual(dirs, {"modules/a"})
110+
111+
112+
class BuildTerraformDocsCmdTest(unittest.TestCase):
113+
def setUp(self):
114+
self.tmp = tempfile.TemporaryDirectory()
115+
self.addCleanup(self.tmp.cleanup)
116+
self.root = self.tmp.name
117+
118+
def test_uses_config_when_present(self):
119+
config = os.path.join(self.root, ".terraform-docs.yaml")
120+
pathlib.Path(config).write_text("formatter: markdown table\n")
121+
122+
cmd = terraform_docs.build_terraform_docs_cmd(self.root)
123+
124+
self.assertEqual(cmd, ["terraform-docs", "--config", config, "."])
125+
126+
def test_falls_back_to_markdown_table_subcommand(self):
127+
cmd = terraform_docs.build_terraform_docs_cmd(self.root)
128+
129+
# `markdown-table` is a single positional subcommand recognised by
130+
# terraform-docs; the previous form (`markdown table`) split it across
131+
# two args, which only worked by accident.
132+
self.assertEqual(cmd, ["terraform-docs", "markdown-table", "."])
133+
134+
135+
class FindUnstagedReadmesTest(unittest.TestCase):
136+
def test_detects_modified_but_unstaged_readme(self):
137+
porcelain = " M modules/a/README.md\n"
138+
self.assertEqual(
139+
terraform_docs.find_unstaged_readmes(porcelain),
140+
["modules/a/README.md"],
141+
)
142+
143+
def test_detects_partially_staged_readme(self):
144+
# `MM` means staged + further unstaged changes; the unstaged half still
145+
# needs to be added before the commit can land.
146+
porcelain = "MM modules/a/README.md\n"
147+
self.assertEqual(
148+
terraform_docs.find_unstaged_readmes(porcelain),
149+
["modules/a/README.md"],
150+
)
151+
152+
def test_detects_newly_generated_untracked_readme(self):
153+
# A previously-undocumented module gets its first README.md on this
154+
# run; `??` means untracked. The original code missed this case.
155+
porcelain = "?? modules/new/README.md\n"
156+
self.assertEqual(
157+
terraform_docs.find_unstaged_readmes(porcelain),
158+
["modules/new/README.md"],
159+
)
160+
161+
def test_ignores_fully_staged_readme(self):
162+
porcelain = "M modules/a/README.md\n"
163+
self.assertEqual(terraform_docs.find_unstaged_readmes(porcelain), [])
164+
165+
def test_ignores_other_files(self):
166+
porcelain = " M modules/a/main.tf\n M src/app.go\n"
167+
self.assertEqual(terraform_docs.find_unstaged_readmes(porcelain), [])
168+
169+
def test_handles_multiple_entries(self):
170+
porcelain = (
171+
" M modules/a/README.md\n"
172+
"M modules/b/README.md\n"
173+
"?? modules/c/README.md\n"
174+
" M modules/d/main.tf\n"
175+
)
176+
self.assertEqual(
177+
sorted(terraform_docs.find_unstaged_readmes(porcelain)),
178+
["modules/a/README.md", "modules/c/README.md"],
179+
)
180+
181+
182+
if __name__ == "__main__":
183+
unittest.main()

0 commit comments

Comments
 (0)