Part 19 of 24

CI/CD Integration: GitHub Actions Pipeline for Database Deployments

The Liquibase commands covered in Articles 5 and 16 become reliable only when they run automatically on every change. A developer who remembers to run futureRollbackSQL before merging is better than one who doesn’t — but a pipeline that enforces it is better than both.

This article builds a complete GitHub Actions pipeline: PR validation gates that block merges when rollback is missing, a staging deployment workflow, and a production deployment workflow with mandatory tagging and pre-generated rollback files.


The setup-liquibase Action

Liquibase’s current GitHub Actions approach is a single setup-liquibase action that installs Liquibase and adds it to PATH, then lets you run any Liquibase command as a standard run step. This replaced 50+ individual command actions in Liquibase 4.x and is the only supported approach for Liquibase 5.x.

- uses: liquibase/setup-liquibase@v1
  with:
    version: "4.33.0"    # Liquibase version
    # edition: "pro"     # Uncomment for Liquibase Pro

After this step, liquibase is available in all subsequent run steps.


Workflow 1: PR Validation Gates

This workflow runs on every PR that touches migration files. It enforces the gates from Article 16 before merge is allowed.

# .github/workflows/db-pr-validation.yml
name: Database Migration Validation

on:
  pull_request:
    paths:
      - 'src/main/resources/db/changelog/**'
      - 'liquibase.properties'

concurrency:
  group: db-pr-${{ github.head_ref }}
  cancel-in-progress: true

jobs:
  validate-migrations:
    name: Validate Migrations
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: ecommerce_ci
          MYSQL_USER: lb_user
          MYSQL_PASSWORD: lb_pass
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping -h 127.0.0.1 -u root --password=root"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=10          

    env:
      LB_URL: jdbc:mysql://127.0.0.1:3306/ecommerce_ci?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
      LB_USERNAME: lb_user
      LB_PASSWORD: lb_pass
      LB_CHANGELOG: src/main/resources/db/changelog/db.changelog-master.yaml

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - uses: liquibase/setup-liquibase@v1
        with:
          version: "4.33.0"

      # Gate 1: Syntax and checksum validation
      - name: Gate 1 — Validate changelog
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            validate          

      # Gate 2: Show pending changesets
      - name: Gate 2 — Status check
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            status --verbose          

      # Gate 3: Preview forward SQL (human-readable in PR logs)
      - name: Gate 3 — Preview forward SQL
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            update-sql | tee /tmp/forward.sql
          echo "### Forward SQL Preview" >> $GITHUB_STEP_SUMMARY
          echo '```sql' >> $GITHUB_STEP_SUMMARY
          cat /tmp/forward.sql >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY          

      # Gate 4: Validate rollback exists for ALL pending changesets
      - name: Gate 4 — Validate rollback (BLOCKING)
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            future-rollback-sql | tee /tmp/rollback.sql
          echo "### Rollback SQL Preview" >> $GITHUB_STEP_SUMMARY
          echo '```sql' >> $GITHUB_STEP_SUMMARY
          cat /tmp/rollback.sql >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY          

      # Gate 5: Round-trip test — apply, rollback, re-apply
      - name: Gate 5 — Rollback round-trip test (BLOCKING)
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            update-testing-rollback          

      # Gate 6: Final clean apply
      - name: Gate 6 — Apply migrations cleanly
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            update          

      # Gate 7: Verify 0 pending after apply
      - name: Gate 7 — Confirm 0 pending changesets
        run: |
          RESULT=$(liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file="${LB_CHANGELOG}" \
            status 2>&1)
          echo "$RESULT"
          if echo "$RESULT" | grep -q "changesets have not been applied"; then
            PENDING=$(echo "$RESULT" | grep "changesets have not been applied" | awk '{print $1}')
            echo "ERROR: $PENDING changeset(s) still pending after update"
            exit 1
          fi
          echo "✓ 0 pending changesets"          

Key points:

  • Gates 4 and 5 are blocking — they fail the PR if rollback is missing or broken
  • Gate 3 and 4 write SQL previews to the GitHub Step Summary — visible directly in the Actions UI without downloading artefacts
  • The MySQL service container uses 127.0.0.1 not localhost to avoid socket vs TCP connection issues
  • concurrency cancels stale runs when new commits are pushed to the same PR branch

Workflow 2: Staging Deployment

Triggers on merge to main (or your integration branch). Deploys to the staging database with automatic tagging.

# .github/workflows/db-staging-deploy.yml
name: Database — Deploy to Staging

on:
  push:
    branches: [main]
    paths:
      - 'src/main/resources/db/changelog/**'

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    environment: staging    # Uses GitHub Environments for approval/secrets

    env:
      APP_VERSION: ${{ github.sha }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - uses: liquibase/setup-liquibase@v1
        with:
          version: "4.33.0"

      - name: Validate changelog
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=staging,default \
            validate          

      - name: Check pending changesets
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=staging,default \
            status          

      - name: Generate rollback SQL (archive as artefact)
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=staging,default \
            future-rollback-sql > rollback-staging-${APP_VERSION}.sql          

      - name: Archive rollback SQL
        uses: actions/upload-artifact@v4
        with:
          name: rollback-staging-${{ github.sha }}
          path: rollback-staging-${{ github.sha }}.sql
          retention-days: 30

      - name: Tag before deployment
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            tag staging-${APP_VERSION}          

      - name: Deploy migrations
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=staging,default \
            update          

      - name: Verify deployment
        env:
          LB_URL: ${{ secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            history
          echo "✓ Staging deployment complete: staging-${APP_VERSION}"          

Workflow 3: Production Deployment

Triggers on a release tag push (v*.*.*). Uses GitHub Environments with required reviewers for the production deployment approval gate.

# .github/workflows/db-production-deploy.yml
name: Database — Deploy to Production

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'    # Triggers on v1.2.0, v2.0.0, etc.

jobs:
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    environment: production          # Requires human approval via GitHub Environments

    env:
      APP_VERSION: ${{ github.ref_name }}    # e.g., v1.2.0

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - uses: liquibase/setup-liquibase@v1
        with:
          version: "4.33.0"

      # Pre-deployment: validate + preview
      - name: Validate changelog
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=prod,default \
            validate          

      - name: Preview forward SQL
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=prod,default \
            update-sql | tee forward-prod-${APP_VERSION}.sql          

      - name: Generate rollback SQL
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=prod,default \
            future-rollback-sql | tee rollback-prod-${APP_VERSION}.sql          

      - name: Archive SQL files
        uses: actions/upload-artifact@v4
        with:
          name: db-deployment-${{ env.APP_VERSION }}
          path: |
            forward-prod-${{ env.APP_VERSION }}.sql
            rollback-prod-${{ env.APP_VERSION }}.sql            
          retention-days: 90    # Keep production deployment artefacts 90 days

      - name: Take pre-deployment snapshot
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            snapshot \
            --snapshot-format=json \
            --output-file=snapshot-pre-${APP_VERSION}.json          

      - name: Archive pre-deployment snapshot
        uses: actions/upload-artifact@v4
        with:
          name: db-snapshot-pre-${{ env.APP_VERSION }}
          path: snapshot-pre-${{ env.APP_VERSION }}.json
          retention-days: 90

      # Deployment
      - name: Tag before deployment (mandatory)
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            tag ${APP_VERSION}          

      - name: Deploy to production
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            --contexts=prod,default \
            update          

      # Post-deployment
      - name: Verify deployment
        env:
          LB_URL: ${{ secrets.PROD_DB_URL }}
          LB_USERNAME: ${{ secrets.PROD_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ secrets.PROD_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            history
          echo "✓ Production deployment complete: ${APP_VERSION}"          

      - name: Post deployment summary
        run: |
          echo "## Production DB Deployment: ${APP_VERSION}" >> $GITHUB_STEP_SUMMARY
          echo "- Tag: \`${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY
          echo "- Rollback command: \`liquibase rollback --tag=${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY
          echo "- Rollback SQL: download artefact \`db-deployment-${APP_VERSION}\`" >> $GITHUB_STEP_SUMMARY          

Workflow 4: Rollback (Manual Trigger)

A workflow that performs rollback when triggered manually from the GitHub Actions UI:

# .github/workflows/db-rollback.yml
name: Database — Rollback

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options: [staging, production]
      rollback_tag:
        description: 'Tag to roll back to (e.g., v1.1.0)'
        required: true
        type: string

jobs:
  rollback:
    name: Rollback ${{ inputs.environment }} to ${{ inputs.rollback_tag }}
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - uses: liquibase/setup-liquibase@v1
        with:
          version: "4.33.0"

      - name: Preview rollback SQL
        env:
          LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            rollback-sql --tag=${{ inputs.rollback_tag }}          

      - name: Execute rollback
        env:
          LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            rollback --tag=${{ inputs.rollback_tag }}          

      - name: Verify rollback
        env:
          LB_URL: ${{ inputs.environment == 'production' && secrets.PROD_DB_URL || secrets.STAGING_DB_URL }}
          LB_USERNAME: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_USER || secrets.STAGING_DB_MIGRATION_USER }}
          LB_PASSWORD: ${{ inputs.environment == 'production' && secrets.PROD_DB_MIGRATION_PASSWORD || secrets.STAGING_DB_MIGRATION_PASSWORD }}
        run: |
          liquibase \
            --url="${LB_URL}" \
            --username="${LB_USERNAME}" \
            --password="${LB_PASSWORD}" \
            --changelog-file=src/main/resources/db/changelog/db.changelog-master.yaml \
            status
          echo "✓ Rollback to ${{ inputs.rollback_tag }} complete"          

GitHub Secrets Configuration

Repository Settings → Secrets and variables → Actions

Staging:
  STAGING_DB_URL                  jdbc:mysql://staging-host:3306/ecommerce?...
  STAGING_DB_MIGRATION_USER       lb_migration_user
  STAGING_DB_MIGRATION_PASSWORD   ***

Production:
  PROD_DB_URL                     jdbc:mysql://prod-host:3306/ecommerce?...
  PROD_DB_MIGRATION_USER          lb_migration_user
  PROD_DB_MIGRATION_PASSWORD      ***

Use GitHub Environment secrets (not repository secrets) for staging and production — they are only accessible when the workflow targets that environment, and production environments can require human approval before the job runs.


Branch Protection Rules

Configure branch protection on main to require the PR validation workflow:

Repository Settings → Branches → Branch protection rules → main

✓ Require status checks to pass before merging
  Required checks:
    - validate-migrations / validate-migrations

✓ Require branches to be up to date before merging
✓ Dismiss stale pull request approvals when new commits are pushed

With this configuration, a PR that adds a migration without rollback cannot be merged — the CI gate blocks it.


Common Mistakes

Storing DB credentials in workflow files: Connection strings with embedded passwords are visible to anyone with repository access. Always use secrets (${{ secrets.X }}). For production, use GitHub Environment secrets with required reviewers.

Not using concurrency on PR workflows: Without concurrency, pushing multiple commits to a PR branch can queue up many workflow runs that test against the same CI database simultaneously — causing race conditions and false failures. The cancel-in-progress: true setting cancels the stale run when a new commit arrives.

Connecting to MySQL using localhost in service containers: GitHub Actions service containers run in Docker. The localhost hostname resolves to the runner, not the container. Use 127.0.0.1 (which the port-forwarding maps correctly) or the service container’s hostname.


Best Practices

  • Four separate workflows — PR validation, staging deploy, production deploy, rollback — each with a single clear responsibility
  • GitHub Environments for staging and production — enables required reviewer approval before production jobs run
  • Archive rollback SQL as a workflow artefact — the pre-generated rollback file is available in the Actions UI for download during an incident
  • update-sql and future-rollback-sql output to GitHub Step Summary — SQL previews visible in the PR without downloading artefacts
  • Branch protection requiring PR validation checks — enforces that no migration merges without passing all gates

What You’ve Learned

  • liquibase/setup-liquibase@v1 is the current GitHub Actions approach — installs Liquibase and adds to PATH
  • PR validation workflow enforces seven gates before merge: validate, status, forward SQL, rollback SQL, round-trip test, clean apply, 0 pending
  • Staging workflow tags, deploys, and archives rollback SQL automatically on every merge to main
  • Production workflow requires human approval via GitHub Environments before running
  • Rollback workflow is a workflow_dispatch trigger — one click from the Actions UI during an incident
  • GitHub Environments scope secrets to specific environments and enforce approval gates

Next: Article 20 — Testing Migrations: H2 vs Testcontainers vs Real MySQL — how to write integration tests that actually validate your migrations.