Contract Testing in CI/CD: Pact with GitHub Actions and Jenkins

Contract Testing in CI/CD: Pact with GitHub Actions and Jenkins

Contract tests are only valuable if they run automatically and block bad deployments. A pact that exists locally but isn't in your CI pipeline is theater. This guide shows you how to wire Pact into GitHub Actions and Jenkins so that contract verification is a real deployment gate, not an afterthought.

The CI/CD Contract Testing Workflow

The complete workflow across consumer and provider pipelines:

Consumer CI:
1. Run consumer pact tests → generate pact files
2. Publish pacts to PactFlow with git SHA + branch
3. [PactFlow webhook triggers provider verification]
4. Before deploy: can-i-deploy --to-environment staging
5. Deploy
6. record-deployment --environment staging
7. Before prod deploy: can-i-deploy --to-environment production
8. Deploy to production
9. record-deployment --environment production

Provider CI:
1. Run unit tests
2. Run provider pact verification (fetches pacts from broker)
3. Publish verification results to PactFlow
4. Before deploy: can-i-deploy --to-environment staging
5. Deploy
6. record-deployment --environment staging

The consumer and provider pipelines run independently. The pact broker is the coordination point.

GitHub Actions: Complete Consumer Pipeline

# .github/workflows/consumer.yml
name: Frontend Consumer CI

on:
  push:
    branches: ['**']
  pull_request:

env:
  PACTFLOW_URL: ${{ secrets.PACTFLOW_URL }}
  PACTFLOW_API_TOKEN: ${{ secrets.PACTFLOW_API_TOKEN }}

jobs:
  test:
    name: Unit & Contract Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm test -- --testPathIgnorePatterns=pact

      - name: Run consumer pact tests
        run: npm test -- --testPathPattern=pact
        # Generates ./pacts/*.json

      - name: Publish pacts to PactFlow
        run: |
          npx pact-broker publish ./pacts \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN \
            --consumer-app-version ${{ github.sha }} \
            --branch ${{ github.ref_name }}

  can-i-deploy-staging:
    name: Can I Deploy  Staging
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install pact-broker CLI
        run: npm install -g @pact-foundation/pact-node

      - name: Check can-i-deploy
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant frontend \
            --version ${{ github.sha }} \
            --to-environment staging \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

  deploy-staging:
    name: Deploy to Staging
    needs: can-i-deploy-staging
    runs-on: ubuntu-latest
    environment: staging

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        run: ./scripts/deploy.sh staging
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

      - name: Record deployment
        run: |
          npx pact-broker record-deployment \
            --pacticipant frontend \
            --version ${{ github.sha }} \
            --environment staging \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

  can-i-deploy-production:
    name: Can I Deploy  Production
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install pact-broker CLI
        run: npm install -g @pact-foundation/pact-node

      - name: Check can-i-deploy
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant frontend \
            --version ${{ github.sha }} \
            --to-environment production \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

  deploy-production:
    name: Deploy to Production
    needs: can-i-deploy-production
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        run: ./scripts/deploy.sh production

      - name: Record deployment
        run: |
          npx pact-broker record-deployment \
            --pacticipant frontend \
            --version ${{ github.sha }} \
            --environment production \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

GitHub Actions: Complete Provider Pipeline

# .github/workflows/provider.yml
name: User Service Provider CI

on:
  push:
    branches: ['**']
  repository_dispatch:
    types: [pact-verification]  # triggered by PactFlow webhook

env:
  PACTFLOW_URL: ${{ secrets.PACTFLOW_URL }}
  PACTFLOW_API_TOKEN: ${{ secrets.PACTFLOW_API_TOKEN }}

jobs:
  verify-pacts:
    name: Provider Pact Verification
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run database migrations
        run: npm run db:migrate
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb

      - name: Run provider verification
        run: npm test -- --testPathPattern=provider.pact
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb
          PACTFLOW_URL: ${{ env.PACTFLOW_URL }}
          PACTFLOW_API_TOKEN: ${{ env.PACTFLOW_API_TOKEN }}
          GIT_SHA: ${{ github.sha }}
          BRANCH_NAME: ${{ github.ref_name }}

  can-i-deploy-staging:
    name: Can I Deploy  Staging
    needs: verify-pacts
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Check can-i-deploy
        run: |
          npx pact-broker can-i-deploy \
            --pacticipant user-service \
            --version ${{ github.sha }} \
            --to-environment staging \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

  deploy-staging:
    needs: can-i-deploy-staging
    runs-on: ubuntu-latest
    environment: staging

    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        run: ./scripts/deploy.sh staging
      - name: Record deployment
        run: |
          npx pact-broker record-deployment \
            --pacticipant user-service \
            --version ${{ github.sha }} \
            --environment staging \
            --broker-base-url $PACTFLOW_URL \
            --broker-token $PACTFLOW_API_TOKEN

Jenkins: Declarative Pipeline

For teams on Jenkins, here's the equivalent pipeline using declarative syntax:

// Jenkinsfile (consumer)
pipeline {
    agent any

    environment {
        PACTFLOW_URL = credentials('pactflow-url')
        PACTFLOW_API_TOKEN = credentials('pactflow-token')
        GIT_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
        BRANCH_NAME = env.GIT_BRANCH?.replaceFirst('origin/', '')
    }

    stages {
        stage('Install') {
            steps {
                sh 'npm ci'
            }
        }

        stage('Unit Tests') {
            steps {
                sh 'npm test -- --testPathIgnorePatterns=pact'
            }
        }

        stage('Consumer Pact Tests') {
            steps {
                sh 'npm test -- --testPathPattern=pact'
            }
            post {
                always {
                    junit 'test-results/*.xml'
                }
            }
        }

        stage('Publish Pacts') {
            steps {
                sh """
                    npx pact-broker publish ./pacts \
                      --broker-base-url ${PACTFLOW_URL} \
                      --broker-token ${PACTFLOW_API_TOKEN} \
                      --consumer-app-version ${GIT_SHA} \
                      --branch ${BRANCH_NAME}
                """
            }
        }

        stage('Can I Deploy — Staging') {
            when { branch 'main' }
            steps {
                sh """
                    npx pact-broker can-i-deploy \
                      --pacticipant frontend \
                      --version ${GIT_SHA} \
                      --to-environment staging \
                      --broker-base-url ${PACTFLOW_URL} \
                      --broker-token ${PACTFLOW_API_TOKEN}
                """
            }
        }

        stage('Deploy Staging') {
            when { branch 'main' }
            steps {
                sh './scripts/deploy.sh staging'
                sh """
                    npx pact-broker record-deployment \
                      --pacticipant frontend \
                      --version ${GIT_SHA} \
                      --environment staging \
                      --broker-base-url ${PACTFLOW_URL} \
                      --broker-token ${PACTFLOW_API_TOKEN}
                """
            }
        }

        stage('Can I Deploy — Production') {
            when { branch 'main' }
            input {
                message 'Deploy to production?'
                ok 'Yes'
            }
            steps {
                sh """
                    npx pact-broker can-i-deploy \
                      --pacticipant frontend \
                      --version ${GIT_SHA} \
                      --to-environment production \
                      --broker-base-url ${PACTFLOW_URL} \
                      --broker-token ${PACTFLOW_API_TOKEN}
                """
            }
        }

        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sh './scripts/deploy.sh production'
                sh """
                    npx pact-broker record-deployment \
                      --pacticipant frontend \
                      --version ${GIT_SHA} \
                      --environment production \
                      --broker-base-url ${PACTFLOW_URL} \
                      --broker-token ${PACTFLOW_API_TOKEN}
                """
            }
        }
    }
}

Bi-Directional Contract Testing

Standard Pact requires both teams to use pact-js. Bi-directional contract testing (BDCT) is a PactFlow feature that supports OpenAPI-based providers.

With BDCT:

  • Consumer: still writes Pact consumer tests
  • Provider: uploads an OpenAPI specification instead of running verification code

PactFlow compares the consumer's pact against the provider's OpenAPI spec to determine compatibility.

Consumer side: same as before — run tests, publish pacts.

Provider side: upload the OpenAPI spec:

npx pact-broker publish-provider-contract \
  openapi.yaml \
  --provider user-service \
  --provider-app-version $GIT_SHA \
  --branch <span class="hljs-variable">$BRANCH_NAME \
  --content-type application/yaml \
  --verification-success \
  --broker-base-url <span class="hljs-variable">$PACTFLOW_URL \
  --broker-token <span class="hljs-variable">$PACTFLOW_API_TOKEN

BDCT is useful when the provider team:

  • Uses a language without a mature Pact provider library
  • Already maintains an OpenAPI spec (no additional testing effort)
  • Has a strict API governance process centered on OpenAPI

Common CI Pitfalls

can-i-deploy times out: The command waits for in-progress verifications. Add --retry-while-unknown 12 --retry-interval 10 to retry for 2 minutes if verification results are pending.

Verification runs with no pacts to verify: If no consumer has published a pact, the provider verification passes trivially. Check consumerVersionSelectors — if you have no deployed consumers yet, use { branch: 'main' } instead of { deployedOrReleased: true }.

Different git SHA formats: Make sure consumer and provider pipelines use the same SHA format. github.sha is the full 40-character SHA; some deployment tools use short SHAs. Be consistent.

Missing record-deployment step: Without this, PactFlow doesn't know what's in each environment. can-i-deploy --to-environment production will fail or give wrong results. Make record-deployment the last step of every deploy job, even if the deploy fails (wrap it in post { always {} } in Jenkins or use if: always() in GitHub Actions).

Read more