Contract Testing in CI/CD: Automating Provider Verification
Contract testing only protects you if it runs in CI/CD. A manually run Pact verification that isn't blocking deploys is just documentation. This guide shows how to wire contract testing into a real CI/CD pipeline: consumer tests publish pacts on every build, providers verify on every build, and can-i-deploy checks gate every deployment.
Key Takeaways
The pipeline has three mandatory steps: publish, verify, can-i-deploy. Skipping any of them breaks the safety guarantee. Pacts must be published, verification must run against the real service, and can-i-deploy must gate production deploys.
Consumer CI publishes pacts; provider CI verifies them. These run independently. The consumer doesn't need the provider deployed, and the provider doesn't need the consumer deployed — that's the point.
Verification must run against the latest consumer pacts. If provider CI only verifies the pact that existed when the provider was last updated, new consumer expectations are never checked. Configure verification to fetch from the broker dynamically.
Can-i-deploy must be the last gate before production. Not before staging, but before production. Staging deploys can be allowed to proceed (to test new behavior), but production should require full consumer compatibility.
Pact webhooks trigger provider verification when new pacts are published. Without webhooks, provider verification only runs on provider CI changes. New consumer contracts won't be verified until the provider's next build.
The CI/CD Contract Testing Pipeline
A fully wired contract testing pipeline looks like this:
Consumer CI: Provider CI:
- Run consumer tests - Start real service
- Generate pact files - Run Pact verification
- Publish pacts to broker → - Fetch latest pacts from broker
- Verify each interaction
- Publish verification results
Before any production deploy:
- Can-i-deploy check
- Block if any consumer contract failsTwo independent pipelines, linked by the Pact Broker.
Consumer CI Configuration
GitHub Actions: Consumer Pipeline
# .github/workflows/consumer.yml
name: Consumer CI
on:
push:
branches: [main]
pull_request:
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Pact consumer tests
run: npm run test:pact
# This generates ./pacts/*.json
- name: Publish pacts to broker
if: github.ref == 'refs/heads/main' # Only publish from main
run: |
npx pact-broker publish \
--pact-files-or-dirs ./pacts \
--consumer-app-version="${{ github.sha }}" \
--branch="${{ github.ref_name }}" \
--broker-base-url="${{ secrets.PACT_BROKER_URL }}" \
--broker-token="${{ secrets.PACT_BROKER_TOKEN }}"
- name: Can-I-Deploy consumer to production
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant="${{ env.CONSUMER_NAME }}" \
--version="${{ github.sha }}" \
--to-environment="production" \
--broker-base-url="${{ secrets.PACT_BROKER_URL }}" \
--broker-token="${{ secrets.PACT_BROKER_TOKEN }}"
env:
CONSUMER_NAME: order-serviceKey decisions:
- Publish from main only: Pacts published from every PR branch create noise. Publish from main (or a dedicated publish step on release branches).
- Include branch metadata: The
--branchflag enables Pact Broker's branch-based filtering. - Consumer can-i-deploy: Checks if all providers still satisfy the consumer before it deploys.
Provider CI Configuration
GitHub Actions: Provider Pipeline
# .github/workflows/provider.yml
name: Provider CI
on:
push:
branches: [main]
pull_request:
jobs:
verify-pacts:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start provider service
run: |
NODE_ENV=test npm start &
# Wait for service to be ready
npx wait-on http://localhost:3000/health --timeout 30000
env:
DATABASE_URL: "postgres://postgres:testpass@localhost:5432/testdb"
- name: Run Pact provider verification
run: npm run test:pact:provider
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
PROVIDER_VERSION: ${{ github.sha }}
PROVIDER_BRANCH: ${{ github.ref_name }}
PUBLISH_VERIFICATION_RESULTS: "true"
- name: Can-I-Deploy provider to production
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant="${{ env.PROVIDER_NAME }}" \
--version="${{ github.sha }}" \
--to-environment="production" \
--broker-base-url="${{ secrets.PACT_BROKER_URL }}" \
--broker-token="${{ secrets.PACT_BROKER_TOKEN }}"
env:
PROVIDER_NAME: inventory-serviceProvider Verification Test
// provider.pact.spec.js
const { Verifier } = require("@pact-foundation/pact");
describe("Pact Provider Verification", () => {
it("validates consumer pacts", () => {
const opts = {
provider: "inventory-service",
providerBaseUrl: "http://localhost:3000",
// Fetch from broker (not local files)
pactBrokerUrl: process.env.PACT_BROKER_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Which pacts to verify: published from main branch
consumerVersionSelectors: [
{ mainBranch: true }, // Latest from consumer's main branch
{ deployedOrReleased: true }, // Anything deployed/released to any env
],
// Publish results back to broker
publishVerificationResult: process.env.PUBLISH_VERIFICATION_RESULTS === "true",
providerVersion: process.env.PROVIDER_VERSION,
providerVersionBranch: process.env.PROVIDER_BRANCH,
// State handlers for each "given()" state
stateHandlers: {
"product P001 has 50 units in stock": async () => {
await db.products.upsert({
id: "P001",
name: "Widget",
quantity: 50,
});
},
"product P999 does not exist": async () => {
await db.products.delete({ id: "P999" });
},
},
// Provider-side request filter (add auth headers, etc.)
requestFilter: (req, res, next) => {
req.headers["x-test-run"] = "pact-verification";
next();
},
};
return new Verifier(opts).verifyProvider();
});
});Key configuration:
consumerVersionSelectors: Determines which consumer pacts to verify.mainBranch: true+deployedOrReleased: truecovers all pacts that matter.publishVerificationResult: Publish back to the broker so can-i-deploy has data to work with. Only publish on CI, not local runs.- State handlers: One per
given()state in consumer pacts. Missing state handlers cause verification to fail with confusing errors.
Pact Webhooks: Triggering Provider CI on New Pacts
By default, provider CI only runs when the provider changes. If the consumer publishes a new pact, the provider won't know until its next CI run — which could be days later.
Pact Broker webhooks fix this: when a new pact is published, the broker triggers the provider's CI pipeline immediately.
GitHub Actions Webhook Trigger
# Add this trigger to your provider workflow:
on:
repository_dispatch:
types: [pact-changed]Configuring the Webhook in Pact Broker
# Create webhook via Pact Broker CLI
npx pact-broker create-webhook \
"https://api.github.com/repos/your-org/inventory-service/dispatches" \
--header "Content-Type: application/json" \
--header "Accept: application/vnd.github.v3+json" \
--header "Authorization: token $GITHUB_TOKEN" \
--data '{"event_type":"pact-changed","client_payload":{"pact_url":"${pactbroker.pactUrl}"}}' \
--event "contract_content_changed" \
--broker-base-url="$PACT_BROKER_URL" \
--broker-token="$PACT_BROKER_TOKEN"Now when order-service publishes a new pact against inventory-service, the broker fires the webhook and inventory-service CI runs verification immediately.
Environment Tracking
Record deployments so can-i-deploy knows what's in each environment:
# Add to your deployment pipeline, after a successful deploy
- name: Record deployment in Pact Broker
run: |
npx pact-broker record-deployment \
--pacticipant="inventory-service" \
--version="${{ github.sha }}" \
--environment="production" \
--broker-base-url="${{ secrets.PACT_BROKER_URL }}" \
--broker-token="${{ secrets.PACT_BROKER_TOKEN }}"With deployments recorded, consumerVersionSelectors: [{ deployedOrReleased: true }] in provider verification automatically includes pacts for every consumer version currently deployed — not just the latest.
Common CI/CD Mistakes
Only publishing pacts from CI, not verifying from CI. The consumer CI publishes pacts. If provider CI never runs verification against them, the pacts accumulate in the broker with no verification results. Can-i-deploy returns "unknown" or fails.
Using local pact files in provider verification. pactUrls: ["./pacts/..."] in provider verification defeats the purpose — you're verifying a locally-checked-in contract, not the live consumer contracts. Always fetch from the broker in CI.
Not publishing verification results. publishVerificationResult: true must be set for can-i-deploy to have data. Without published results, can-i-deploy can't determine compatibility.
Skipping can-i-deploy. Can-i-deploy is the enforcement step. If you run verification but don't gate deploys on it, breaking changes still reach production.
Not using consumer version selectors correctly. { latest: true } only verifies the most recent pact from any consumer. { mainBranch: true } is better. { deployedOrReleased: true } ensures you verify against everything in production.
Minimal Viable Pipeline
If you're starting from scratch and want the minimum that actually protects you:
Consumer CI:
- Run consumer pact tests
- Publish pact files to broker with current SHA
Provider CI:
- Start real service
- Run provider verification against broker (fetch
mainBranch: true) - Publish results to broker
Deploy gate:
- Run can-i-deploy before any production deploy
- Block deploy if any consumer contract fails
That's it. Add webhooks, environment tracking, and branch filtering as you scale.