Workflows and discovery config¶
The repository holds no engine code. Four caller workflows in .github/workflows/
hand everything to the nrit-tf-pr-ops engine, and projects.yml at the root
tells the engine what to discover, what to leave alone, and which gates to run.
This page covers all of them plus the CODEOWNERS and pull request template that
ship alongside.
The four callers, and which half of the engine each one uses:
| File | Trigger | Calls |
|---|---|---|
tf-pr-ops-pr.yml |
pull_request: opened, synchronize, reopened |
The dispatch composite action |
tf-pr-ops-unlock.yml |
pull_request: closed |
The dispatch composite action |
tf-pr-ops.yml |
issue_comment: created, and workflow_dispatch |
The terraform-pr-ops.yml reusable workflow |
drift.yml |
cron: "17 6 * * *", and workflow_dispatch |
The drift.yml reusable workflow |
Two callers pin a composite action, two pin a reusable workflow. The distinction decides how many version pins each file carries; see the pins below.
.github/workflows/tf-pr-ops-pr.yml¶
The pull request path. Two jobs, in order:
pre-commit runs first and gates everything. It installs the toolchain from
mise.toml (so it runs the same formatter, tflint, checkov, and invariants
versions as a local pre-commit install does) and runs the hooks on the files
the pull request changed, from the base commit to the head. That symmetry is
the point: the hooks catch problems at commit time, and this job catches a
commit made without them (or with --no-verify) before it reaches main. Main
is already clean because every merge passed this same gate, so the diff is
enough; hooks that do not take filenames, such as the repository invariants,
still scan the whole tree. The changelog reminder rides along
as a step in the same job: when a pull request changes code but not
CHANGELOG.md, it emits a warning annotation without failing.
The repository invariants live in .github/scripts/check-repo-invariants.sh.
What they guard and why is on
Policy, security, and cost gates.
dispatch needs pre-commit, so a failed lint stops the dispatch entirely:
no plan runs, no report lands, and merge stays blocked because the required
merge gate is never created. When the hooks pass, the
tf-pr-ops/.github/actions/dispatch composite action does the trusted
work on the hosted runner: it parses the event, discovers the impacted units,
opens the pending merge gate and the per-unit check rows, and dispatches the
plan to the ops workflow. It authenticates the check rows with the checks App
(TFPR_CHECKS_APP_CLIENT_ID / TFPR_CHECKS_APP_PRIVATE_KEY) and passes the
approval variable through explicitly
(allow-unreviewed-apply: ${{ vars.TFPR_ALLOW_UNREVIEWED_APPLY }}), because a
composite action cannot read the vars context itself.
Its permissions:
permissions:
contents: read
pull-requests: write
checks: write # the engine-authored check runs, merge gate included
actions: write # dispatch the ops workflow
No id-token here: this caller never touches Azure.
.github/workflows/tf-pr-ops-unlock.yml¶
Releases the PR's cross-PR unit locks when it closes, merged or not. It is its
own workflow so a merged PR's checks list shows one row that reads as lock
release, instead of a skipped pre-commit row and a duplicate dispatch row
stacked on the last real run. The closed path only parses the event and
dispatches the unlock, so it needs no checks App credentials and only
contents: read plus actions: write.
.github/workflows/tf-pr-ops.yml¶
The ops caller: comment commands and dispatched work. It is stable: engine changes never need an edit here.
It triggers on two things:
issue_commentwith typecreated, which is how/plan,/apply, and/unlockcomments reach the engine.workflow_dispatch, with acommandchoice input (plan,apply, orunlock, defaultplan) and an optionalprojectinput to target a single unit. Four further inputs (pr_number,head_sha,dispatch_run_id,dispatch_run_number) pluscontext, the resolved plan context, are set by the engine when the PR caller's dispatch action hands work to this workflow, so a dispatched plan starts at the plan job; never set them by hand.
The permissions block is a ceiling, not a grant to this file. A reusable
workflow cannot ask for more than the caller holds, so the caller has to declare
everything the engine's jobs use:
pull-requests: write posts and edits the run comment and its reactions,
checks: write writes the engine-authored check runs (merge gate included),
and id-token: write mints the OIDC token for Azure. Cut any of them and the
engine fails inside the reusable workflow, not here.
The single job calls the reusable workflow:
jobs:
engine:
uses: nrit-solutions/tf-pr-ops/.github/workflows/terraform-pr-ops.yml@v6.2.0
with:
command: ${{ inputs.command || 'plan' }}
project: ${{ inputs.project || '' }}
pr_number: ${{ inputs.pr_number || '' }}
head_sha: ${{ inputs.head_sha || '' }}
dispatch_run_id: ${{ inputs.dispatch_run_id || '' }}
dispatch_run_number: ${{ inputs.dispatch_run_number || '' }}
context: ${{ inputs.context || '' }}
engine_ref: v6.2.0 # must match the uses: ref above
secrets: inherit
.github/workflows/drift.yml¶
The daily drift sweep. It runs on cron: "17 6 * * *", so 06:17 UTC every day,
and also on workflow_dispatch with no inputs.
Its permissions are deliberately different:
permissions:
contents: read
issues: write
id-token: write
pull-requests: read # the lock sweep reads holder PR state
Drift reports as GitHub issues, not as a pull request comment, so it needs
issues: write and no write access to pull requests or checks.
pull-requests: read is for the lock sweep: the drift run releases every unit
lock whose holder PR has closed, and it reads the holder's state to decide.
The job takes one input, engine_ref, matching its own uses: ref, and it also
uses secrets: inherit.
The pins¶
The two reusable-workflow callers (tf-pr-ops.yml, drift.yml) carry the
engine version twice: the uses: ref selects the reusable-workflow body,
and the engine_ref input selects the engine checkout the workflow makes at
run time (the prebuilt tfpr binary is installed for that same tag). The two
action callers (tf-pr-ops-pr.yml, tf-pr-ops-unlock.yml) carry one pin
each: the dispatch action knows its own ref. Six pins across four files, all at
the same exact version, so a run only changes when someone commits a new one.
The resolve job logs the exact engine commit it checked out.
Bump all six pins together
If the pins skew, one run's orchestration does not match another's engine,
and the failure is a mismatch inside a run, not a clear version error.
Change every @vX and engine_ref: vX in the same commit, in all four
caller files. The current pins are on
Versions and upgrades.
See The PR-ops engine for how the pin is verified at run time and what branch protection to set.
secrets: inherit¶
The two reusable-workflow callers pass secrets: inherit. That hands the
reusable workflow every secret in the repository, with no explicit mapping
and no per-secret allowlist. It keeps the caller stable when the engine starts
using a new secret, and it means the engine sees any secret you add to the
repository for an unrelated reason. Worth knowing before you store an unrelated
credential in this repository. The two action callers are the opposite: they
pass only the named credentials they need as inputs. The
Security model page covers the blast radius.
.github/CODEOWNERS¶
Ships as a single catch-all rule:
That is the template's owner, not the customer's. Replace it with the customer's real owning teams during onboarding, or every review request routes to NRIT.
.github/pull_request_template.md¶
Three sections. What and why is free text. Scope asks for the units
impacted, the subscription or landing zone, and any version pins moved, which the
template spells out as the AVM version in main.tf, the policy library ref,
and the engine pins.
Then a seven-item checklist:
- Plan comment reviewed for every impacted unit, including deletes and replaces
- Policy, security, and cost gate output reviewed
- If an engine pin moved, the
uses:ref andengine_refmatch in both workflows - Affected
subscription.hcl/region.hclvalues confirmed - Reviewer from the required approver group requested
/applyrun after approval and the apply succeededtf-pr-ops / merge-gateis green
The last two matter for the merge gate. It stays red until the change is applied, so applying is part of the pull request, not something that happens after merge.
projects.yml¶
This is the repository's actual discovery config, not the schema. For every field and all three discovery modes, see projects.yml schema.
terragrunt:
root: live
include_dependents: true
gate_ignore:
- ".tflint.hcl"
- ".claude/**"
steps:
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"
root: live points discovery at the directory holding root.hcl.
include_dependents: true means an impacted unit pulls its downstream dependents
into the same run, so a plan shows the full effect of the change and an apply runs
in dependency order.
A unit is impacted when its own directory changed, when a file it reads changed, or
when it is downstream of a unit that is. The read-files part matters here: every
unit reads root.hcl, tenant.hcl, and the subscription.hcl and region.hcl
above it, so a one-line change to live/root.hcl impacts the whole tree. See
Plan and apply.
gate_ignore: names the Terraform-pattern paths that belong to no unit on
purpose. Since engine v3.4.0 the merge gate fails a pull request whose changed
Terraform paths no planned unit accounts for; these globs are the deliberate
exceptions (linter configuration, bundled tooling). Keep the list short: every
entry is a place where Terraform can change without the gate noticing. See
the merge gate.
There is no exclude. The foundation is governed like any other unit: plan review,
required approvals, and the merge gate. Excluding it would hide the highest-impact
units from the same scrutiny, so it stays in.
The three post_plan hooks are the policy, security, and cost gates, and they run
on every plan and every drift plan, not on apply (since engine v6.0.0 the flat
steps: block is the plan stage). What they do and how to enforce them is on
Policy, security, and cost gates.
$TFPR_ENGINE_DIR is exported into the hook environment by the engine and points at
the engine checkout made at engine_ref. The gate scripts live in the engine, not
in this repository, so hooks resolve them there rather than in the consumer
workspace. That also means a pull request cannot edit a gate script to weaken its
own gate.
Next: The live tree.