Skip to content

Repository files navigation

Infracost CI

Cloud cost estimates for infrastructure code, in your CI pipeline.

infracost-scanner is a single Go binary — scanner inside the container image — that scans directories of infrastructure code, calculates the cost difference between two branches, posts a comment on the pull request, and uploads the run to Infracost Cloud. It embeds the Infracost CLI as a library rather than shelling out to it.

  • Cost diffs on every PR — what this change does to the monthly bill.
  • Guardrails, budgets and FinOps policies — evaluated against the head branch, with a non-zero exit when a blocking one trips.
  • Baseline scans — keep the dashboard current for the default branch.
  • No install step in CI — the container image ships git and every parser and provider plugin.

How it works

flowchart LR
  base[base checkout] --> scanner
  head[head checkout] --> scanner["scanner diff"]
  scanner <--> cloud[("Infracost Cloud<br/>policies · guardrails · budgets")]
  scanner --> comment["PR comment"]
  scanner --> gate{"blocking<br/>violation?"}
  gate -->|yes| fail["exit 1"]
  gate -->|no| pass["exit 0"]
Loading

diff takes two checkouts of the same repository — the base branch and the head branch — not one path. scan is the single-directory command.

Across the life of a pull request:

sequenceDiagram
    participant Repo as Default branch
    participant PR as Pull request
    participant CI
    participant Cloud as Infracost Cloud
    Repo->>CI: push
    CI->>Cloud: scanner scan --path .
    PR->>CI: opened / synchronised
    CI->>Cloud: scanner diff --base-path … --head-path …
    Cloud-->>CI: policies, guardrails, budgets
    CI->>PR: cost comment (created or updated)
    PR->>CI: merged / closed
    CI->>Cloud: scanner status --status MERGED
Loading

Provider support

Provider scan diff PR comment status
GitHub
GitLab
Bitbucket
Azure Repos

Self-managed servers work: GitHub Enterprise Server, self-managed GitLab, Bitbucket Data Center and Azure DevOps Server are all derived from the repository URL.

Getting started

Two secrets, and nothing else. Set an Infracost API key in INFRACOST_CLI_AUTHENTICATION_TOKEN, and a token the provider lets you comment with. The repository, pull request, branches and run id come from the CI platform's own variables.

What is inferred

Setting GitHub Actions GitLab CI Bitbucket Pipelines Azure Pipelines
Detected by GITHUB_ACTIONS GITLAB_CI BITBUCKET_* SYSTEM_COLLECTIONURI
Provider github gitlab bitbucket azure_repos, or github for a GitHub-backed repo
Repository URL GITHUB_SERVER_URL + GITHUB_REPOSITORY CI_PROJECT_URL BITBUCKET_GIT_HTTP_ORIGIN BUILD_REPOSITORY_URI
Pull request id event payload CI_MERGE_REQUEST_IID BITBUCKET_PR_ID SYSTEM_PULLREQUEST_PULLREQUESTID
Branch GITHUB_HEAD_REF CI_MERGE_REQUEST_SOURCE_BRANCH_NAME BITBUCKET_BRANCH SYSTEM_PULLREQUEST_SOURCEBRANCH
Base branch GITHUB_BASE_REF CI_MERGE_REQUEST_TARGET_BRANCH_NAME BITBUCKET_PR_DESTINATION_BRANCH SYSTEM_PULLREQUEST_TARGETBRANCH
Pipeline run id GITHUB_RUN_ID CI_PIPELINE_ID BITBUCKET_BUILD_NUMBER BUILD_BUILDID
Pull request title event payload CI_MERGE_REQUEST_TITLE API lookup, needs the comment token API lookup, needs the comment token
Pull request author event payload CI_COMMIT_AUTHOR (commit author) API lookup, needs the comment token BUILD_REQUESTEDFOR
Pull request labels event payload CI_MERGE_REQUEST_LABELS
Comment token — yours to set GITHUB_TOKEN GITLAB_TOKEN BITBUCKET_TOKEN SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT

Event payload is the pull_request object in GITHUB_EVENT_PATH: Actions has no predefined variable for any of those four.

† Bitbucket sets that one to an http:// URL, which keys a second repository on the dashboard. The example below overrides it — the one line you still have to copy.

On a branch build with no pull request, the branch falls back to GITHUB_REF_NAME, CI_COMMIT_BRANCH and BUILD_SOURCEBRANCHNAME respectively.

Any INFRACOST_VCS_* you set yourself wins over the inferred value — see Configuration. On any other CI platform nothing is inferred, so set them all. --debug prints one line naming what was inferred and what your environment overrode.

GitHub Actions

name: Infracost
on: [pull_request]

jobs:
  infracost:
    runs-on: ubuntu-latest
    container: ghcr.io/infracost/ci:0.1
    permissions:
      contents: read
      pull-requests: write
    env:
      INFRACOST_CLI_AUTHENTICATION_TOKEN: ${{ secrets.INFRACOST_API_KEY }}
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.sha }}
          path: base
      - uses: actions/checkout@v4
        with:
          path: head
      - run: scanner diff --base-path base --head-path head

The image entrypoint is the scanner, but a container: job replaces it with its own shell — so the step names the binary rather than passing a subcommand to the image.

On GitHub Enterprise Server nothing extra is needed: the API URL is derived from the inferred repository URL. Override it with --github-api-url if it differs. That flag wants the base server URL (the shape of GITHUB_SERVER_URL), not GITHUB_API_URL — the latter ends /api/v3, and /api/graphql is appended to whatever it is given. It is a flag only: GITHUB_SERVER_URL is https://github.com on github.com, which is not an API URL.

GitLab CI

infracost:
  image: ghcr.io/infracost/ci:0.1
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    # Full history: the shallow default may not contain the target branch.
    GIT_DEPTH: 0
  script:
    - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - git worktree add base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - git worktree add head HEAD
    - scanner diff --base-path base --head-path head

INFRACOST_CLI_AUTHENTICATION_TOKEN and GITLAB_TOKEN both go in masked CI/CD variables. GITLAB_TOKEN needs api scope to post notes — CI_JOB_TOKEN cannot, so it is deliberately not a fallback.

Self-managed GitLab needs nothing extra: the server URL is derived from CI_PROJECT_URL. A GitLab served from a relative root (https://host/gitlab/group/repo) needs --gitlab-server-url and --gitlab-project.

To scan the default branch instead, drop the rules: and run scan --path ..

Bitbucket Pipelines

image: ghcr.io/infracost/ci:0.1

# Full history: the shallow default may not contain the destination branch.
clone:
  depth: full

pipelines:
  pull-requests:
    '**':
      - step:
          name: Infracost
          script:
            # BITBUCKET_GIT_HTTP_ORIGIN is http, so set the https web URL.
            - export INFRACOST_VCS_REPOSITORY_URL="https://bitbucket.org/$BITBUCKET_REPO_FULL_NAME"
            # FETCH_HEAD, not origin/<branch>: the clone has no remote-tracking
            # ref for the destination branch.
            - git fetch origin "$BITBUCKET_PR_DESTINATION_BRANCH"
            - git worktree add base FETCH_HEAD
            - git worktree add head HEAD
            - scanner diff --base-path base --head-path head
  branches:
    main:
      - step:
          name: Infracost baseline
          script:
            - export INFRACOST_VCS_REPOSITORY_URL="https://bitbucket.org/$BITBUCKET_REPO_FULL_NAME"
            - scanner scan --path .

Bitbucket ignores the image's entrypoint and runs the script: lines itself, so the line names the binary.

INFRACOST_CLI_AUTHENTICATION_TOKEN and BITBUCKET_TOKEN both go in secured repository variables. BITBUCKET_TOKEN is a repository or workspace access token with pull requests: write scope, or user:app-password for Basic auth.

BITBUCKET_PR_ID only exists in a pull-requests: pipeline, which is why diff lives there and the branch pipeline runs scan.

Bitbucket has no predefined variable for the pull request title or author, so diff reads them from the pull request API with the same BITBUCKET_TOKEN it comments with. The token is required either way: diff fails without it.

Bitbucket Data Center: set INFRACOST_VCS_REPOSITORY_URL to the /projects/<key>/repos/<slug> web URL, or pass --bitbucket-repo and --bitbucket-server-url.

Azure Pipelines

jobs:
  - job: infracost
    # diff is a pull request run: a build of the default branch has no target
    # branch to fetch. Run scan --path . there instead.
    condition: eq(variables['Build.Reason'], 'PullRequest')
    container: ghcr.io/infracost/ci:0.1
    steps:
      - checkout: self
        fetchDepth: 0
        # The default drops the auth header, so the fetch below cannot reach a
        # private repository.
        persistCredentials: true
      - script: |
          git fetch origin "$(System.PullRequest.TargetBranchName)"
          git worktree add base "origin/$(System.PullRequest.TargetBranchName)"
          git worktree add head HEAD
      - script: scanner diff --base-path base --head-path head
        env:
          INFRACOST_CLI_AUTHENTICATION_TOKEN: $(INFRACOST_API_KEY)
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)

SYSTEM_ACCESSTOKEN must be mapped explicitly — Azure Pipelines does not expose System.AccessToken to a step otherwise. Give the build service Contribute to pull requests on the repository. A personal access token works too, via AZURE_DEVOPS_EXT_PAT; only a 52-character PAT is sent as Basic auth, anything else goes out as a bearer token. diff fails without one of them, and uses it to read the pull request title and author — the only fields Azure has no variable for.

Jenkins

Nothing is inferred on Jenkins, so the INFRACOST_VCS_* variables carry the metadata. A multibranch pipeline sets the CHANGE_* variables on a pull request build.

pipeline {
  agent {
    docker {
      image 'ghcr.io/infracost/ci:0.1'
      // The plugin holds the container open with `cat`, so clear the entrypoint.
      args  '--entrypoint='
    }
  }

  environment {
    INFRACOST_CLI_AUTHENTICATION_TOKEN = credentials('infracost-api-key')
    GITHUB_TOKEN                       = credentials('github-token')
    INFRACOST_VCS_PROVIDER             = 'github'
    INFRACOST_VCS_REPOSITORY_URL       = 'https://github.com/ORG/REPO'
    INFRACOST_VCS_PULL_REQUEST_ID      = "${env.CHANGE_ID}"
    INFRACOST_VCS_PULL_REQUEST_TITLE   = "${env.CHANGE_TITLE}"
    INFRACOST_VCS_PULL_REQUEST_AUTHOR  = "${env.CHANGE_AUTHOR}"
    INFRACOST_VCS_BRANCH               = "${env.CHANGE_BRANCH}"
    INFRACOST_VCS_BASE_BRANCH          = "${env.CHANGE_TARGET}"
    INFRACOST_VCS_PIPELINE_RUN_ID      = "${env.BUILD_NUMBER}"
  }

  stages {
    stage('Infracost') {
      when { changeRequest() }
      steps {
        sh '''
          # The workspace is reused between builds, so clear old worktrees.
          git worktree remove --force base || true
          git worktree remove --force head || true
          git worktree prune

          git fetch origin "$CHANGE_TARGET"
          git worktree add base FETCH_HEAD
          git worktree add head HEAD
          scanner diff --base-path base --head-path head
        '''
      }
    }
  }
}

Jenkins checks out the pull request ref, so there is no origin/$CHANGE_TARGET to point the base worktree at — hence FETCH_HEAD, the ref the fetch just wrote.

Swap INFRACOST_VCS_PROVIDER and the token for gitlab / GITLAB_TOKEN, bitbucket / BITBUCKET_TOKEN or azure_repos / AZURE_DEVOPS_EXT_PAT to match where the repository lives.

Set INFRACOST_VCS_REPOSITORY_URL to the repository web URL. GIT_URL is a clone URL and may carry credentials, so it is not a safe substitute.

Commands

Command What it does
diff --base-path <dir> --head-path <dir> Scan both checkouts, compute the cost diff, post or update the PR comment, upload the run. Exits 1 on a new blocking guardrail or policy violation. Comments on GitHub, GitLab, Azure Repos and Bitbucket.
scan --path <dir> Scan one directory and upload a branch run.
status --status OPEN|MERGED|CLOSED Update the pull request state in the dashboard.
plugins install|list|detect Install the parser and provider plugins, report their versions, or print the projects they identify. No auth token needed.

--tag sets the marker diff uses to find its own comment (default infracost-comment). Changing it on a repository that already has an Infracost comment means the next run cannot find the old one and posts a second.

Run scanner <command> --help for the full flag list. Most VCS metadata can come from a flag or an INFRACOST_VCS_* variable; the flag wins when both are set. Paths are flags only.

Configuration

On the four platforms above these are overrides — set one only to correct an inferred value, or add something the platform does not expose. Everywhere else, set them all.

Variable Notes
INFRACOST_CLI_AUTHENTICATION_TOKEN Required by diff and scan.
INFRACOST_VCS_PROVIDER github, gitlab, azure_repos or bitbucket.
INFRACOST_VCS_REPOSITORY_URL Repository web URL. Never a clone URL with credentials in it.
INFRACOST_VCS_PULL_REQUEST_ID PR number. On GitLab this is the project-scoped iid.
INFRACOST_VCS_PULL_REQUEST_URL Alternative to the ID. Set both and they must agree.
INFRACOST_VCS_BRANCH, INFRACOST_VCS_BASE_BRANCH Fall back to the checkout's git metadata.
INFRACOST_VCS_PULL_REQUEST_TITLE, INFRACOST_VCS_PULL_REQUEST_AUTHOR, INFRACOST_VCS_PULL_REQUEST_LABELS Shown on the dashboard run. Labels are comma-separated.
INFRACOST_VCS_PIPELINE_RUN_ID Links the run back to the CI job.
INFRACOST_CI_DISABLE_DASHBOARD Skip uploading results.
INFRACOST_CI_PLATFORM Pin the detected platform: github_actions, gitlab_ci, bitbucket, azure_devops_TfsGit or azure_devops_GitHub. Any other name turns inference off.
GITHUB_TOKEN, GITLAB_TOKEN, BITBUCKET_TOKEN, AZURE_DEVOPS_EXT_PAT or SYSTEM_ACCESSTOKEN Comment token for the provider diff is running against.
INFRACOST_CI_VCS_TLS_CA_CERT_FILE PEM bundle trusted in addition to the system pool, for a self-managed VCS behind a private CA. VCS only — the dashboard, pricing and events clients use the system pool.
INFRACOST_CI_VCS_TLS_INSECURE_SKIP_VERIFY Skip VCS certificate verification. The token then travels over an unauthenticated connection — prefer the CA file. Warns when on.

Commit SHA, message, author and timestamp are read from the head checkout when not set explicitly — which is why git must be on PATH. The container image has it.

Installing

Container (recommended)

The image carries git and every plugin, so a job pulls once instead of downloading ~150 MB of plugins on every run.

docker run --rm -e INFRACOST_CLI_AUTHENTICATION_TOKEN -v "$PWD:/src" -w /src \
  ghcr.io/infracost/ci:latest scan --path .

Tags are latest, the minor series (0.1) and the exact version (0.1.0). Only the exact version is immutable; pin by digest to pin the bytes:

docker run --rm ghcr.io/infracost/ci@sha256:... --version

Each release publishes the manifest-list digest as image-digest.txt, and docker inspect reports the plugin versions baked in under the io.infracost.plugins label.

The image defaults to root, which matches what a GitHub Actions container: job does and sidesteps uid mismatches against a mounted workspace. Under a runAsNonRoot policy, give the user a writable home — the scanner caches there:

docker run --rm --user 65532 -e HOME=/tmp -e INFRACOST_CLI_AUTHENTICATION_TOKEN \
  -v "$PWD:/src" -w /src ghcr.io/infracost/ci:latest scan --path .

git:: Terraform module sources work; hg:: sources do not, since Mercurial is not in the image.

Binary

Linux and macOS:

BASE="${INFRACOST_SCANNER_BASE_URL:-https://github.com/infracost/ci/releases}"
REF="latest/download"        # or "download/v0.1.0" to pin
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m); case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; esac
SHA=$(command -v sha256sum || echo "shasum -a 256")   # macOS has no sha256sum

# Chained: an unverified archive must never reach tar.
ARCHIVE="infracost-scanner_${OS}_${ARCH}.tar.gz"
curl -fsSL -O "${BASE}/${REF}/${ARCHIVE}" &&
  curl -fsSL -O "${BASE}/${REF}/checksums.txt" &&
  grep " ${ARCHIVE}$" checksums.txt | $SHA -c - &&
  tar -xzf "$ARCHIVE"

Windows (PowerShell):

# PowerShell 5.1 may default below TLS 1.2, and the progress stream makes a
# multi-megabyte -OutFile download very slow.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = "SilentlyContinue"

$Base = if ($env:INFRACOST_SCANNER_BASE_URL) { $env:INFRACOST_SCANNER_BASE_URL } else { "https://github.com/infracost/ci/releases" }
$Ref = "latest/download"     # or "download/v0.1.0" to pin
# An emulated x64 host on ARM64 reports AMD64; ARCHITEW6432 holds the real one.
$Machine = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$Arch = if ($Machine -eq "ARM64") { "arm64" } else { "amd64" }

$Archive = "infracost-scanner_windows_$Arch.zip"
Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Ref/$Archive" -OutFile $Archive
Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Ref/checksums.txt" -OutFile checksums.txt

$Expected = (Select-String -Path checksums.txt -Pattern " $Archive$").Line.Split(" ")[0]
$Actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLower()
if ($Expected -ne $Actual) { throw "checksum mismatch for $Archive" }

Expand-Archive -Path $Archive -DestinationPath . -Force

Assets are infracost-scanner_<os>_<arch>.tar.gz (linux, darwin), infracost-scanner_windows_<arch>.zip and checksums.txt. Set INFRACOST_SCANNER_BASE_URL to serve the same layout from your own mirror. checksums.txt comes from the same host as the archive, so verification proves the download was not corrupted — not that the host is honest. Only point it at a host you trust.

Binary installs need git on PATH, and the first run downloads the plugins.

Development

make build            # Build the binary
make test             # Run all tests
make test-unit        # Unit tests only
make test-integration # Integration tests (needs INFRACOST_CLI_AUTHENTICATION_TOKEN)
make lint             # golangci-lint
make mocks            # Regenerate mockery mocks

The Dockerfile copies a released binary rather than compiling one, so it does not build from a clean checkout. To build it locally, put a binary where the release workflow would:

mkdir -p "dist/$(go env GOARCH)"
CGO_ENABLED=0 go build -o "dist/$(go env GOARCH)/infracost-scanner" .
docker build -t infracost-ci:dev .

Cutting a release

Push the tag. Nothing else.

git tag v0.1.0
git push origin v0.1.0

Do not use gh release create. The workflow drafts the release itself, builds into it, verifies, and publishes last. A release that already exists and is published makes the workflow refuse, because drafting over it would 404 every pinned download and hand latest back to the previous version.

If that happens, delete the release and the tag, then push the tag again:

gh release delete v0.1.0 --repo infracost/ci --yes
git push --delete origin v0.1.0

Pushing a v*.*.* tag builds six platforms, attaches checksums.txt, publishes the release, and pushes ghcr.io/infracost/ci for linux/amd64 and linux/arm64. The image is only tagged once the archives and the image have both been verified, so a rolled-back release leaves nothing behind.

License

Apache 2.0

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages