Documentation menu

Merge blocking

Block pull requests that exceed your cost policy, with an API key, the DiffCost GitHub App, a threshold and a required status check.

Merge blocking needs Starter, Team or the 14-day Team trial. With an API key, the DiffCost service applies your cost policy and posts a commit status named diffcost/cost-check on the pull request's (PR's) head commit, as the DiffCost GitHub App. Making that status a required check is what blocks the merge. The DiffCost workflow job passes even when the PR is blocked (with fail_on_error: true it fails only when DiffCost can't analyze the PR, the DiffCost service doesn't accept the results, or .diffcost.yml can't be applied), so requiring the job's own check never blocks a costly PR: require diffcost/cost-check.

Step Who does it
1. Sign in to the dashboard A GitHub organization owner first: their sign-in starts the Team trial.
2. Create an API key A DiffCost admin.
3. Install the DiffCost GitHub App A GitHub organization owner. Others can request it.
4. Set a cost policy A DiffCost admin (dashboard thresholds), or anyone who can merge into the base branch (.diffcost.yml).
5–7. Check, require and verify Someone who can edit the repository's branch protection or rulesets.

DiffCost admins are your GitHub organization owners, re-checked at every sign-in.

1. Sign in to the dashboard#

Sign in at diffcost.dev with GitHub. You need to be a member of the GitHub organization that owns the repositories: the dashboard works for organizations, not personal accounts (the free Action works for any repository).

  • If you belong to several GitHub organizations, DiffCost asks which one to use the first time you sign in.
  • If your organization restricts OAuth App access, a GitHub organization owner has to approve the DiffCost OAuth App first: GitHub shows a Request button during sign-in, or the owner approves it in GitHub under the organization's Settings → Third-party access → OAuth app policy. Until then the organization isn't offered.
  • The Team trial starts when the first GitHub organization owner signs in. If another member signs in first, the organization stays on Free, and the dashboard asks an owner to sign in. You can set up the Action on Free first, without an account; sign in when you're ready to create the API key, because that starts the 14 days.

2. Create an API key and add it to the workflow#

  1. In the dashboard, go to Settings → GitHub Action API keys → Create API key. The default scope * covers every repository your GitHub organization owns; you can list specific repositories instead.
  2. Click Create key, then Copy key. The key is shown once, so store it before you click Done.
  3. In GitHub, store it as an Actions secret named DIFFCOST_API_KEY, in the repository or the organization.
  4. Pass it to the DiffCost step:
      - uses: diffcost/action@v1
        with:
          sql_paths: "queries/**/*.sql"
          gcp_project: "my-project"
          api_key: ${{ secrets.DIFFCOST_API_KEY }}

Check the secret name. If api_key is empty (a missing or misnamed secret), DiffCost runs as the free Action and posts no status. The comment then says "🔑 No DiffCost API key, so this run can't block merges" with the likely cause, and the log has a notice. See No DiffCost API key.

Dependabot PRs get only Dependabot secrets. In GitHub, add DIFFCOST_API_KEY as a Dependabot secret too (the repository's Settings → Secrets and variables → Dependabot). If the workflow authenticates to Google Cloud with a service account key, add GCP_SA_KEY there as well: without credentials every changed query has an unknown cost, which blocks the PR, and in a dbt repository dbt compile fails before DiffCost runs.

3. Install the DiffCost GitHub App#

In the dashboard, open Settings. Under Merge-blocking thresholds, click Install the DiffCost GitHub App. Installing it usually takes a GitHub organization owner; for anyone else, GitHub sends an install request to the owners.

Choose All repositories, or select every repository that runs DiffCost: the DiffCost service can't post statuses on a repository the installation doesn't cover. Once it detects the installation, the dashboard shows "GitHub App installed for all repositories" (or "GitHub App installed for selected repositories").

4. Set a cost policy#

Without a threshold, every PR passes. When a query got more expensive, the status says "Worst query +$X/run (no threshold set)". Set one of these:

  • the org-wide threshold (scope *) in the dashboard: every paid plan;
  • a threshold for one repository in the dashboard: Team plan and the trial;
  • a thresholds: block in the repository's .diffcost.yml: every paid plan.
# .diffcost.yml, in the repository root, read from the PR's base branch
thresholds:
  absolute: 5.00   # block when any one query costs more than $5.00/run extra
  relative: 200    # or when any one query's cost goes up by more than 200%

Thresholds and alerts explains how DiffCost evaluates thresholds and which one applies.

5. Check that every PR gets a status#

A required check that never reports holds the PR: GitHub shows "Expected — Waiting for status to be reported" and the PR can't merge. Before you make diffcost/cost-check required, make sure every PR gets a status:

  • The workflow runs on every pull_request, with no paths: filter.
  • Every run has the API key, Dependabot PRs included (see step 2).
  • The DiffCost GitHub App covers the repository (see step 3).
  • If the branch uses a merge queue, the workflow also runs on merge_group (see Merge queues). Otherwise the queue waits for the required status until its status-check timeout, then removes the PR.
  • DiffCost runs once per PR. A second run on the same PR overwrites the first run's status.

Some PRs get no status by design:

  • PRs from forks get no secrets and a read-only token, so DiffCost can post neither a status nor the PR comment for them (the report is in the job summary). An admin has to bypass the requirement to merge one. Don't work around this with pull_request_target plus a checkout of the fork's code: that runs untrusted code with your secrets.
  • The DiffCost service posts no status when the App doesn't cover the repository, the service rejects the API key, or the runner can't reach the service. The PR comment and the log then name the cause and the fix (install the App on the repository, fix the key, or re-run the job), whether or not the policy was exceeded. DiffCost posts this comment even on PRs that change no SQL, and even with comment_on_clean: false.
  • If the DiffCost service doesn't answer in time, or answers with something that isn't a DiffCost result (for example a proxy's page), DiffCost can't tell whether the status was posted. The comment says the status could not be confirmed.
  • When DiffCost can't analyze the PR ("could not analyze": invalid configuration, git or manifest errors) or crashes (then only the step log has the error), no status is posted. The required check waits until the cause is fixed.

A run that posts no status doesn't remove a status an earlier run posted on the same commit (a re-run, or a reopened PR). That status still applies, passing or failing, until a run posts a new one.

6. Make the check required#

  1. Open one pull request that runs DiffCost, so the status is posted once. GitHub only offers checks that have reported recently when you pick required checks.
  2. In GitHub, add diffcost/cost-check as a required status check in the branch protection rule or ruleset for the base branch, and set its source to the DiffCost GitHub App. Any workflow with statuses: write can post a status with the same name; pinning the source makes GitHub ignore those.

Don't require the check of the DiffCost workflow job itself (the job is named diffcost in the examples): it passes even when the PR is blocked. PRs that change no SQL get a passing diffcost/cost-check status, so they aren't held up.

7. Verify that it blocks#

Open a test PR that should be blocked: for example, make one query scan much more data than your absolute limit allows, or temporarily lower the dashboard threshold. Then check that:

  • the PR comment shows "🚫 Merge blocked — this PR exceeds a DiffCost cost policy" right below its title;
  • diffcost/cost-check is failing, and its source is the DiffCost GitHub App;
  • GitHub won't let you merge the PR.

Close the test PR and restore the threshold.

Merge queues#

If the base branch uses a merge queue, GitHub needs the required check on the queue's commit as well as on the PR. GitHub runs workflows on the queue's commit for the merge_group event, so add it to the workflow's triggers:

on:
  pull_request:
  merge_group:   # `paths:` filters don't apply to merge_group

Then let the credential steps run on queue runs. Their fork check reads github.event.pull_request, which a queue run doesn't have, so without this change the auth step is skipped and every query has an unknown cost:

      - uses: google-github-actions/auth@v2
        if: github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository

In a dbt repository, give the dbt compile and baseline download steps the same if:.

On a queue run, DiffCost:

  • compares the queue's commit with the commit it was built on (merge_group.base_sha). When GitHub groups several PRs into one queue commit, the report covers all of them, and the job summary links the PR that names the queue branch. For dbt, the baseline is still the main-branch manifest, so the report can also include models that PRs ahead of yours in the queue changed;
  • applies the same cost policy and posts diffcost/cost-check on the queue's commit. A queue run that exceeds the policy fails the check, so the PR doesn't merge;
  • posts no PR comment. The report is in the workflow run's job summary, which names the PR;
  • sends a Slack alert when the policy is exceeded, as on a PR run (a dashboard threshold's alert is titled "DiffCost — Merge Blocked (merge queue)");
  • shows the run in the dashboard's Pull requests table with a Merge queue badge. The 30-day figures, the net cost change chart and cost by author count only PR runs, so a queued PR isn't counted twice. A queue run that changes no SQL gets a passing status and isn't listed.

The queue run needs the API key like any other run.

Roll out to every repository#

To gate every repository in the organization:

  1. Store a *-scoped API key as an organization secret named DIFFCOST_API_KEY, and as an organization Dependabot secret, with repository access set to every repository that runs DiffCost. On GitHub Free, private repositories can't use organization secrets: use repository secrets there. A repository the secret doesn't reach runs DiffCost without a key: it posts no status, and the comment says "No DiffCost API key".
  2. Install the DiffCost GitHub App on All repositories.
  3. With Workload Identity Federation, add the roles/iam.workloadIdentityUser binding for each repository (see BigQuery access).
  4. Add the DiffCost workflow to each repository (and, for dbt, the main-branch manifest workflow).
  5. Set the org-wide threshold (scope *) in the dashboard, or a .diffcost.yml in each repository.
  6. Open a test PR in each repository and check that it gets a diffcost/cost-check status from the DiffCost GitHub App (see step 5). Fix any repository that doesn't before the next step.
  7. Require diffcost/cost-check in each repository's branch protection, or in an organization ruleset that targets only the repositories that run the workflow: a repository without it would wait for the status forever. Check that your GitHub plan offers organization rulesets.

Then verify blocking in each repository, as in step 7 above.

Protect the workflow itself#

The required check trusts the workflow that runs DiffCost. .diffcost.yml is read from the PR's base commit, so editing it in a PR has no effect on that PR. A PR can still edit .github/workflows/*.yml in the same PR, for example to skip DiffCost or to send different numbers with your API key. To close that gap, do one of these:

  • protect .github/workflows/** with a CODEOWNERS entry and turn on Require review from Code Owners;
  • run DiffCost as a required workflow in an organization ruleset, so PRs can't change it.

What the comment says about enforcement#

  • "🚫 Merge blocked": the policy was exceeded and the failing status was posted. The blocked output is true.
  • "Cost policy exceeded — not enforced": the policy was exceeded but no failing status was posted, so GitHub doesn't block the merge (unless an earlier run already posted a failing status on this commit, which still applies). The comment gives the reason, for example: the App isn't installed for the repository, the status post failed, the API key was rejected, or the DiffCost service was unreachable, rate-limited the run or returned an error. Troubleshooting lists every reason.
  • "Cost policy exceeded — enforcement unconfirmed": the DiffCost service didn't answer in time, or its answer wasn't a DiffCost result, so the status may or may not have been posted. Check the PR's checks, or re-run the job.
  • Free plan (including a trial that ended): thresholds aren't evaluated. With the DiffCost GitHub App installed, the status reports success: "Merge blocking is off on the Free plan". Without the App no status is posted, and the comment says the App isn't installed.

If the dashboard settings can't be fetched (key rejected, rate-limited or service unreachable), DiffCost checks the .diffcost.yml thresholds on the runner, on any plan, and the dashboard's pricing isn't applied in that run. If the DiffCost service then accepts the results, its verdict (dashboard thresholds and your plan) replaces that local check. Otherwise the comment and the policy_exceeded output show the local result, as "not enforced". Troubleshooting lists the fixes.

Something missing or wrong? Email support@diffcost.dev.