Skip to content

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_comment with type created, which is how /plan, /apply, and /unlock comments reach the engine.
  • workflow_dispatch, with a command choice input (plan, apply, or unlock, default plan) and an optional project input to target a single unit. Four further inputs (pr_number, head_sha, dispatch_run_id, dispatch_run_number) plus context, 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:

permissions:
  contents: read
  pull-requests: write
  checks: write
  id-token: write

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:

*       @nrit-solutions/platform-engineering

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 and engine_ref match in both workflows
  • Affected subscription.hcl / region.hcl values confirmed
  • Reviewer from the required approver group requested
  • /apply run after approval and the apply succeeded
  • tf-pr-ops / merge-gate is 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.