The PR-ops engine¶
The engine is consumed at a pinned release tag; a customer repository does not copy it. It carries four thin caller workflows, so the customer repo only ever holds its own Terraform plus the callers.
The four caller workflows¶
Each customer repository has exactly four files in .github/workflows/:
| File | Calls | Trigger |
|---|---|---|
tf-pr-ops-pr.yml |
the dispatch composite action at nrit-solutions/tf-pr-ops/.github/actions/dispatch@v6.2.0 |
Pull requests opened or updated: resolve, then hand the plan work to the ops caller. A consumer-owned pre-commit job gates the dispatch. |
tf-pr-ops-unlock.yml |
the same dispatch composite action |
Pull request closed: releases the PR's cross-PR unit locks. Its own workflow so a merged PR's checks list shows one row that reads as lock release. |
tf-pr-ops.yml |
the reusable ops workflow nrit-solutions/tf-pr-ops/.github/workflows/terraform-pr-ops.yml@v6.2.0 |
/plan, /apply, /unlock comments, plus the engine's own dispatched work. |
drift.yml |
nrit-solutions/tf-pr-ops/.github/workflows/drift.yml@v6.2.0 |
A daily schedule and manual dispatch. |
Each is well under a hundred lines. They set permissions, pass a couple of
inputs, and inherit the repo's secrets. Nothing else. Most engine releases
need nothing beyond the pin bump, but not all: a release can add a caller
file, a permission line, or a projects.yml key, and its release notes state
the exact edit. Read the notes before bumping, whatever the version number
says.
Copy them as they are, keeping the PR caller's workflow name tf-pr-ops and
its job ids pre-commit and dispatch: GitHub renders those two rows as
tf-pr-ops / pre-commit and tf-pr-ops / dispatch, the only native rows on
the pull request. Every other row is an engine-created check run named by
the engine itself. The pre-commit job is convention, not engine contract:
it runs the hooks from the repository's own .pre-commit-config.yaml, and a
repo may extend, weaken, or remove the gate; the engine-enforced gates do
not depend on it.
tf-pr-ops-pr.yml¶
name: tf-pr-ops
# The closed event lives in tf-pr-ops-unlock.yml: splitting it out keeps a
# merged PR's checks list free of a skipped pre-commit row and a duplicate
# dispatch row from the unlock run.
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
checks: write # the engine-authored check runs, merge gate included
actions: write # dispatch the ops workflow
jobs:
# First and gating: a failed lint stops dispatch, so no plan runs and no
# report lands on a PR that fails its own hooks; merge stays blocked
# because the required merge gate is never created.
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Changelog reminder
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
changed="$(git diff --name-only "$BASE_SHA"...HEAD)"
if grep -qvE '\.md$' <<<"$changed" && ! grep -qx 'CHANGELOG.md' <<<"$changed"; then
echo "::warning file=CHANGELOG.md::This PR changes code but not CHANGELOG.md. Append to [Unreleased], or ignore this if the change is invisible to operators."
fi
# installs everything in mise.toml: terraform, terragrunt, tflint,
# checkov, and pre-commit itself, at the pinned versions
- uses: jdx/mise-action@v4.2.3
with:
# Pinned: the action's latest can name a release whose assets do not exist yet.
version: 2026.9.2
cache: true
- uses: actions/cache@v6.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
# Only the files this PR changes: main is already clean, every merge
# passed this same gate. Hooks with pass_filenames false (the repository
# invariants) still see the whole tree.
- run: pre-commit run --from-ref "$BASE_SHA" --to-ref HEAD --show-diff-on-failure --color=always
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
dispatch:
needs: pre-commit
if: github.event.repository.is_template != true
runs-on: ubuntu-latest
steps:
- uses: nrit-solutions/tf-pr-ops/.github/actions/dispatch@v6.2.0
with:
checks-app-client-id: ${{ vars.TFPR_CHECKS_APP_CLIENT_ID }}
checks-app-private-key: ${{ secrets.TFPR_CHECKS_APP_PRIVATE_KEY }}
allow-unreviewed-apply: ${{ vars.TFPR_ALLOW_UNREVIEWED_APPLY }}
# Lets the action download the prebuilt tfpr for its pinned tag
# instead of building from source. Optional; empty builds.
engine-app-client-id: ${{ vars.ENGINE_APP_CLIENT_ID }}
engine-app-private-key: ${{ secrets.ENGINE_APP_PRIVATE_KEY }}
tf-pr-ops-unlock.yml¶
name: tf-pr-ops unlock
on:
pull_request:
types: [closed]
permissions:
contents: read
actions: write # dispatch the ops workflow
jobs:
unlock:
if: github.event.repository.is_template != true
runs-on: ubuntu-latest
steps:
- uses: nrit-solutions/tf-pr-ops/.github/actions/dispatch@v6.2.0
with:
# Lets the action download the prebuilt tfpr for its pinned tag
# instead of building from source. Optional; empty builds.
engine-app-client-id: ${{ vars.ENGINE_APP_CLIENT_ID }}
engine-app-private-key: ${{ secrets.ENGINE_APP_PRIVATE_KEY }}
The dispatch action's closed path only parses the event and dispatches the
unlock, so this caller carries no checks App credentials and no
checks: write.
tf-pr-ops.yml¶
name: tf-pr-ops ops
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
command:
type: choice
options: [plan, apply, unlock]
default: plan
project:
required: false
# Set by the engine when the PR caller's dispatch action hands work to
# this workflow; never set them by hand.
pr_number:
required: false
head_sha:
required: false
dispatch_run_id:
required: false
dispatch_run_number:
required: false
# The resolved plan context the dispatch action sends, so a dispatched
# plan starts at the plan job.
context:
required: false
# The caller grants the ceiling of permissions the reusable workflow's jobs use.
permissions:
contents: read
pull-requests: write
checks: write
id-token: write
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
drift.yml¶
# Canonical caller for the reusable drift workflow.
# Copy to a consumer repo as .github/workflows/drift.yml. Bump the two version
# pins (the uses: ref and engine_ref) together to move to a new engine release.
name: drift
on:
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
id-token: write
pull-requests: read # the lock sweep reads holder PR state
jobs:
engine:
uses: nrit-solutions/tf-pr-ops/.github/workflows/drift.yml@v6.2.0
with:
engine_ref: v6.2.0 # must match the uses: ref above
secrets: inherit
The pins must match¶
The two reusable-workflow callers carry the version in two places: the
uses: ref and the engine_ref input. They must be the same value. The two
dispatch-action callers (tf-pr-ops-pr.yml, tf-pr-ops-unlock.yml) carry a
single pin each, because the dispatch action knows its own ref. Six pins
across four files, all at the same version.
uses:picks the workflow YAML that GitHub runs.engine_refpicks the engine scripts that the workflow checks out at run time.
A skew runs new YAML against old scripts, or the reverse. The resolve job logs
the requested engine_ref and the engine commit it actually checked out, so a
mismatch is visible at the top of every run.
Where the engine scripts run from¶
The reusable workflow checks the engine out at engine_ref into a directory named
.tfpr-engine and exposes it to hooks as $TFPR_ENGINE_DIR. Your projects.yml
calls the gate scripts from there:
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"
$TFPR_ENGINE_DIR defaults to the workspace, so a vendored copy still resolves the
same paths. See Vendored mode below.
Upgrading¶
Callers pin an exact version. Nothing arrives automatically, so an upgrade is always a commit you can see and revert.
Bump all six pins in the four caller files in the same commit, to the same new tag, and read the release notes first. If the pins disagree, new workflow YAML runs an old engine, or the reverse.
There is no moving v1 tag. An earlier release policy offered one, and it is
retired: it meant a customer could not tell from the file which engine they were
running, and a repository could silently change behavior with no commit.
See Versions and upgrades for the full pinning contract, including the module and policy-library pins that live in the repository itself.
The security model¶
The reusable workflow splits jobs by trust so PR-branch code can never run with
write credentials: trusted jobs run pinned engine scripts with only a read-only
App token, and only the Azure-facing jobs run under the plan and apply
environments with an OIDC identity. The Security model page
covers the split, the identities, and the gates in full.
The callers reference the public entrypoint repository nrit-solutions/tf-pr-ops,
which holds the released workflows and the dispatch action. The engine core they
run, nrit-tf-pr-ops, is private, so every job checks it out with a token minted
from the engine App (ENGINE_APP_CLIENT_ID and ENGINE_APP_PRIVATE_KEY). The
App is installed on the NRIT organization with read access to the core. See
Self-hosted runner and the engine GitHub App.
Enabling the engine on a repo¶
The bootstrap generates the customer repository from nrit-alz-customer-template,
which already ships the four callers and a projects.yml. To wire up a repo:
- Confirm
tf-pr-ops-pr.yml,tf-pr-ops-unlock.yml,tf-pr-ops.ymlanddrift.ymlpin the engine version you want, withuses:andengine_refmatching in the two reusable-workflow callers. - Confirm
ENGINE_APP_CLIENT_IDandENGINE_APP_PRIVATE_KEYare set from the engine App NRIT issued for the organization (the bootstrap sets them when a client id is supplied). - Create and install the org's checks App (see the checks App).
- Make
tf-pr-ops / merge-gatea required check on the main branch. The bootstrap's ruleset carries required checks only when itsrequired_status_checksvariable is set, and it is empty by default, so check for the requirement rather than assuming it.
Branch protection and the merge gate¶
The engine writes a check run called tf-pr-ops / merge-gate, opened as
queued ("Waiting for plan") when a run takes the PR. It concludes green only
when the PR has no Terraform changes, is a no-op, or has been fully applied.
Make it a required check on the main branch so unapplied changes cannot
merge. It is an API-written check run, so its name is the same in every
consumption mode.
/apply also respects the repo's required reviews. That is configured entirely in
branch protection or the ruleset the bootstrap provisions; the engine reads
GitHub's review decision and blocks apply until it is met.
Every run also publishes a second check run, tf-pr-ops / approval,
carrying that review decision so the requirement is visible from the first plan.
tf-pr-ops / approval is informational
Do not make it a required check. It reports failure on every PR until
someone approves, and the branch rule already enforces the approval, so
requiring it adds no protection and can wedge merges. merge-gate is the only
engine check to make required. See
Command reference.
Tool versions¶
Pin Terraform and Terragrunt in mise.toml (or .tool-versions for asdf). The
setup-tools action detects the version manager and installs those versions, so a
version bump is a one-line change in the repo with no workflow edit.
Vendored mode, the escape hatch¶
A repo that cannot use the shared workflow can vendor the engine instead:
copy the private core nrit-tf-pr-ops at a tag (this needs read access NRIT
grants per agreement), including .github/,
scripts/, go.mod, go.sum, cmd/, and internal/, and run the workflows
directly with no uses: and no engine_ref. The workflow then builds the
tfpr binary from the vendored Go source (there is no release tag to download
a prebuilt binary for), so the Go module files and source are not optional.
$TFPR_ENGINE_DIR defaults to $GITHUB_WORKSPACE, so the hook paths still
resolve. Vendored mode stays supported, but the repo then owns engine updates
by re-copying at a new tag. Prefer the reusable caller; reach for vendoring
only when a repo genuinely cannot call the shared workflow.