GitLab CI

GitLab CI 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 merge requests and land as a single updated MR note; the apply runs when the change merges to your default branch and posts its result back on the merge request.

Prerequisites

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

Set two masked CI/CD variables on the project:

Variable Value
STATEGRAPH_API_KEY Your Stategraph API key
COMMENT_TOKEN A GitLab token with api scope, used to post MR notes — CI_JOB_TOKEN cannot write notes, so a project access token (or personal access token) is required

Pipeline

stages: [plan, apply]

default:
  image: alpine:3.20

variables:
  STATEGRAPH_API_BASE: https://stategraph.example.com
  STATEGRAPH_TENANT_ID: your-tenant-id

.install: &install
  - apk add --no-cache curl jq >/dev/null
  - curl -fsSL https://example.com/stategraph-linux-amd64.tar.gz | tar xz -C /usr/local/bin stategraph
  - curl -fsSL https://github.com/opentofu/opentofu/releases/download/v1.8.8/tofu_1.8.8_linux_amd64.tar.gz | tar xz -C /usr/local/bin tofu

plan:
  stage: plan
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes: [infra/**/*]
  script:
    - *install
    - cd infra
    - rc=0; stategraph plan > plan.txt 2>&1 || rc=$?
    - cat plan.txt
    - outcome=success; [ "$rc" -ne 0 ] && outcome=failure
    - |
      {
        echo '<!-- stategraph-plan -->'
        echo "#### Stategraph Plan \`${outcome}\`"
        echo '<details><summary>Show plan</summary>'
        echo
        echo '```'
        cat plan.txt
        echo '```'
        echo '</details>'
      } > note.md
    - |
      nid=$(curl -s -H "PRIVATE-TOKEN: $COMMENT_TOKEN" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes?per_page=100" | jq -r '[.[] | select(.body | startswith("<!-- stategraph-plan -->"))][0].id')
      if [ "$nid" != "null" ] && [ -n "$nid" ]; then
        curl -s -X PUT -H "PRIVATE-TOKEN: $COMMENT_TOKEN" --data-urlencode "body@note.md" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$nid" > /dev/null
      else
        curl -s -X POST -H "PRIVATE-TOKEN: $COMMENT_TOKEN" --data-urlencode "body@note.md" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" > /dev/null
      fi
    - exit $rc

apply:
  stage: apply
  rules:
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes: [infra/**/*]
  script:
    - *install
    - cd infra
    - rc=0; stategraph apply --auto-approve > apply.txt 2>&1 || rc=$?
    - cat apply.txt
    - outcome=success; [ "$rc" -ne 0 ] && outcome=failure
    - |
      iid=$(curl -s -H "PRIVATE-TOKEN: $COMMENT_TOKEN" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/repository/commits/$CI_COMMIT_SHA/merge_requests" | jq -r '.[0].iid')
      if [ "$iid" != "null" ] && [ -n "$iid" ]; then
        {
          echo "#### Stategraph Apply \`${outcome}\`"
          echo '<details><summary>Show apply</summary>'
          echo
          echo '```'
          cat apply.txt
          echo '```'
          echo '</details>'
        } > note.md
        curl -s -X POST -H "PRIVATE-TOKEN: $COMMENT_TOKEN" --data-urlencode "body@note.md" "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$iid/notes" > /dev/null
      fi
    - exit $rc

The plan job posts the Stategraph diff as a single MR note with an outcome badge and the output in a collapsible section, updated in place on every push (the <!-- stategraph-plan --> marker is what the job finds and edits). A failed plan still posts the note — the badge flips to failure with the error inside — and the job then fails, so the MR shows both the red pipeline and the reason.

After the merge, the apply job posts its result — success or failure — back on the merge request it came from, resolved through the merge commit. Direct pushes without an MR skip the note and keep the output in the job log.

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; both binaries in the install snippet are static, so any Linux image works, including Alpine. Pin TF_CMD in variables if the image carries both and you need to choose.

Concurrent merge requests

Two MRs 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.

Next Steps