The PR-ops engine¶
The engine is consumed as a reusable workflow. A customer repository does not copy the engine. It carries two thin caller workflows that call the engine at a pinned release tag. The engine scripts are checked out at that tag at run time, so the customer repo only ever holds its own Terraform plus the two callers.
The two caller workflows¶
Each customer repository has exactly two files in .github/workflows/:
| File | Calls | Trigger |
|---|---|---|
terraform-pr-ops.yml |
nrit-tf-pr-ops/.github/workflows/terraform-pr-ops.yml@v1.5.0 |
Pull requests (including close, which releases unit locks) and /plan, /apply, /unlock comments. |
drift.yml |
nrit-tf-pr-ops/.github/workflows/drift.yml@v1.5.0 |
A daily schedule and manual dispatch. |
Each is about thirty lines. They set permissions, pass a couple of inputs, and inherit the repo's secrets. Nothing else. Engine changes never need a caller edit, so these files stay stable across releases.
Copy them as they are. GitHub builds the check name from the caller's workflow
name and job id, so renaming either changes every check name on the pull
request and breaks any required check configured against the old name.
terraform-pr-ops.yml¶
# Canonical caller for the reusable terraform-pr-ops workflow.
# Copy to a consumer repo as .github/workflows/terraform-pr-ops.yml. This file is
# stable: engine changes never need a caller edit. Bump the two version pins
# (the uses: ref and engine_ref) together to move to a new engine release.
name: tf-pr-ops
on:
pull_request:
# closed lets the engine release the PR's cross-PR unit locks.
types: [opened, synchronize, reopened, closed]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
command:
type: choice
options: [plan, apply]
default: plan
project:
required: false
# The caller grants the ceiling of permissions the reusable workflow's jobs use.
permissions:
contents: read
pull-requests: write
checks: write
statuses: write
id-token: write
jobs:
engine:
uses: nrit-solutions/nrit-tf-pr-ops/.github/workflows/terraform-pr-ops.yml@v1.5.0
with:
command: ${{ inputs.command || 'plan' }}
project: ${{ inputs.project || '' }}
engine_ref: v1.5.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
jobs:
engine:
uses: nrit-solutions/nrit-tf-pr-ops/.github/workflows/drift.yml@v1.5.0
with:
engine_ref: v1.5.0 # must match the uses: ref above
secrets: inherit
The two pins must match¶
Each caller carries the version in two places: the uses: ref and the
engine_ref input. They must be the same value.
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 both pins in the same commit to the same new tag, and read the release notes first. If the two disagree, new workflow YAML runs old scripts.
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 behaviour 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.
Because nrit-tf-pr-ops is private, the engine checkout authenticates with a token
minted from a GitHub App (CATALOG_APP_CLIENT_ID and CATALOG_APP_PRIVATE_KEY).
The App installation must grant read access to nrit-tf-pr-ops. 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 two callers and a projects.yml. To wire up a repo:
- Confirm
.github/workflows/terraform-pr-ops.ymlanddrift.ymlpin the engine version you want, withuses:andengine_refmatching. - Set
nrit-tf-pr-opsto organisation-accessible so consumer repos may call its reusable workflows. - Grant the engine GitHub App read access to
nrit-tf-pr-ops. - Make
tf-pr-ops / merge-gatea required status check on the main branch. The bootstrap's ruleset carries required status 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 sets a commit status called tf-pr-ops / merge-gate. It is green
only when the PR has no Terraform changes, is a no-op, or has been fully applied.
Make it a required status check on the main branch so unapplied changes cannot
merge. It is an API-set commit status, so reusable mode does not change its context
string.
/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 commit status, 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 status 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 status 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 org-shared workflow can vendor the engine instead: copy
.github/workflows/, .github/actions/setup-tools/, and scripts/ from
nrit-tf-pr-ops at a tag, and run the workflows directly with no uses: and no
engine_ref. $TFPR_ENGINE_DIR then defaults to $GITHUB_WORKSPACE, so the hook
paths still resolve. This is the original model and 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.