> ## Documentation Index
> Fetch the complete documentation index at: https://docs.draftt.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Draftt CI GitHub Action

The [`draftt-io/terraform-ci`](https://github.com/draftt-io/terraform-ci) GitHub Action runs in your workflow after `terraform plan`. It scrubs the plan JSON on the runner and uploads the scrubbed copy to Draftt. The [Draftt CI GitHub App](https://docs.draftt.io/draftt-ci-github-app.md) publishes the result; the Action itself never writes to GitHub and needs no GitHub token.

## Add the Action

**1. Create an access key**

In Draftt, go to **Settings > Access Keys** and create a key with the Developer role or above. In GitHub, store it as a repository or organization secret named `DRAFTT_API_KEY`.

**2. Add the step to your workflow**

Add the Draftt step after the one that writes your plan as JSON. For example:

```yaml theme={null}
name: Terraform policy scan

on:
  pull_request:

permissions:
  contents: read

jobs:
  policy-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false
      - run: terraform init -input=false
      - run: terraform plan -input=false -out=tfplan
      - run: terraform show -json tfplan > plan.json
      - name: Scan Terraform policies with Draftt
        uses: draftt-io/terraform-ci@v1
        with:
          plan-json: plan.json
          api-key: ${{ secrets.DRAFTT_API_KEY }}
```

Check out the pull request head commit, as in the example, so Draftt can annotate the changed files. Set `terraform_wrapper: false`, or the plan file is not valid JSON. Write the plan inside the workspace. For OpenTofu, use `tofu plan` and `tofu show -json`.

> [!WARNING]
>
> Use `draftt-io/terraform-ci@v1`. We recommend it: `v1` follows the latest 1.x release, which brings fixes and new optional inputs and never a breaking change. A breaking change ships as `v2`, with migration notes in its [release](https://github.com/draftt-io/terraform-ci/releases).

**3. Open a pull request**

Open or update a pull request that changes Terraform files. The **Draftt Terraform Policy Scan** check fills in with the result when the step uploads the plan. If it stays at **Waiting for a Terraform plan**, open the workflow run and expand the Draftt step: a scan that cannot complete leaves the step green with a warning that says why. See [Troubleshooting](#troubleshooting).

Terraform 1.0 or later, or OpenTofu 1.6 or later. Runners need Node 24 and `git`; the standard `ubuntu-latest` image has both.

## Inputs

| Input | Default | Description |
| - | - | - |
| `plan-json` | Required | Path to the Terraform or OpenTofu plan JSON, relative to the workspace. The file must be inside the workspace. |
| `api-key` | Required | Draftt access key with the Developer role or above. |
| `api-url` | `https://api.draftt.io/ci/scans` | The Draftt CI scans endpoint. Change it only when Draftt asks you to. Must use HTTPS and end in `/ci/scans`. |
| `terraform-root` | `.` | Directory that holds the root module's `.tf` files, relative to the workspace. See [Several root modules](#several-root-modules-in-one-repository). |
| `fail-on-scan-error` | `"false"` | `"true"` fails the step when the scan cannot complete. Policy violations never fail the step. |

## Outputs

| Output | Description |
| - | - |
| `scan-status` | `completed`, `skipped` (pull request from an external fork), or `error`. |
| `violation-count` | Resources with policy violations. |
| `warning-count` | Incomplete evaluation or coverage warnings. |
| `annotation-count` | Annotations the Draftt CI App published on the check. |
| `check-run-url` | URL of the check the Draftt CI App published. Empty when none was published. |

Give the step an `id` to read the outputs in later steps. When `scan-status` is `skipped` or `error`, every count is `0` and the URL is empty, so read `scan-status` first. Outputs contain counts and the check URL only, never plan data or credentials.

```yaml theme={null}
      - name: Scan Terraform policies with Draftt
        id: draftt
        uses: draftt-io/terraform-ci@v1
        with:
          plan-json: plan.json
          api-key: ${{ secrets.DRAFTT_API_KEY }}
      - run: echo "Draftt found ${{ steps.draftt.outputs.violation-count }} resources with violations"
        if: steps.draftt.outputs.scan-status == 'completed'
```

## Several root modules in one repository

Scan each root module with its own Draftt step, or a matrix job, and set `terraform-root` to the module's directory. Each root gets its own check, named **Draftt Terraform Policy Scan (root)**, and its own comment.

```yaml theme={null}
    strategy:
      matrix:
        root: [network, compute]
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      # setup, init, plan and show in ${{ matrix.root }}, then:
      - uses: draftt-io/terraform-ci@v1
        with:
          plan-json: ${{ matrix.root }}/plan.json
          terraform-root: ${{ matrix.root }}
          api-key: ${{ secrets.DRAFTT_API_KEY }}
```

* `terraform-root` is a workspace-relative path, `.` for the repository root. It can contain letters, digits, `.`, `_`, `/`, and `-`, up to 200 characters, with no `..` segments.
* A commit takes at most 50 roots.
* Two scans in the same workflow run attempt must use different roots; a second upload for the same root is answered with the first result and not published. One root planned several times for different environments, with `-var-file` or workspaces, is therefore not supported yet.

## What is sent to Draftt

The raw plan never leaves the runner. The Action sends one HTTPS request with:

* **The planned changes:** the resource changes of managed resources, and the `aws_caller_identity` data source, with every value Terraform marks sensitive removed from both sides of each change. Unchanged resources are included with their current values, scrubbed the same way. A removed value is marked unknown, the same way Terraform marks a value known only after apply, so a policy that depends on it is reported as not evaluated instead of evaluated against a default.
* **A reduced configuration:** the configuration of the changed resources, including those inside modules, with sensitive values removed. Provider configuration is cut down to the region and the assumed role ARN, and the variables that set them.
* **Where each resource is defined:** file path and line per resource address, so the App can annotate the changed files.
* **Where the scan came from:** repository and owner IDs, pull request number, head commit SHA, the `terraform-root`, and the workflow run ID, attempt, and URL.

Never sent: the raw plan, the state file, planned values, outputs, Terraform source files, or any GitHub token. If a recognized credential format is still present after scrubbing, an AWS access key, a PEM private key, a GitHub or GitLab token, a JWT, or a URL with embedded credentials, the Action uploads nothing and reports a scan error that names the kind of credential and where it is.

The Action masks the API key before it reads the plan. The job log and summary never show the key, plan values, or Draftt response bodies.

## Events and limits

* **Pull request events only:** Draftt CI publishes results on `pull_request` events. Pull requests from external forks are skipped, because forks have no access to your secrets, and `scan-status` is `skipped`.
* **Dependabot pull requests:** GitHub does not give workflows triggered by Dependabot access to Actions secrets, so the scan ends in an error on them. Skip the job with `if: github.actor != 'dependabot[bot]'`, or add the key as a Dependabot secret too.
* **`pull_request_target`:** Don't use it. The planned code would run with access to the Draftt key.
* **Plan size:** The raw plan is limited to 50 MiB, the scrubbed plan to 49 MiB, and the whole request to 50 MiB. Requests over 1 MiB are compressed.
* **Request time:** The upload is retried on `502`, `503`, and `504` answers and on network failures, up to 3 attempts within 8 minutes in total.
* **Source map:** The Action sends a map from resource addresses to files and lines so the App can annotate the changed files. It drops the map when the request would exceed the size limit, when the pull request head isn't checked out, when Terraform files differ from the commit, when `GITHUB_WORKSPACE` isn't the repository root, or when Git can't be inspected. The map is capped at 100,000 resources. Without it, findings stay in the check summary and the comment.

## Troubleshooting

Open the workflow run on the pull request and expand the Draftt step. On success, the log shows the `api-url` host, the scan key, the counts, and the check URL. The job summary shows the same counts.

A scan that cannot complete does not fail the step by default: the step turns green with a warning, the outputs report `scan-status: error`, and the check stays at **Waiting for a Terraform plan** until it times out. To fail the step instead, set `fail-on-scan-error: "true"`.

Messages in the step log:

* **`Input required and not supplied: api-key`:** The secret is not set for this repository, or the run has no secrets, as on a Dependabot pull request.
* **`Plan JSON path must stay inside the workspace`**, **`Plan JSON path is not a file`:** Write the plan inside the workspace and pass its path relative to the workspace root, for example `infra/plan.json`.
* **`Plan JSON is not valid JSON`:** Usually the Terraform wrapper added lines to the output. Set `terraform_wrapper: false` on `hashicorp/setup-terraform`.
* **`Terraform plan has an unsupported format_version`:** The file is not the output of `terraform show -json` for a saved plan.
* **`terraform-root must be an existing directory inside the repository`:** The directory doesn't exist in the checked-out commit, or the path points outside the workspace.
* **`Scrubbed plan still contains a recognized <kind> at <path>`:** A credential is in the plan in a value Terraform doesn't mark sensitive. Nothing was uploaded. Mark the value sensitive in Terraform, or move it out of the plan.
* **`Draftt authentication failed`:** The access key is invalid or revoked.
* **`Draftt answered 403: owner_mismatch`:** The repository doesn't belong to the GitHub organization bound to the Draftt organization that owns the access key.
* **`Draftt rejected the scan request as too large`:** The scrubbed plan is over the size limit. Scan a smaller root module, or split the plan.
* **`Draftt could not complete the scan`:** A server error. Re-run the job, and contact support if it persists.
* **`Draftt answered <status>: <message>`:** Any other `4xx` or `502` answer; the message says why. `github_unavailable` means Draftt could not reach GitHub, and a re-run usually clears it.

Problems that appear after the scan completed, when the App publishes the result, are warnings with `scan-status: completed` and an empty `check-run-url`; `fail-on-scan-error` does not fail the step for them. See the [App troubleshooting](https://docs.draftt.io/draftt-ci-github-app.md#troubleshooting).

**No annotations on the changed files**, while the check and the comment show the findings: the step log carries a warning that names the cause. Check that `actions/checkout` checks out the pull request head, that the workspace is the repository root, that `terraform-root` is the directory with the root module's `.tf` files, and that no generated `.tf` files or `.terraform/` directory are untracked in the working tree.

## Support

Questions and problems: [support@draftt.io](mailto:support@draftt.io). Report a security issue to [security@draftt.io](mailto:security@draftt.io), not in a public issue. The Action is open source under Apache-2.0.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.