Skip to content

Command reference

Commands are comments on a pull request.

Comment Effect
/plan Plan all changed units (re-plan).
/plan -p <label> Plan one unit and its dependents.
/apply Apply all changed units in the PR, in dependency order.
/apply -p <label> Apply exactly one unit, no dependents.
/apply --force (or -f) Apply even when a unit's plan changed since it was reviewed, and run every unit for real instead of skipping no-ops.
/unlock Force-release the unit locks this PR holds.
/help List the commands, as a reply on the PR.

Selecting a unit

<label> after -p (or --project, or as a bare argument) matches a unit by:

  • its full-path label (for example live/landingzones/corp/corp-demo-dev/westeurope/example),
  • its name (the leaf folder), or
  • its directory.

A plan with changes tells you the exact label to use in its instructions block. The flag is spelled --project because that is the engine's word for a unit; the two mean the same thing here.

A selector that matches no unit gets a reply saying so, with a 😕 reaction on the command comment, instead of a silently skipped run.

A filtered run is a diagnostic: it never writes the merge gate and it skips the unattributed-path check, so /plan -p cannot turn the gate green or red. The full /plan or /apply is what moves the gate.

Who can run a command

Every command must come from a comment whose GitHub author_association is OWNER, MEMBER, or COLLABORATOR. Any other author, for example CONTRIBUTOR or NONE, is refused: the engine adds a 😕 reaction to the comment and posts a reply naming the author association it saw.

Since v1.9.2, /apply and /unlock must also clear a real permission check. The engine reads the commenter's permission on the repository and requires write or admin; maintain reports as write and passes, triage reports as read and does not. A refusal names the permission the command needs and the one the account has. /plan is deliberately left on the association filter: it reads rather than writes, and requiring write would stop a read collaborator asking for a re-plan on their own pull request.

This is the first of the controls on the flow. It answers "who is asking". The approval gate below reads a property of the pull request, not of the person typing, so without this check any user who can comment on the PR could drive a real apply.

Association on its own is not a permission check

author_association is what GitHub reports on the comment, not the author's permission level on the repository. MEMBER means "a member of the organization that owns the repository", which in an organization with default read access can include people with no write permission. That is exactly why the state-changing commands no longer rely on it alone.

The permission check fails closed: if the API cannot be read, the command is refused rather than allowed.

Both checks only exist on the comment path. Runs from pull_request events, workflow_dispatch, and workflow_call never see an author_association, so they are not filtered by it.

Automatic runs

  • Opening, updating, or reopening a PR runs /plan on the changed units.
  • Closing or merging a PR releases the unit locks it holds. See Cross-PR unit locks.
  • The ops workflow also declares a manual workflow_dispatch with a command choice (plan, apply, or unlock), an optional project input, and a pr_number. (Four further inputs are set by the engine when the PR caller hands work over; never set them by hand.)

Manual dispatch drives a pull request

A dispatch without pr_number is refused at parse with a message saying so: every plan, apply, and unlock belongs to a pull request. For a full plan of the default branch, dispatch drift.yml instead; the drift sweep never needs a PR. The unlock command with a PR number is the manual lock release for a closed PR whose close event produced no run.

What each run posts

  • A fresh run-report comment. The superseded report of the same unit and command is minimized with an Outdated banner (opt out with TFPR_MINIMIZE_OUTDATED=false), so the newest report is the one that reads.
  • A per-unit check row, tf-pr-ops / <command> (<label>) #<run> (a spinner while running, then a green check or red cross); the superseded row turns neutral, titled Outdated: superseded by #N.
  • An update to the tf-pr-ops / merge-gate check run. A filtered -p run is the exception: it leaves the gate untouched.
  • An update to the tf-pr-ops / approval check.
  • A 👀 reaction on the command comment, confirming it was picked up. That is the only reaction an accepted command gets; the outcome is in the report comment, the per-unit check, and the merge gate. A refused command gets 😕 instead. See Comment reactions.

The approval check

Every run sets a tf-pr-ops / approval check run from GitHub's review decision, so a required-approval rule is visible from the first plan rather than only when an apply is refused. It reads:

Review decision State Description
APPROVED success Required reviews met
empty, with TFPR_ALLOW_UNREVIEWED_APPLY set to true success No reviews required; unreviewed apply allowed
empty, otherwise failure No reviews required by this repository; apply is blocked
anything else failure Required reviews not met (<decision>)

An empty review decision means the repository requires no reviews at all. The engine refuses that rather than treating it as consent: an empty decision cannot be told apart from a ruleset that was removed, so allowing it would let the apply gate disappear with no signal. A repository that requires no reviews on purpose opts back in with the TFPR_ALLOW_UNREVIEWED_APPLY repository variable set to true. Leave it unset everywhere else. See Secrets and variables.

Never make tf-pr-ops / approval a required check

It is informational. It blocks nothing on its own, and it reports failure on every PR until someone approves. Make it required and you deadlock the repository: the status only clears on approval, but the branch rule already enforces approval, so the check adds nothing and can wedge merges. The gate you do make required is tf-pr-ops / merge-gate.

/apply is gated separately, on the same review decision. If the PR does not meet the required reviews, the engine posts a comment saying apply is blocked and does not run.

Template repositories are skipped

Both workflows exit before resolve when the caller repository is a GitHub template repository. A template holds no infrastructure and none of the bootstrap-created variables or secrets, so plan and apply cannot work there, and skipping keeps its own pull requests green. A repository generated from a template is not itself a template, so a customer repo never hits this.