Reports OpenTofu/Terraform workflow status as PR comments or status issues with rich plan and apply detail.
- Rich plan diffs — attribute-level changes with inline character diffs, grouped by module
- Apply reports — shows only actually-changed resources with per-resource outcomes, diagnostics, and resolved computed values when post-apply state is available
- Progressive enrichment — structured plan → raw text fallback → general workflow table, progressively enriched with whatever data is available
- PR comments — automatically posts and updates comments on pull requests with workspace-based deduplication
- Status issues — creates and maintains status issues for non-PR workflows (push to main, scheduled runs)
- Auto-discovery — automatically identifies init, validate, plan, show-plan, apply, and state steps from the workflow context
- Limit-aware — intelligently truncates output to fit within GitHub's 65,536 character limit while preserving the most important information
- Zero runtime dependencies — uses only Node.js built-in modules
The action posts a Markdown comment to your PR showing plan details with attribute-level diffs grouped by module. Click to expand:
✅ production Plan: 3 to add, 2 to replace, 2 to destroy
| Action | Resource | Count |
|---|---|---|
| ➕ Add | null_resource | 3 |
| Add | 3 | |
| ± Replace | null_resource | 1 |
| random_string | 1 | |
| Replace | 2 | |
| 🗑️ Destroy | null_resource | 2 |
| Destroy | 2 |
± null_resource root
null_resource.root
| Attribute | Before | After |
|---|---|---|
| id | 4740972482117666024 |
(known after apply) |
| triggers.stage | 0 | 1 |
± random_string suffix
module.naming.random_string.suffix
| Attribute | Before | After |
|---|---|---|
| id | 0aura6xr |
(known after apply) |
| keepers.prefix | fixture | fixture-updated |
| result | 0aura6xr |
(known after apply) |
🗑️ null_resource item[0]
module.parent["2"].module.child.null_resource.item[0]
| Attribute | Before | After |
|---|---|---|
| id | 2081773518097729371 | |
| triggers.index | 0 | |
| triggers.label | 2 | |
🗑️ null_resource item[1]
module.parent["2"].module.child.null_resource.item[1]
| Attribute | Before | After |
|---|---|---|
| id | 8179146176311086127 | |
| triggers.index | 1 | |
| triggers.label | 2 | |
➕ null_resource item[0]
module.parent["3"].module.child.null_resource.item[0]
| Attribute | Before | After |
|---|---|---|
| id | |
(known after apply) |
| triggers.index | 0 |
|
| triggers.label | 3 |
➕ null_resource item[1]
module.parent["3"].module.child.null_resource.item[1]
| Attribute | Before | After |
|---|---|---|
| id | |
(known after apply) |
| triggers.index | 1 |
|
| triggers.label | 3 |
➕ null_resource item[2]
module.parent["3"].module.child.null_resource.item[2]
| Attribute | Before | After |
|---|---|---|
| id | |
(known after apply) |
| triggers.index | 2 |
|
| triggers.label | 3 |
| Output | Action | Before | After |
|---|---|---|---|
| generated_name | 🔧 | fixture-0aura6xr |
(known after apply) |
| root_id | 🔧 | 4740972482117666024 |
(known after apply) |
name: OpenTofu Plan
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up OpenTofu
uses: opentofu/setup-opentofu@v2
with:
# The wrapper is disabled because it interferes with signal forwarding
# (opentofu/setup-opentofu#41), detailed exitcodes (opentofu/setup-opentofu#42),
# and is generally discouraged when using retailnext/exec-action.
tofu_wrapper: false
- name: Init
id: init
uses: retailnext/exec-action@main
with:
command: tofu init -json
- name: Validate
id: validate
uses: retailnext/exec-action@main
with:
command: tofu validate -json
- name: Plan
id: plan
uses: retailnext/exec-action@main
with:
command: tofu plan -json -detailed-exitcode -out=tfplan
success_exit_codes: "0,2"
- name: Show Plan
id: show-plan
uses: retailnext/exec-action@main
with:
command: tofu show -json tfplan
- name: Report
if: always()
uses: retailnext/tf-report-action@main
with:
steps: ${{ toJSON(steps) }}
github-token: ${{ github.token }}A single workflow that plans on pull requests and applies on merge to main.
All steps use -json where available for the richest output. The workspace
input identifies the report for deduplication.
On PRs, the action posts a comment with the plan. On push to main, it creates or updates a status issue with the apply result.
name: Infrastructure
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up OpenTofu
uses: opentofu/setup-opentofu@v2
with:
# The wrapper is disabled because it interferes with signal forwarding
# (opentofu/setup-opentofu#41), detailed exitcodes (opentofu/setup-opentofu#42),
# and is generally discouraged when using retailnext/exec-action.
tofu_wrapper: false
- name: Init
id: init
uses: retailnext/exec-action@main
with:
command: tofu init -json
- name: Validate
id: validate
uses: retailnext/exec-action@main
with:
command: tofu validate -json
- name: Plan
id: plan
uses: retailnext/exec-action@main
with:
command: tofu plan -json -detailed-exitcode -out=tfplan
success_exit_codes: "0,2"
- name: Show Plan
id: show-plan
uses: retailnext/exec-action@main
with:
command: tofu show -json tfplan
- name: Apply
id: apply
if: github.event_name == 'push'
uses: retailnext/exec-action@main
with:
command: tofu apply -json tfplan
- name: State
id: state
if: steps.apply.outcome != 'skipped'
uses: retailnext/exec-action@main
with:
command: tofu state pull
# Hide outputs to avoid leaking sensitive state values in the
# GitHub Actions log. The state is still available to the report
# action via the stdout_file output.
hide_outputs: true
- name: Report
if: always()
uses: retailnext/tf-report-action@main
with:
steps: ${{ toJSON(steps) }}
workspace: production
github-token: ${{ github.token }}When managing multiple workspaces from the same repository, use a matrix to run each workspace in its own working directory. Each workspace gets a separate PR comment, identified by its deduplication marker.
name: Infrastructure
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
workspace:
- name: staging
directory: envs/staging
- name: production
directory: envs/production
defaults:
run:
working-directory: ${{ matrix.workspace.directory }}
steps:
- uses: actions/checkout@v4
- name: Set up OpenTofu
uses: opentofu/setup-opentofu@v2
with:
# The wrapper is disabled because it interferes with signal forwarding
# (opentofu/setup-opentofu#41), detailed exitcodes (opentofu/setup-opentofu#42),
# and is generally discouraged when using retailnext/exec-action.
tofu_wrapper: false
- name: Init
id: init
uses: retailnext/exec-action@main
with:
command: tofu init -json
working_directory: ${{ matrix.workspace.directory }}
- name: Plan
id: plan
uses: retailnext/exec-action@main
with:
command: tofu plan -json -detailed-exitcode -out=tfplan
success_exit_codes: "0,2"
working_directory: ${{ matrix.workspace.directory }}
- name: Show Plan
id: show-plan
uses: retailnext/exec-action@main
with:
command: tofu show -json tfplan
working_directory: ${{ matrix.workspace.directory }}
- name: Report
if: always()
uses: retailnext/tf-report-action@main
with:
steps: ${{ toJSON(steps) }}
workspace: ${{ matrix.workspace.name }}
github-token: ${{ github.token }}The action can report on any workflow, not just Terraform/OpenTofu. The
target-step input focuses the report on a specific step — if that step is
skipped or fails, the report prominently surfaces the failure.
When no recognized IaC steps are found, the action renders a general workflow step status table showing each step's outcome, exit code, and duration.
name: Database Migration
on:
push:
branches: [main]
permissions:
contents: read
issues: write
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Migrations
id: migrate
uses: retailnext/exec-action@main
with:
command: npm run db:migrate
- name: Verify Schema
id: verify
uses: retailnext/exec-action@main
with:
command: npm run db:verify
- name: Report
if: always()
uses: retailnext/tf-report-action@main
with:
steps: ${{ toJSON(steps) }}
target-step: migrate
workspace: db-migrations
github-token: ${{ github.token }}| Input | Required | Default | Description |
|---|---|---|---|
steps |
Yes | — | JSON string of workflow steps (use ${{ toJSON(steps) }}) |
workspace |
No | GITHUB_WORKFLOW/GITHUB_JOB |
Workspace name for comment title, status issue title, and deduplication marker |
target-step |
No | — | Step ID to focus the report on; skipped/failed status is prominently reported |
github-token |
Yes | — | GitHub token for API calls |
init-step-id |
No | init |
Step ID for the init step (override when your workflow uses non-default IDs) |
validate-step-id |
No | validate |
Step ID for the validate step |
plan-step-id |
No | plan |
Step ID for the plan step |
show-plan-step-id |
No | show-plan |
Step ID for the show-plan step |
apply-step-id |
No | apply |
Step ID for the apply step |
state-step-id |
No | state |
Step ID for the state step (post-apply state for resolving computed values) |
The action automatically identifies IaC workflow steps by their step IDs. It
looks for steps matching the configured step IDs (defaulting to init,
validate, plan, show-plan, apply, and state) and determines which
operations were performed.
The action progressively enriches the report with whatever data is available:
- Tier 1 — Structured report: When a
show-planstep provides plan JSON, the action renders a full report with attribute-level diffs, module grouping, and inline character highlighting. Adding-jsonto plan and apply steps further enriches the report with diagnostics, drift detection, and per-resource apply outcomes. When astatestep provides post-apply state JSON (viastate pull), attribute values that were unknown at plan time (such as provider-generated IDs) are resolved to their actual values in the apply report. Without thestatestep, these values are shown as "(value not in plan)". - Tier 3 — Raw text fallback: When no show-plan JSON is available but plan or apply steps ran, the action formats their raw output with JSON Lines parsing where possible.
- Tier 4 — Workflow table: When no recognized IaC steps are found, the
action renders a general workflow step status table. This is the mode used
when reporting non-IaC workflows via
target-step.
Each report includes an HTML comment marker for deduplication:
<!-- tf-report-action:"WORKSPACE_NAME" -->- PR context: Old comments with the same workspace marker are deleted before posting the new one, ensuring the latest status appears at the bottom of the conversation.
- Non-PR context: The action searches for an existing issue with the marker and updates it, or creates a new one if none exists.
Only bot-authored comments are deleted — human comments are never touched, even if they contain the marker text.
| Context | Required Permission |
|---|---|
| Pull request | pull-requests: write |
| Non-PR (status issues) | issues: write |
| Both PR and push triggers | Both pull-requests: write and issues: write |
The contents: read permission is always needed to check out the repository.
GitHub enforces a 65,536 character limit on comment and issue bodies. The action automatically manages output within this limit:
- The rendering engine uses a compositor that progressively degrades sections (full → compact → omit) to fit within the limit
- The footer (logs link, timestamp) is reserved from the budget before rendering
- A 512-character safety margin prevents edge cases from exceeding the limit
# Install dependencies
npm ci
# Run all linters
npm run lint
# Type check
npm run typecheck
# Run tests
npm run test
# Run tests with coverage
npm run test:coverage:ci
# Run integration tests with coverage
npm run test:integration:coverage
# Bundle for distribution
npm run bundle
# Format code
npm run format
# Full CI pipeline
npm run ciOutput format inspired by tfplan2md by oocx, used under the MIT License.
MIT