Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/actions/action_tests/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ runs:
with:
version: 0.7.8

# The terraform-docs action's pre-commit hook shells out to
# `terraform-docs`, so the binary needs to be on PATH inside the action
# sandbox. There is no maintained setup-terraform-docs action, so install
# the release tarball directly. Action Tests only run on ubuntu-latest.
- name: Setup terraform-docs
run: |
version=0.16.0
tmp=$(mktemp -d)
curl -sSLo "$tmp/terraform-docs.tgz" \
"https://github.com/terraform-docs/terraform-docs/releases/download/v${version}/terraform-docs-v${version}-linux-amd64.tar.gz"
tar -xzf "$tmp/terraform-docs.tgz" -C "$tmp"
sudo install -m 0755 "$tmp/terraform-docs" /usr/local/bin/terraform-docs
terraform-docs --version
shell: bash

- name: Specify defaults
run: |
echo "CLI_PATH=${{ inputs.cli-path }}" >> "$GITHUB_ENV"
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ junit.xml

# Snyk
.dccache

# Python
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ actions:

enabled:
# enabled actions inherited from github.com/trunk-io/configs plugin
- terraform-docs
- linter-test-helper
- npm-check-pre-push
- remove-release-snapshots
Expand Down
135 changes: 108 additions & 27 deletions actions/terraform-docs/terraform-docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,38 @@

This script acts as a pre-commit hook to ensure terraform documentation is up to date.
It performs the following:
1. Runs terraform-docs to update documentation
2. Checks if any README.md files show up in the unstaged changes
3. Exits with failure if there are unstaged README changes, success otherwise
1. Finds directories where Terraform files have changed
2. Runs terraform-docs in each directory containing changed Terraform files
3. Checks if any README.md files show up in the unstaged changes
4. Exits with failure if there are unstaged README changes, success otherwise
"""

# trunk-ignore(bandit/B404)
import subprocess
import os
import subprocess # trunk-ignore(bandit/B404)
import sys

TERRAFORM_EXTENSIONS = (".tf", ".tofu", ".tfvars")
CONFIG_FILENAME = ".terraform-docs.yaml"

def run_command(cmd):

def run_command(cmd, cwd=None):
"""
Execute a shell command and return its exit code, stdout, and stderr.

Args:
cmd: List of command arguments to execute
cwd: Optional working directory in which to run the command

Returns:
Tuple containing (return_code, stdout, stderr)
"""
try:

process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
cwd=cwd,
# trunk-ignore(bandit/B603)
shell=False, # Explicitly disable shell to prevent command injection
)
Expand All @@ -46,28 +51,104 @@ def run_command(cmd):
sys.exit(1)


# First, run terraform-docs to update documentation
update_cmd = ["terraform-docs", "."]
return_code, stdout, stderr = run_command(update_cmd)
def terraform_dirs_from_paths(paths, repo_root="."):
"""
Given an iterable of repo-relative file paths, return the set of directories
that contain Terraform files and still exist on disk.

Deleted files are skipped because their parent directory may no longer exist
(or the module may have been removed entirely), and there's nothing for
terraform-docs to document there.
"""
dirs = set()
for file_path in paths:
file_path = file_path.strip()
if not file_path or not file_path.endswith(TERRAFORM_EXTENSIONS):
continue
dir_path = os.path.dirname(file_path) or "."
abs_dir = (
dir_path if os.path.isabs(dir_path) else os.path.join(repo_root, dir_path)
)
if os.path.isdir(abs_dir):
dirs.add(dir_path)
return dirs


def get_changed_terraform_directories():
"""
Return the set of directories containing Terraform files that are part of
this commit (staged) or have been modified in the working tree.

The hook runs pre-commit, so staged changes are the primary source of truth;
we also include unstaged edits so that a developer iterating in the working
tree sees their docs regenerated.
"""
paths = set()
for diff_args in (["--cached", "--name-only"], ["--name-only"]):
cmd = ["git", "diff", *diff_args]
return_code, stdout, _stderr = run_command(cmd)
if return_code != 0:
continue
paths.update(stdout.splitlines())
return terraform_dirs_from_paths(paths)


def build_terraform_docs_cmd(repo_root):
"""Pick the terraform-docs invocation based on whether a config file exists."""
config_file_path = os.path.join(repo_root, CONFIG_FILENAME)
if os.path.exists(config_file_path):
return ["terraform-docs", "--config", config_file_path, "."]
return ["terraform-docs", "markdown-table", "."]

if stderr:
print(f"terraform-docs error: Warning during execution:\n{stderr}", file=sys.stderr)

# Check git status for unstaged README changes
status_cmd = ["git", "status", "--porcelain"]
return_code, stdout, stderr = run_command(status_cmd)
def find_unstaged_readmes(porcelain_output):
"""
Parse `git status --porcelain` output and return README.md paths that are
either modified-but-unstaged or untracked. Both states block the commit
because the developer needs to `git add` the regenerated docs.
"""
unstaged = []
for line in porcelain_output.splitlines():
if len(line) < 3:
continue
status = line[:2]
path = line[3:].strip()
# `_M` = unstaged modification (any X), `??` = untracked.
if (status[1] == "M" or status == "??") and path.endswith("README.md"):
unstaged.append(path)
return unstaged


def main():
repo_root = os.getcwd()
terraform_dirs = get_changed_terraform_directories()

if not terraform_dirs:
print(
"terraform-docs: No Terraform files changed, skipping documentation update"
)
return 0

update_cmd = build_terraform_docs_cmd(repo_root)

for directory in sorted(terraform_dirs):
print(f"terraform-docs: Updating documentation in {directory}")
target = directory if directory != "." else repo_root
_return_code, _stdout, stderr = run_command(update_cmd, cwd=target)
if stderr:
print(f"terraform-docs warning in {directory}: {stderr}", file=sys.stderr)

_return_code, stdout, _stderr = run_command(["git", "status", "--porcelain"])
unstaged_readmes = find_unstaged_readmes(stdout)
if unstaged_readmes:
print(
"terraform-docs error: Please stage any README changes before committing."
)
return 1

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

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

print("terraform-docs: Documentation is up to date")
sys.exit(0)
if __name__ == "__main__":
sys.exit(main())
52 changes: 52 additions & 0 deletions actions/terraform-docs/terraform_docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { actionRunTest } from "tests";
import { TrunkActionDriver } from "tests/driver";

const preCheck = (driver: TrunkActionDriver) => {
// The action shells out to `terraform-docs`, so the tool's shim has to be on
// PATH inside the sandbox. The base trunk.yaml only enables the action; we
// append a `tools` block here.
const trunkYamlPath = ".trunk/trunk.yaml";
driver.writeFile(
trunkYamlPath,
driver.readFile(trunkYamlPath).concat(`
tools:
enabled:
- terraform-docs@0.16.0
`),
);

driver.writeFile(
"modules/a/main.tf",
`variable "name" {
description = "Example input."
type = string
}
`,
);
};

const testCallback = async (driver: TrunkActionDriver) => {
// Stage the new .tf file so the pre-commit hook's `git diff --cached`
// picks it up and runs terraform-docs against modules/a.
await driver.gitDriver?.add("modules/a/main.tf");

let commitError: Error | undefined;
try {
await driver.gitDriver?.commit("Add module a", [], { "--allow-empty": null });
} catch (err) {
commitError = err as Error;
}

// terraform-docs regenerates modules/a/README.md, which is untracked at
// commit time. The hook should reject the commit until the developer
// stages the new doc.
expect(commitError).toBeDefined();
expect(commitError?.message).toContain("Please stage any README changes before committing.");
};

actionRunTest({
actionName: "terraform-docs",
syncGitHooks: true,
preCheck,
testCallback,
});
Loading