Skip to content

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_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 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:

  1. Confirm tf-pr-ops-pr.yml, tf-pr-ops-unlock.yml, tf-pr-ops.yml and drift.yml pin the engine version you want, with uses: and engine_ref matching in the two reusable-workflow callers.
  2. Confirm ENGINE_APP_CLIENT_ID and ENGINE_APP_PRIVATE_KEY are set from the engine App NRIT issued for the organization (the bootstrap sets them when a client id is supplied).
  3. Create and install the org's checks App (see the checks App).
  4. Make tf-pr-ops / merge-gate a required check on the main branch. The bootstrap's ruleset carries required 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 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.

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