ci: add API and database Schema validation workflow #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: API Schema Compatibility Check | |
| # This workflow: | |
| # - Runs on ALL PRs that modify API-related files | |
| # - Always fails if breaking changes are detected | |
| on: | |
| pull_request: | |
| paths: | |
| - 'crates/api_models/**' | |
| - 'crates/openapi/**' | |
| - 'crates/router/**' | |
| - 'api-reference/**' | |
| - 'migrations/**' | |
| - 'v2_migrations/**' | |
| - 'v2_compatible_migrations/**' | |
| - '.github/oasdiff/**' | |
| types: [opened, synchronize, reopened] | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| api-compatibility-check: | |
| name: API & Migration Compatibility Check | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Generate a token | |
| if: ${{ github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }} | |
| id: generate_token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.HYPERSWITCH_BOT_APP_ID }} | |
| private-key: ${{ secrets.HYPERSWITCH_BOT_APP_PRIVATE_KEY }} | |
| - name: Checkout PR from fork | |
| if: ${{ (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) }} | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| - name: Checkout PR with token | |
| if: ${{ (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name) }} | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| token: ${{ steps.generate_token.outputs.token }} | |
| - name: Install Rust | |
| uses: dtolnay/rust-toolchain@master | |
| with: | |
| toolchain: stable 2 weeks ago | |
| - name: Setup Rust cache | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| # Cache key based on Cargo files | |
| key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} | |
| # Cache the target directory | |
| cache-targets: true | |
| # Cache cargo registry | |
| cache-all-crates: true | |
| - name: Install system dependencies | |
| run: sudo apt-get update && sudo apt-get install -y jq | |
| - name: Setup Go for oasdiff | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version: '1.21' | |
| - name: Install oasdiff | |
| run: | | |
| go install github.com/oasdiff/oasdiff@latest | |
| echo "$HOME/go/bin" >> $GITHUB_PATH | |
| - name: Verify oasdiff installation | |
| run: | | |
| export PATH="$HOME/go/bin:$PATH" | |
| oasdiff --version || oasdiff --help | head -5 | |
| - name: Generate current API schemas | |
| env: | |
| CARGO_INCREMENTAL: 0 | |
| RUSTFLAGS: "-C debuginfo=0" | |
| run: | | |
| cargo run -p openapi --features v1 2>&1 | |
| cargo run -p openapi --features v2 2>&1 | |
| if [[ -f "api-reference/v1/openapi_spec_v1.json" ]]; then | |
| if jq empty api-reference/v1/openapi_spec_v1.json 2>/dev/null; then | |
| cp api-reference/v1/openapi_spec_v1.json pr-v1-schema.json | |
| else | |
| echo "{}" > pr-v1-schema.json | |
| fi | |
| else | |
| echo "{}" > pr-v1-schema.json | |
| fi | |
| if [[ -f "api-reference/v2/openapi_spec_v2.json" ]]; then | |
| if jq empty api-reference/v2/openapi_spec_v2.json 2>/dev/null; then | |
| cp api-reference/v2/openapi_spec_v2.json pr-v2-schema.json | |
| else | |
| echo "{}" > pr-v2-schema.json | |
| fi | |
| else | |
| echo "{}" > pr-v2-schema.json | |
| fi | |
| - name: Extract base branch schemas | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| if git show "$BASE_SHA:api-reference/v1/openapi_spec_v1.json" > base-v1-schema.json 2>/dev/null; then | |
| echo "V1 schema extracted from $BASE_REF" | |
| else | |
| echo "{}" > base-v1-schema.json | |
| fi | |
| if git show "$BASE_SHA:api-reference/v2/openapi_spec_v2.json" > base-v2-schema.json 2>/dev/null; then | |
| echo "V2 schema extracted from $BASE_REF" | |
| else | |
| echo "{}" > base-v2-schema.json | |
| fi | |
| - name: Run breaking change detection | |
| id: breaking_changes | |
| run: | | |
| BREAKING_CHANGES=0 | |
| if oasdiff breaking \ | |
| --fail-on ERR \ | |
| --err-ignore .github/oasdiff/.oasdiff-err-ignore.yaml \ | |
| --warn-ignore .github/oasdiff/.oasdiff-warn-ignore.yaml \ | |
| base-v1-schema.json pr-v1-schema.json > v1-breaking-report.txt 2>&1; then | |
| echo "V1 API is backward compatible" > v1-breaking-status.txt | |
| else | |
| echo "Breaking changes detected in V1 API" > v1-breaking-status.txt | |
| BREAKING_CHANGES=$((BREAKING_CHANGES + 1)) | |
| cat v1-breaking-report.txt | |
| fi | |
| if oasdiff breaking \ | |
| --fail-on ERR \ | |
| --err-ignore .github/oasdiff/.oasdiff-err-ignore.yaml \ | |
| --warn-ignore .github/oasdiff/.oasdiff-warn-ignore.yaml \ | |
| base-v2-schema.json pr-v2-schema.json > v2-breaking-report.txt 2>&1; then | |
| echo "V2 API is backward compatible" > v2-breaking-status.txt | |
| else | |
| echo "Breaking changes detected in V2 API" > v2-breaking-status.txt | |
| BREAKING_CHANGES=$((BREAKING_CHANGES + 1)) | |
| cat v2-breaking-report.txt | |
| fi | |
| oasdiff diff base-v1-schema.json pr-v1-schema.json > v1-detailed-diff.txt 2>/dev/null || true | |
| oasdiff diff base-v2-schema.json pr-v2-schema.json > v2-detailed-diff.txt 2>/dev/null || true | |
| echo "breaking_changes=$BREAKING_CHANGES" >> $GITHUB_OUTPUT | |
| echo "total_issues=$BREAKING_CHANGES" >> $GITHUB_OUTPUT | |
| continue-on-error: true | |
| - name: Display API Compatibility Report | |
| if: always() | |
| run: | | |
| echo "==========================================" | |
| echo "API COMPATIBILITY REPORT" | |
| echo "==========================================" | |
| echo "" | |
| echo "V1 API Breaking Changes:" | |
| cat v1-breaking-report.txt 2>/dev/null || echo "No breaking changes detected" | |
| echo "" | |
| echo "V2 API Breaking Changes:" | |
| cat v2-breaking-report.txt 2>/dev/null || echo "No breaking changes detected" | |
| echo "" | |
| echo "V1 API Detailed Diff:" | |
| cat v1-detailed-diff.txt 2>/dev/null || echo "No changes detected" | |
| echo "" | |
| echo "V2 API Detailed Diff:" | |
| cat v2-detailed-diff.txt 2>/dev/null || echo "No changes detected" | |
| echo "==========================================" | |
| - name: Fail if breaking changes detected | |
| if: steps.breaking_changes.outputs.breaking_changes > 0 | |
| run: | | |
| echo "::error::Breaking changes detected in API schema." | |
| echo "::error::Found ${{ steps.breaking_changes.outputs.breaking_changes }} breaking change(s)." | |
| echo "::error::Please review the compatibility report and consider:" | |
| echo "::error::- Creating a new API version (v3)" | |
| echo "::error::- Using backward-compatible changes instead" | |
| echo "::error::- Coordinating with API consumers before deployment" | |
| exit 1 | |
| - name: Upload validation artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: api-validation-artifacts-${{ github.run_number }} | |
| path: | | |
| pr-v1-schema.json | |
| pr-v2-schema.json | |
| base-v1-schema.json | |
| base-v2-schema.json | |
| v1-breaking-report.txt | |
| v2-breaking-report.txt | |
| v1-detailed-diff.txt | |
| v2-detailed-diff.txt | |
| v1-breaking-status.txt | |
| v2-breaking-status.txt | |
| retention-days: 30 | |
| migration-compatibility-check: | |
| name: Migration Breaking Change Detection | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Generate a token | |
| if: ${{ github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }} | |
| id: generate_token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.HYPERSWITCH_BOT_APP_ID }} | |
| private-key: ${{ secrets.HYPERSWITCH_BOT_APP_PRIVATE_KEY }} | |
| - name: Checkout PR from fork | |
| if: ${{ (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) }} | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| - name: Checkout PR with token | |
| if: ${{ (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name) }} | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.head.ref }} | |
| repository: ${{ github.event.pull_request.head.repo.full_name }} | |
| token: ${{ steps.generate_token.outputs.token }} | |
| - name: Fetch base branch | |
| env: | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| run: | | |
| git fetch origin "$BASE_REF:$BASE_REF" | |
| - name: Validate V1 migrations | |
| id: migration_check | |
| run: | | |
| BASE_REF="origin/${{ github.event.pull_request.base.ref }}" | |
| NEW_MIGRATIONS=$(git diff --name-only --diff-filter=AM "$BASE_REF"...HEAD -- "migrations/**/*.sql" 2>/dev/null || echo "") | |
| if [[ -z "$NEW_MIGRATIONS" ]]; then | |
| echo "breaking_changes=0" >> $GITHUB_OUTPUT | |
| echo "warnings=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| BREAKING=0 | |
| WARNINGS=0 | |
| while IFS= read -r file; do | |
| [[ -z "$file" ]] && continue | |
| CONTENT=$(cat "$file") | |
| if echo "$CONTENT" | grep -iE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" >/dev/null; then | |
| echo "BREAKING: $file" | |
| echo "$CONTENT" | grep -inE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" | sed 's/^/ /' | |
| BREAKING=$((BREAKING + 1)) | |
| fi | |
| if echo "$CONTENT" | grep -iE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" >/dev/null; then | |
| echo "WARNING: $file" | |
| echo "$CONTENT" | grep -inE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" | sed 's/^/ /' | |
| WARNINGS=$((WARNINGS + 1)) | |
| fi | |
| done <<< "$NEW_MIGRATIONS" | |
| echo "breaking_changes=$BREAKING" >> $GITHUB_OUTPUT | |
| echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT | |
| continue-on-error: true | |
| - name: Validate V2 migrations | |
| id: v2_migration_check | |
| run: | | |
| if [[ ! -d "v2_migrations" ]]; then | |
| echo "breaking_changes=0" >> $GITHUB_OUTPUT | |
| echo "warnings=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| BASE_REF="origin/${{ github.event.pull_request.base.ref }}" | |
| NEW_MIGRATIONS=$(git diff --name-only --diff-filter=AM "$BASE_REF"...HEAD -- "v2_migrations/**/*.sql" 2>/dev/null || echo "") | |
| if [[ -z "$NEW_MIGRATIONS" ]]; then | |
| echo "breaking_changes=0" >> $GITHUB_OUTPUT | |
| echo "warnings=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| BREAKING=0 | |
| WARNINGS=0 | |
| while IFS= read -r file; do | |
| [[ -z "$file" ]] && continue | |
| CONTENT=$(cat "$file") | |
| if echo "$CONTENT" | grep -iE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" >/dev/null; then | |
| echo "BREAKING: $file" | |
| echo "$CONTENT" | grep -inE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" | sed 's/^/ /' | |
| BREAKING=$((BREAKING + 1)) | |
| fi | |
| if echo "$CONTENT" | grep -iE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" >/dev/null; then | |
| echo "WARNING: $file" | |
| echo "$CONTENT" | grep -inE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" | sed 's/^/ /' | |
| WARNINGS=$((WARNINGS + 1)) | |
| fi | |
| done <<< "$NEW_MIGRATIONS" | |
| echo "breaking_changes=$BREAKING" >> $GITHUB_OUTPUT | |
| echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT | |
| continue-on-error: true | |
| - name: Validate V2 compatible migrations | |
| id: v2_compat_migration_check | |
| run: | | |
| if [[ ! -d "v2_compatible_migrations" ]]; then | |
| echo "breaking_changes=0" >> $GITHUB_OUTPUT | |
| echo "warnings=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| BASE_REF="origin/${{ github.event.pull_request.base.ref }}" | |
| NEW_MIGRATIONS=$(git diff --name-only --diff-filter=AM "$BASE_REF"...HEAD -- "v2_compatible_migrations/**/*.sql" 2>/dev/null || echo "") | |
| if [[ -z "$NEW_MIGRATIONS" ]]; then | |
| echo "breaking_changes=0" >> $GITHUB_OUTPUT | |
| echo "warnings=0" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| BREAKING=0 | |
| WARNINGS=0 | |
| while IFS= read -r file; do | |
| [[ -z "$file" ]] && continue | |
| CONTENT=$(cat "$file") | |
| if echo "$CONTENT" | grep -iE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" >/dev/null; then | |
| echo "BREAKING: $file" | |
| echo "$CONTENT" | grep -inE "(DROP TABLE|DROP COLUMN|DELETE FROM|TRUNCATE)" | sed 's/^/ /' | |
| BREAKING=$((BREAKING + 1)) | |
| fi | |
| if echo "$CONTENT" | grep -iE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" >/dev/null; then | |
| echo "WARNING: $file" | |
| echo "$CONTENT" | grep -inE "(ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|DROP INDEX|DROP CONSTRAINT)" | sed 's/^/ /' | |
| WARNINGS=$((WARNINGS + 1)) | |
| fi | |
| done <<< "$NEW_MIGRATIONS" | |
| echo "breaking_changes=$BREAKING" >> $GITHUB_OUTPUT | |
| echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT | |
| continue-on-error: true | |
| - name: Calculate migration totals | |
| id: migration_report | |
| run: | | |
| TOTAL_BREAKING=$(( | |
| ${MIGRATION_BREAKING:-0} + | |
| ${V2_MIGRATION_BREAKING:-0} + | |
| ${V2_COMPAT_MIGRATION_BREAKING:-0} | |
| )) | |
| TOTAL_WARNINGS=$(( | |
| ${MIGRATION_WARNINGS:-0} + | |
| ${V2_MIGRATION_WARNINGS:-0} + | |
| ${V2_COMPAT_MIGRATION_WARNINGS:-0} | |
| )) | |
| echo "total_breaking=$TOTAL_BREAKING" >> $GITHUB_OUTPUT | |
| echo "total_warnings=$TOTAL_WARNINGS" >> $GITHUB_OUTPUT | |
| if [[ $TOTAL_BREAKING -gt 0 ]]; then | |
| echo "validation_status=BREAKING CHANGES DETECTED" >> $GITHUB_OUTPUT | |
| elif [[ $TOTAL_WARNINGS -gt 0 ]]; then | |
| echo "validation_status=WARNINGS FOUND" >> $GITHUB_OUTPUT | |
| else | |
| echo "validation_status=ALL CHECKS PASSED" >> $GITHUB_OUTPUT | |
| fi | |
| env: | |
| MIGRATION_BREAKING: ${{ steps.migration_check.outputs.breaking_changes }} | |
| MIGRATION_WARNINGS: ${{ steps.migration_check.outputs.warnings }} | |
| V2_MIGRATION_BREAKING: ${{ steps.v2_migration_check.outputs.breaking_changes }} | |
| V2_MIGRATION_WARNINGS: ${{ steps.v2_migration_check.outputs.warnings }} | |
| V2_COMPAT_MIGRATION_BREAKING: ${{ steps.v2_compat_migration_check.outputs.breaking_changes }} | |
| V2_COMPAT_MIGRATION_WARNINGS: ${{ steps.v2_compat_migration_check.outputs.warnings }} | |
| - name: Display Migration Summary | |
| if: always() | |
| run: | | |
| echo "==========================================" | |
| echo "MIGRATION VALIDATION SUMMARY" | |
| echo "==========================================" | |
| echo "V1: Breaking=${{ steps.migration_check.outputs.breaking_changes }} Warnings=${{ steps.migration_check.outputs.warnings }}" | |
| echo "V2: Breaking=${{ steps.v2_migration_check.outputs.breaking_changes }} Warnings=${{ steps.v2_migration_check.outputs.warnings }}" | |
| echo "V2 Compat: Breaking=${{ steps.v2_compat_migration_check.outputs.breaking_changes }} Warnings=${{ steps.v2_compat_migration_check.outputs.warnings }}" | |
| echo "----------------------------------------" | |
| echo "Total: Breaking=${{ steps.migration_report.outputs.total_breaking }} Warnings=${{ steps.migration_report.outputs.total_warnings }}" | |
| echo "Status: ${{ steps.migration_report.outputs.validation_status }}" | |
| echo "==========================================" | |
| - name: Fail if migration breaking changes detected | |
| if: steps.migration_report.outputs.total_breaking != '0' && steps.migration_report.outputs.total_breaking != '' | |
| run: | | |
| echo "::error::Breaking changes detected in database migrations." | |
| echo "::error::Found ${{ steps.migration_report.outputs.total_breaking }} breaking change(s)." | |
| exit 1 |