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 stagingThe 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_TOKENGitHub 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_TOKENJenkins: 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_TOKENBDCT 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).