Skip to content

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_ref picks 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:

  1. Confirm .github/workflows/terraform-pr-ops.yml and drift.yml pin the engine version you want, with uses: and engine_ref matching.
  2. Set nrit-tf-pr-ops to organisation-accessible so consumer repos may call its reusable workflows.
  3. Grant the engine GitHub App read access to nrit-tf-pr-ops.
  4. Make tf-pr-ops / merge-gate a required status check on the main branch. The bootstrap's ruleset carries required status checks only when its required_status_checks variable 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.

Next: Self-hosted runner and the engine GitHub App.