Contract Testing in CI/CD: Automating Provider Verification

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 fails

Two 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-service

Key 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 --branch flag 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-service

Provider 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: true covers 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:

  1. Run consumer pact tests
  2. Publish pact files to broker with current SHA

Provider CI:

  1. Start real service
  2. Run provider verification against broker (fetch mainBranch: true)
  3. Publish results to broker

Deploy gate:

  1. Run can-i-deploy before any production deploy
  2. Block deploy if any consumer contract fails

That's it. Add webhooks, environment tracking, and branch filtering as you scale.

Read more

Start now free