DomainCanary Start free

Guides

Check an SPF change in a pull request before it reaches DNS

Last updated 29 Sep 2026

Reviewing an SPF diff in a pull request is mostly guesswork. You see include: lines and address ranges, but you can't tell from the text which services are still sending mail. A dropped sender gets merged anyway, and you only find out when messages start bouncing.

Can a CI job catch a broken SPF change? Yes. Resolve the proposed record against live DNS, compare it with what you publish today, and fail the pull request when a sender loses its pass.

Add the check to your DNS repository

  1. Add a step that prints the proposed SPF record for your domain's root name. The recipes further down cover octoDNS, DNSControl, Terraform and a plain file.
  2. Add the DomainCanary SPF pre-flight action after it, with the domain and that record as inputs.
  3. For the traffic check, create an API key on your account page and store it as a repository secret called DOMAINCANARY_API_KEY.
  4. Mark the check as required in your branch protection rules, so a failed check blocks the merge.

A workflow for a repository that keeps the record in dns/spf.txt:

on: pull_request: paths: ["dns/**"] jobs: spf: runs-on: ubuntu-latest permissions: contents: read pull-requests: write # only for comment: true steps: - uses: actions/checkout@v4 - id: record run: echo "spf=$(cat dns/spf.txt)" >> "$GITHUB_OUTPUT" - uses: domaincanary/spf-preflight-action@v1 with: domain: example.com record: ${{ steps.record.outputs.spf }} api-key: ${{ secrets.DOMAINCANARY_API_KEY }} comment: true

With comment: true, the action posts its result on the pull request, so the reviewer reads it beside the diff. The verdict output is pass, warn, fail or unchanged.

What fails the pull request

The action sends your proposed record to our SPF change check. We resolve every include in both records, count up the lookups, and diff the covered IP addresses. The check fails on:

  • the proposed record costs more than 10 DNS lookups
  • it ends in +all, or includes or redirects to a record that does
  • an added include points at a name with no SPF record, with two SPF records, or back at itself
  • a term is malformed, such as include: with no domain or a/99
  • with an API key, a removed address range sent mail in the last 90 days of your DMARC reports

If your live record already has a syntax error or a broken include, we'll report it without failing the run. The check only blocks the PR if your change introduced the problem.

These changes get a warning instead:

  • removing addresses, when there's no API key to check them against your reports
  • reordering the terms of a record that has a -, ~ or ? term, because receivers stop at the first term that matches
  • adding a, mx, ptr, exists or a macro, whose addresses only resolve when a message arrives

Set fail-on-warn: true to fail on warnings as well.

Any answer that isn't a verdict also fails the check: a rate limit, a revoked key, or our service not answering. That way, a check that couldn't run never shows as passed.

Without a key, removals are warnings

Without a key, the action only checks your syntax, lookup limit, and the all ending. DNS can't tell you if an address is still sending mail, so any removed IPs just trigger a warning.

With a key, we check removed ranges against actual senders in your domain's DMARC aggregate reports from the last 90 days. If a removed range sent mail, the check fails and lists it in the result. You'll need the domain verified in your DomainCanary account with reports coming in for this to work, and if it can't run, the output tells you why.

Print the proposed record from your DNS tool

Each recipe searches the tool's output for a string starting with v=spf1, because field names change between versions of these tools. Run the step on its own once and check it prints the record you expect.

octoDNS, with yq, on the zone file you changed:

yq '.[""] | .. | select(tag == "!!str") | select(test("^v=spf1"))' zones/example.com.yaml

DNSControl:

dnscontrol print-ir 2>/dev/null \ | jq -r --arg zone example.com '.. | objects | select(.name? == $zone and has("records")) | .records[] | select(.name == "@" or .name == $zone or .name == ($zone + ".")) | .. | strings | select(startswith("v=spf1"))' | sort -u

If you use SPF_BUILDER, DNSControl splits a long record across several names. The check needs the record at the root name, which is the first one printed.

Terraform, for Cloudflare's content or value, Route 53's records and most other providers:

terraform show -json plan.out \ | jq -r --arg name example.com '.resource_changes[].change.after | select(. != null and .name == $name) | .. | strings | select(test("^\"?v=spf1"))'

Some providers want the full name with a trailing dot, or @, in name. Match whatever your resources use.

A plain file: cat dns/spf.txt.

Pull requests from forks run without the key

GitHub withholds secrets from workflows that run on a pull request from a fork. On those, the action runs keyless and any removal is a warning. Don't switch the workflow to pull_request_target to get the key into those runs. That event runs with your secrets, and checking out the fork's code under it hands them to whoever opened the pull request.

GitLab, Bitbucket and other CI

The action wraps one HTTP call, so any CI system can make it:

curl -s -X POST https://domaincanary.com/api/v1/spf-preflight \ -H "Authorization: Bearer $DOMAINCANARY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "example.com", "record": "v=spf1 include:_spf.google.com -all"}'

Leave out the Authorization header for a keyless check. Fail the job on any response that isn't a 200 with a verdict field, the same way the action does.

The traffic check reads the DMARC reports your domain already receives, once they arrive in a DomainCanary account. Our weekly digest reads those same reports and mails you which senders passed and which failed, free for your first domain.

Diff the change before you publish it

Paste the record you are about to publish and see what it drops.

Free · No signup · The result names what to change

Questions

Does the action read my octoDNS or DNSControl files?

No. It takes the proposed record as text. A step before it prints the record from your DNS tool, and the recipes on this page show that step for octoDNS, DNSControl, Terraform and a plain file.

Is it safe to use the API key in a public repository?

Store it as a repository secret, and GitHub keeps it out of logs and out of pull requests from forks. The key belongs to your whole account. If it ever leaks, revoke it on your account page and create another one.

What does the check cost?

Nothing. The action works without a key, and API keys come with every plan, including the free plan. Each account gets 20 checks in any 10 minutes and 100 a day.

DomainCanary is a DMARC enforcement service that protects your domain from email spoofing without blocking your own mail.