Skip to content

Hook and step model

Hooks let you run shell commands at fixed points around init, plan, and apply. The three gates are implemented as hooks, and you can add your own (format, validate, a source scan, a notification). Hook variables use the engine's word project; in Terragrunt mode a project is exactly a unit.

The five seams

Seam Runs
pre_init before terraform init
post_init after init, before plan
post_plan after plan, with the plan available
pre_apply after plan, before apply
post_apply after a successful apply

Two stages

Since engine v6.0.0 the plan run and the apply run each have their own hook list, nested under plan: and apply: in the steps: block:

steps:
  plan:                       # also the drift sweep
    post_plan:
      - bash "$TFPR_ENGINE_DIR/scripts/conftest-gate.sh"
      - CHECKOV_SOFT_FAIL=1 bash "$TFPR_ENGINE_DIR/scripts/checkov-gate.sh"
      - bash "$TFPR_ENGINE_DIR/scripts/infracost-gate.sh"
  apply:                      # default: no hooks
    pre_apply: []
    post_apply: []

Firing order:

  • plan and drift: pre_init, init, post_init, plan, post_plan
  • apply: pre_init, init, post_init, plan, post_plan, pre_apply, apply, post_apply

Apply re-plans before it applies, and the reviewed-plan guard refuses an apply whose plan differs from the one reviewed, so the apply stage runs no hooks unless its own list names them: the plan run already gated the same plan. The apply report then carries a one-line note, "Plan-stage gates not re-run: the plan matches the one reviewed and gated at <commit>". /apply --force skips the guard and still does not fall back to the plan-stage hooks; force means the operator has read the fresh plan. A repository that wants a gate at apply time lists it under apply:.

The flat form, hook slots at the top level with no plan: or apply: key, keeps working and means the plan stage only. A flat pre_apply or post_apply is refused by discovery with the fix named, because an apply-time hook that never runs would read as a gate that passed; a misspelt slot is refused for the same reason.

Init, plan, the reviewed-plan guard, the unit lock and the ordered apply are not steps and cannot be replaced from projects.yml. Hooks are the customization surface; the guards are the product.

Each command runs in the unit directory under bash -eo pipefail. A non-zero command sets the unit's status to error, which blocks the merge gate and stops the rest of the hooks. End a command with || true to make it advisory.

Environment exposed to hooks

Variable Meaning
TFPR_PLANFILE The binary plan file. Available from post_plan on.
TFPR_PLANJSON The plan rendered as JSON (terraform show -json). Available from post_plan on.
TFPR_OUT Append markdown here to surface it in the PR comment or drift issue.
TFPR_ENGINE_DIR The engine checkout root. Call bundled gates as bash "$TFPR_ENGINE_DIR/scripts/<gate>.sh". Defaults to the workspace in vendored mode.
TFPR_BIN Path to the compiled tfpr binary, for hooks that invoke engine subcommands.
PROJECT_NAME, PROJECT_DIR, PROJECT_LABEL The unit's name, directory, and full-path label.

TFPR_OUT is staged around the plan: output written in pre_init or post_init renders above the plan as "Pre-plan checks", and output written from post_plan on renders below the plan as "Validation checks". So the comment always follows the order the hooks ran. The "Validation checks" panel is status-aware: it collapses when clean and auto-expands when your hook output contains a :x: or :warning: marker, or when the run fails. Emit those markers to have a finding surface automatically.

The gate hooks

All three ship in scripts/ and are meant to run as post_plan hooks.

conftest-gate.sh

Tests TFPR_PLANJSON against a policy directory and appends a policy summary to TFPR_OUT. Gating: exits non-zero on any deny.

Variable Default Purpose
CONFTEST_POLICY_DIR <repo>/policy Directory of .rego policies.
CONFTEST_NAMESPACE main Rego namespace to test.
CONFTEST_FAIL_ON_WARN unset Set to 1 to also fail on warn.

Passes with a note if no policies exist yet, so you can enable the hook before you write any.

checkov-gate.sh

Scans TFPR_PLANJSON (plan scanning, which also sidesteps Terragrunt) and appends the failed checks to TFPR_OUT. Gating: exits with checkov's status unless soft-fail is set.

Variable Default Purpose
CHECKOV_SOFT_FAIL unset Set to 1 to report without ever gating.
CHECKOV_HARD_FAIL_ON unset Check IDs that must gate.
CHECKOV_REPO_ROOT $PWD Enables inline checkov:skip comment support.
CHECKOV_ARGS unset Extra CLI arguments.

infracost-gate.sh

Runs infracost breakdown against TFPR_PLANJSON and appends a monthly-cost table to TFPR_OUT, labeled with the unit path. Report-only: always exits zero.

Variable Default Purpose
INFRACOST_API_KEY unset Hosted pricing API key. Without it the hook notes it and skips.
INFRACOST_BREAKDOWN_ARGS unset Extra arguments for infracost breakdown.

Writing your own hook

A hook is any shell command. For example, run terraform fmt -check before init and surface the result in the comment:

steps:
  pre_init:
    - terraform fmt -check -recursive || echo ":warning: run terraform fmt" >> "$TFPR_OUT"
  post_plan:
    - bash "$TFPR_ENGINE_DIR/scripts/conftest-gate.sh"