GitHub Actions

GitHub Actions needs no Stategraph-specific integration: install the CLI on the runner, export three variables, and replace terraform plan/apply with stategraph plan/apply. Plans run on pull requests; the apply runs when the change lands on your default branch.

Prerequisites

  • Stategraph running and reachable from GitHub's runners (see Deployment)
  • A Stategraph API key and tenant (see Setup)
  • Your state imported once, with stategraph.json committed (see Setup)

Store the API key as a repository or environment secret named STATEGRAPH_API_KEY.

Workflow

name: Stategraph
on:
  pull_request:
    paths: ['infra/**']
  push:
    branches: [main]
    paths: ['infra/**']

env:
  STATEGRAPH_API_BASE: https://stategraph.example.com
  STATEGRAPH_API_KEY: ${{ secrets.STATEGRAPH_API_KEY }}
  STATEGRAPH_TENANT_ID: your-tenant-id

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    defaults: { run: { working-directory: infra } }
    steps:
      - uses: actions/checkout@v7
      - uses: opentofu/setup-opentofu@v2
        with: { tofu_wrapper: false }
      - name: Install Stategraph CLI
        run: |
          curl -fsSL https://example.com/stategraph-linux-amd64.tar.gz -o /tmp/sg.tgz
          sudo tar xzf /tmp/sg.tgz -C /usr/local/bin stategraph
      - name: Plan
        id: plan
        shell: bash
        continue-on-error: true
        run: stategraph plan 2>&1 | tee plan.txt
      - name: Comment plan on PR
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
          OUTCOME: ${{ steps.plan.outcome }}
        run: |
          {
            echo '<!-- stategraph-plan -->'
            echo "#### Stategraph Plan \`${OUTCOME}\`"
            echo '<details><summary>Show plan</summary>'
            echo
            echo '```'
            cat plan.txt
            echo '```'
            echo '</details>'
            echo
            echo "*Pusher: @${GITHUB_ACTOR}, Action: \`${GITHUB_EVENT_NAME}\`*"
          } > comment.md
          cid=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --jq '[.[] | select(.body | startswith("<!-- stategraph-plan -->"))][0].id')
          if [ -n "$cid" ] && [ "$cid" != "null" ]; then
            gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${cid}" -F body=@comment.md > /dev/null
          else
            gh pr comment "$PR" --body-file comment.md > /dev/null
          fi
      - name: Fail the job if the plan failed
        if: steps.plan.outcome == 'failure'
        run: exit 1

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    defaults: { run: { working-directory: infra } }
    steps:
      - uses: actions/checkout@v7
      - uses: opentofu/setup-opentofu@v2
        with: { tofu_wrapper: false }
      - name: Install Stategraph CLI
        run: |
          curl -fsSL https://example.com/stategraph-linux-amd64.tar.gz -o /tmp/sg.tgz
          sudo tar xzf /tmp/sg.tgz -C /usr/local/bin stategraph
      - name: Apply
        id: apply
        shell: bash
        continue-on-error: true
        run: stategraph apply --auto-approve 2>&1 | tee apply.txt
      - name: Comment apply on the merged PR
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          OUTCOME: ${{ steps.apply.outcome }}
        run: |
          pr=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].number')
          if [ -z "$pr" ] || [ "$pr" = "null" ]; then
            echo "no associated PR; apply output stays in the job log"
            exit 0
          fi
          {
            echo "#### Stategraph Apply \`${OUTCOME}\`"
            echo '<details><summary>Show apply</summary>'
            echo
            echo '```'
            cat apply.txt
            echo '```'
            echo '</details>'
          } > comment.md
          gh pr comment "$pr" --body-file comment.md > /dev/null
      - name: Fail the job if the apply failed
        if: steps.apply.outcome == 'failure'
        run: exit 1

The plan lands on the pull request the way reviewers expect from Terraform CI: an outcome badge, the output in a collapsible section, and a pusher footer. Each PR gets one sticky comment, updated in place on every push — the <!-- stategraph-plan --> marker is what the workflow finds and edits, and gh ships on GitHub-hosted runners.

Failures comment too. The badge flips to failure with the error inside the collapsible section, and the final step then fails the job, so the PR shows both the red check and the reason without a trip into the Actions logs.

After the merge, the apply job posts its result — success or failure — back on the pull request it came from, resolved through the merge commit. The PR ends up carrying the full plan-review-apply record.

stategraph apply --auto-approve plans and applies in one step, so the apply always reflects the merge result — there is no stale-plan-file problem to manage between jobs.

The CLI shells out to OpenTofu (or Terraform) for planning; setup-opentofu provides the binary. Pin TF_CMD in env if the runner has both and you need to choose.

Concurrent pull requests

Two PRs touching disjoint resources plan and apply independently — Stategraph locks per resource, not per state file. When changes genuinely overlap, the second apply is rejected at commit with the conflicting transaction id; re-running the job re-plans and retries. No force-unlock, no serialized queue.

Pull-request-driven applies

Want the plan as a PR comment and applies driven by commenting on the PR instead of merging? That is Stategraph Orchestration — connect the repository and skip hand-rolled workflow YAML entirely.

Next Steps