From ad105eb7526839cae07727ac2663ea9d67499a03 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 16 Jun 2026 22:14:44 -0600 Subject: [PATCH 1/9] feat(ci): added ci examples using pgFirstAid --- workflows/README.md | 320 ++++++++++++++ workflows/db-health-checks.yml | 237 +++++++++++ workflows/managed-db-validate.yml | 492 ++++++++++++++++++++++ workflows/pgfirstaid-pr-audit.yml | 80 ++++ workflows/pgfirstaid_audit.py | 328 +++++++++++++++ workflows/pre-post-migration-validate.yml | 380 +++++++++++++++++ 6 files changed, 1837 insertions(+) create mode 100644 workflows/README.md create mode 100644 workflows/db-health-checks.yml create mode 100644 workflows/managed-db-validate.yml create mode 100644 workflows/pgfirstaid-pr-audit.yml create mode 100644 workflows/pgfirstaid_audit.py create mode 100644 workflows/pre-post-migration-validate.yml diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000..7abf24d --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,320 @@ +# pgFirstAid CI/CD Integration Workflows + +This directory contains four non-overlapping GitHub Actions workflows for integrating pgFirstAid into your CI/CD pipeline. Each workflow has a distinct purpose and trigger, covering the full database health lifecycle: **PR feedback → migration safety → scheduled monitoring → cloud validation**. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ pgFirstAid Workflow Suite │ +├──────────────┬──────────────┬──────────────┬────────────────────┤ +│ PR Audit │ Migration │ Health │ Managed DB │ +│ (per PR) │ Safety │ Monitoring │ Validation │ +│ │ (per PR on │ (daily │ (manual, │ +│ │ migrations)│ cron) │ per-provider) │ +├──────────────┼──────────────┼──────────────┼────────────────────┤ +│ Quick dev │ Gate │ Trend │ Cloud-specific │ +│ feedback │ deployments │ tracking │ compatibility │ +└──────────────┴──────────────┴──────────────┴────────────────────┘ +``` + +## Available Workflows + +### 1. `pgfirstaid-pr-audit.yml` — **PR Developer Feedback** + +Posts a pgFirstAid audit summary as a PR comment on every push. Gives developers immediate visibility into database health impact of their changes without leaving the PR. + +**Use Case:** Add to your standard CI — every PR automatically learns about DB health. + +**Triggers:** Pull request (opened, synchronize, reopened) + workflow_dispatch + +**Key Features:** +- Runs pgFirstAid against your staging database +- Posts/updates a single PR comment with severity summary and full findings table +- Fails the job if findings meet the configured threshold (CRITICAL/HIGH/MEDIUM/LOW/NONE) +- Uses a standalone Python script — no postgres client setup required in CI + +**Files to copy into your repo:** + +| File | Destination | +|---|---| +| `pgfirstaid-pr-audit.yml` | `.github/workflows/pgfirstaid-pr-audit.yml` | +| `pgfirstaid_audit.py` | `.github/scripts/pgfirstaid_audit.py` | + +**Setup:** + +1. Copy the files: +```bash +mkdir -p .github/workflows .github/scripts +cp pgfirstaid-pr-audit.yml .github/workflows/ +cp pgfirstaid_audit.py .github/scripts/ +``` + +2. Add the database secret in **Settings → Secrets and variables → Actions**: + +| Secret name | Value | +|---|---| +| `STAGING_DATABASE_URL` | `postgresql://user:password@host:5432/dbname` | + +Any [libpq connection string](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) format works. + +3. Configure the threshold in the workflow's `env` block: +```yaml +env: + PGFIRSTAID_VERSION: v2.1.1 # Pin to a tag for stability + PGFIRSTAID_FAIL_SEVERITY: HIGH # CRITICAL | HIGH | MEDIUM | LOW | NONE +``` + +`NONE` posts results without ever failing the job — useful for teams that want visibility before enforcing a gate. + +4. Database permissions — the database user needs `SELECT` on system catalogs. Most read-only users already have this. If you hit permission errors: +```sql +GRANT pg_monitor TO your_ci_user; +``` +`pg_monitor` is a built-in PostgreSQL role (10+) that covers the catalog views pgFirstAid queries. + +**Network access:** The GitHub-hosted runner needs a route to your staging database. +- **Public staging DB** — works out of the box. Restrict by IP using [GitHub's runner IP ranges](https://api.github.com/meta). +- **VPC / private network** — use [Tailscale GitHub Action](https://github.com/tailscale/github-action) to join the runner to your mesh, or a [self-hosted runner](https://docs.github.com/en/actions/hosting-your-own-runners) inside your network. + +**Manual runs:** The workflow includes `workflow_dispatch`, so you can trigger an audit from **Actions → pgFirstAid Staging Audit → Run workflow**. Without a PR, results print to the job log instead of posting a comment. + +**Example PR comment output:** +``` +### pgFirstAid Audit + +| Severity | Count | +|---|---| +| CRITICAL | 1 | +| HIGH | 2 | +| MEDIUM | 3 | + +
+Full results (6 findings) + +| Severity | Category | Check | Object | Issue | Recommended Action | +... + +
+ +> Job failed — findings at or above `HIGH` threshold were found. +``` + +--- + +### 2. `pre-post-migration-validate.yml` — **Migration Safety Gate** + +Validates database health before and after migrations. This is the **only workflow that gates deployments** — it blocks if migrations introduce new critical issues. + +**Use Case:** Essential for any project with database migrations. Add to your CI to prevent migration regressions. + +**Triggers:** Pull requests changing `migrations/**`, `db/**`, or `pgFirstAid.sql` + workflow_dispatch + +**Key Features:** +- Captures a baseline health snapshot before migrations +- Applies migrations (Flyway, Liquibase, or direct SQL) +- Compares post-migration health against the pre-migration baseline +- Detects new critical/high issues introduced by the migration +- Blocks the deployment pipeline if regressions are found + +**Secrets required:** + +| Secret | Value | +|---|---| +| `PGHOST` | Database hostname | +| `PGUSER` | Database user | +| `PGPASSWORD` | Database password | +| `PGDATABASE` | Database name | + +**Example Integration:** +```yaml +jobs: + validate-migration: + uses: ./.github/workflows/pre-post-migration-validate.yml + with: + environment: staging +``` + +--- + +### 3. `db-health-checks.yml` — **Scheduled Health Monitoring** + +Tracks database health trends over time via daily cron. Generates full reports and compares against previous baselines to detect degradation. + +**Use Case:** Set up daily monitoring to catch slow-burn issues like bloat growth, connection creep, and missing maintenance. + +**Triggers:** Daily schedule (2 AM UTC) + workflow_dispatch + +**Available Jobs:** + +| Job | Description | Trigger | +|-----|-------------|---------| +| `full-health-check` | Complete health report with CSV + JSON export | Schedule or manual | +| `baseline-comparison` | Compare with previous run to detect new issues | Daily schedule | + +**Secrets required:** + +| Secret | Value | +|---|---| +| `PGHOST` | Database hostname | +| `PGUSER` | Database user | +| `PGPASSWORD` | Database password | +| `PGDATABASE` | Database name | + +**Scheduled Daily Health Check:** +```yaml +jobs: + daily-health: + uses: ./.github/workflows/db-health-checks.yml +``` + +--- + +### 4. `managed-db-validate.yml` — **Cloud Compatibility Validation** + +Validates pgFirstAid against a specific cloud-managed PostgreSQL instance (AWS RDS, GCP Cloud SQL, or Azure). + +**Use Case:** Test pgFirstAid compatibility with your managed PostgreSQL provider, or validate cloud-specific configuration. + +**Triggers:** Manual dispatch only (choose provider + instance) + +**Supported Providers:** +- AWS RDS (resolves endpoint via `describe-db-instances`) +- GCP Cloud SQL (resolves via `gcloud sql instances describe`) +- Azure Database for PostgreSQL Flexible Server (resolves via `az postgres flexible-server show`) + +**Secrets required by provider:** + +| Provider | Secrets | +|---|---| +| AWS RDS | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_RDS_USER`, `AWS_RDS_PASSWORD` | +| GCP Cloud SQL | `GCP_PROJECT_ID`, `GCP_CLOUD_SQL_USER`, `GCP_CLOUD_SQL_PASSWORD` | +| Azure | `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `AZURE_POSTGRESQL_USER`, `AZURE_POSTGRESQL_PASSWORD` | + +**Manual Trigger:** +```yaml +# Via GitHub UI: Actions → Managed Database Validation → Run workflow +# Set cloud_provider, db_identifier, region (and resource_group for Azure) +``` + +## Understanding pgFirstAid Output + +The `pg_firstAid()` function returns health issues organized by severity: + +### Severity Levels + +| Severity | Meaning | CI Action | +|----------|---------|-----------| +| `CRITICAL` | Must fix immediately | Block deployment | +| `HIGH` | Should fix soon | Warn, allow deployment | +| `MEDIUM` | Monitor and plan | Log only | +| `LOW` | Nice to have | Informational | +| `INFO` | General information | Informational | + +### Common Health Checks + +| Check Name | Description | +|------------|-------------| +| `Missing Primary Key` | Tables without primary keys | +| `Table Bloat` | Tables with excessive dead tuples | +| `Duplicate Index` | Redundant indexes | +| `Unused Large Index` | Large indexes with low usage | +| `Blocked and Blocking Queries` | Query lock waits | +| `Long Running Queries` | Queries exceeding threshold | + +### Sample Output + +``` + severity | category | check_name | object_name | issue_description | recommended_action +----------+-------------------+----------------------+-------------+-------------------+------------------- + CRITICAL | Structural Health | Missing Primary Key | users | Table missing primary key | Add primary key to users table + HIGH | Structural Health | Missing Statistics | orders | Statistics not updated recently | Run ANALYZE on orders table +``` + +## Integrating with Migration Tools + +### Flyway + +```yaml +- name: Pre-Migration Health Check + run: psql -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" + +- name: Apply Migrations + run: flyway migrate + +- name: Post-Migration Health Check + run: | + psql -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" +``` + +### Liquibase + +```yaml +- name: Pre-Migration Health Check + run: psql -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" + +- name: Apply Migrations + run: mvn liquibase:update + +- name: Post-Migration Health Check + run: psql -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" +``` + +### ArgoCD (Helm with pgFirstAid) + +```yaml +# values.yml +pgFirstAid: + enabled: true + functionFile: pgFirstAid.sql + viewFile: view_pgFirstAid.sql + + healthCheck: + enabled: true + checkInterval: 60s + severityThreshold: critical +``` + +## Troubleshooting + +### pg_firstAid function not found + +Make sure you've installed the function: +```sql +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +\i pgFirstAid.sql +\i view_pgFirstAid.sql +``` + +### Permission errors + +If you don't have superuser access, use the managed view: +```sql +\i view_pgFirstAid_managed.sql +``` + +This version works with limited privileges and doesn't require DROP privileges. + +### Connection timeouts + +For cloud databases, ensure you're using the correct connection parameters: +- AWS RDS: Use the endpoint from `describe-db-instances` +- GCP Cloud SQL: Use the IP from `gcloud sql instances describe` +- Azure: Use the `defaultHostName` from `az postgres flexible-server show` + +## Best Practices + +1. **Run health checks in staging before production** +2. **Block deployments on new CRITICAL issues** +3. **Review HIGH issues weekly** +4. **Track trends over time** (use baseline comparison) +5. **Document any known exceptions** (e.g., tables that intentionally lack primary keys) + +## Security Considerations + +- Store database credentials as secrets, never in workflow files +- Use pgFirstAid's managed view for limited-privilege users +- Regularly rotate database credentials +- Restrict workflow access to authorized personnel only + +## License + +This workflow is provided under the same license as pgFirstAid. diff --git a/workflows/db-health-checks.yml b/workflows/db-health-checks.yml new file mode 100644 index 0000000..c6dc949 --- /dev/null +++ b/workflows/db-health-checks.yml @@ -0,0 +1,237 @@ +# Database Health Check Suite +# +# Scheduled health monitoring — tracking database health trends over time. +# Runs daily via cron and supports manual triggers for ad-hoc checks. + +name: Database Health Check Suite + +on: + schedule: + # Daily at 2 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + check_baseline: + description: 'Run baseline health check' + required: false + default: 'false' + check_current: + description: 'Run current health check' + required: false + default: 'true' + +concurrency: + group: db-health-checks-${{ github.sha }} + +jobs: + full-health-check: + name: Full Health Check + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.PGPASSWORD }} + + - name: Install pgFirstAid + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + + -- Install pgFirstAid function + \i pgFirstAid.sql + \i view_pgFirstAid.sql + \i view_pgFirstAid_managed.sql + EOF + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Run Full Health Check + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" <<'EOF' + -- Snapshot health data once to avoid repeated expensive bloat estimation + CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid(); + + \echo '========================================' + \echo ' DATABASE HEALTH CHECK REPORT' + \echo '========================================' + \echo '' + + \echo '=== CRITICAL Issues (Must Fix) ===' + SELECT severity, check_name, object_name, issue_description, recommended_action, documentation_link + FROM _health_snapshot + WHERE severity = 'CRITICAL' + ORDER BY check_name; + \echo '' + + \echo '=== HIGH Priority Issues (Should Fix) ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _health_snapshot + WHERE severity = 'HIGH' + ORDER BY check_name; + \echo '' + + \echo '=== MEDIUM Priority Issues (Monitor) ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _health_snapshot + WHERE severity = 'MEDIUM' + ORDER BY check_name; + \echo '' + + \echo '=== LOW Priority Issues (Nice to Have) ===' + SELECT severity, check_name, object_name, issue_description + FROM _health_snapshot + WHERE severity = 'LOW' + ORDER BY check_name; + \echo '' + + \echo '=== INFO (General Information) ===' + SELECT severity, check_name, object_name + FROM _health_snapshot + WHERE severity = 'INFO' + ORDER BY check_name; + \echo '' + + \echo '========================================' + \echo ' SUMMARY STATISTICS' + \echo '========================================' + \echo '' + SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects + FROM _health_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + EOF + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Generate CSV Report + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" \ + -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ + -c "SELECT * FROM _health_snapshot ORDER BY severity, check_name;" \ + > "${{ github.workspace }}/full_health_check.csv" + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Generate JSON Report + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" \ + -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ + -c "SELECT json_agg(to_json(d)) FROM _health_snapshot d;" \ + > "${{ github.workspace }}/full_health_check.json" + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Upload Reports + uses: actions/upload-artifact@v4 + if: github.event_name == 'schedule' + with: + name: latest-full-health-check + path: | + ${{ github.workspace }}/full_health_check.csv + ${{ github.workspace }}/full_health_check.json + retention-days: 30 + + baseline-comparison: + name: Baseline Comparison + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + needs: full-health-check + if: github.event_name == 'schedule' + + steps: + - uses: actions/checkout@v4 + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.PGPASSWORD }} + + - name: Install pgFirstAid + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + \i pgFirstAid.sql + \i view_pgFirstAid.sql + EOF + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Download Previous Baseline + uses: actions/download-artifact@v4 + with: + name: latest-full-health-check + path: previous/ + + - name: Download Current Results + run: | + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" --csv \ + -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ + -c "SELECT * FROM _health_snapshot ORDER BY severity, check_name;" \ + > "${{ github.workspace }}/current_health_check.csv" + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Compare Baselines + run: | + echo "=== Baseline Comparison ===" > comparison-report.md + echo "" >> comparison-report.md + echo "Previous artifact from: $(ls -t previous/ | head -1)" >> comparison-report.md + echo "Current snapshot: $(date)" >> comparison-report.md + echo "" >> comparison-report.md + + echo "## Critical Issues Change" >> comparison-report.md + PREV_CRITICAL=$(grep -c '^CRITICAL,' previous/full_health_check.csv 2>/dev/null || echo 0) + CURR_CRITICAL=$(grep -c '^CRITICAL,' "${{ github.workspace }}/current_health_check.csv" 2>/dev/null || echo 0) + echo "Previous: $PREV_CRITICAL | Current: $CURR_CRITICAL" >> comparison-report.md + + if [ "$CURR_CRITICAL" -gt "$PREV_CRITICAL" ]; then + echo "::warning::Critical issues increased by $((CURR_CRITICAL - PREV_CRITICAL))" + fi + + cat comparison-report.md + + - name: Upload Comparison Report + uses: actions/upload-artifact@v4 + with: + name: baseline-comparison-${{ github.run_id }} + path: comparison-report.md + retention-days: 7 diff --git a/workflows/managed-db-validate.yml b/workflows/managed-db-validate.yml new file mode 100644 index 0000000..d06f9d4 --- /dev/null +++ b/workflows/managed-db-validate.yml @@ -0,0 +1,492 @@ +# Managed Database Validation Workflow +# +# Validates pgFirstAid against cloud-managed PostgreSQL databases (AWS RDS, GCP Cloud SQL, Azure). +# Triggered manually to test compatibility with each cloud provider. +# +# This workflow is NOT for: +# - Scheduled health monitoring (see db-health-checks.yml) +# - Migration safety validation (see pre-post-migration-validate.yml) +# - PR feedback (see pgfirstaid-pr-audit.yml) + +name: Managed Database Validation + +on: + workflow_dispatch: + inputs: + cloud_provider: + description: 'Cloud provider to validate against' + required: true + type: choice + options: + - aws + - gcp + - azure + db_identifier: + description: 'Database identifier (RDS instance ID, Cloud SQL name, Azure server name)' + required: true + region: + description: 'Cloud region' + required: true + default: 'us-east-1' + resource_group: + description: 'Azure resource group (required for Azure)' + required: false + default: '' + check_type: + description: 'Type of validation to run' + required: false + type: choice + options: + - full + - critical-only + - regression + default: 'full' + +concurrency: + group: managed-db-${{ inputs.cloud_provider }}-${{ github.run_id }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Job 1: AWS RDS Validation + validate-aws-rds: + name: AWS RDS Validation + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.cloud_provider == 'aws' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.region }} + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.AWS_RDS_PASSWORD }} + + - name: Resolve RDS Endpoint + id: rds-endpoint + run: | + # Get RDS endpoint from instance identifier + RESOLVED_HOST=$(aws rds describe-db-instances \ + --db-instance-identifier "${{ inputs.db_identifier }}" \ + --query 'DBInstances[0].Endpoint.Address' \ + --output text) + + if [ -z "$RESOLVED_HOST" ]; then + echo "::error::Could not resolve RDS instance ${{ inputs.db_identifier }}" + exit 1 + fi + + echo "host=$RESOLVED_HOST" >> $GITHUB_OUTPUT + echo "instance=$RESOLVED_HOST" + + # Get instance details + echo "Instance Details:" + aws rds describe-db-instances \ + --db-instance-identifier "${{ inputs.db_identifier }}" \ + --query 'DBInstances[0].[DBInstanceStatus,EngineVersion,MultiAZ,AutomatedBackupMinutes]' \ + --output table + env: + AWS_REGION: ${{ inputs.region }} + + - name: Install pgFirstAid on RDS + run: | + psql -h ${{ steps.rds-endpoint.outputs.host }} \ + -U ${{ secrets.AWS_RDS_USER }} \ + -d postgres <<'SQL' + -- Check for pg_stat_statements extension + SELECT name FROM pg_extension WHERE extname = 'pg_stat_statements'; + + -- Install if not present + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + + -- Install pgFirstAid + \i pgFirstAid.sql + \i view_pgFirstAid_managed.sql + + -- Verify installation + SELECT pg_firstAid(); + SQL + env: + PGPASSWORD: ${{ secrets.AWS_RDS_PASSWORD }} + + - name: Run Health Check on RDS + run: | + psql -h ${{ steps.rds-endpoint.outputs.host }} \ + -U ${{ secrets.AWS_RDS_USER }} \ + -d postgres <<'SQL' + -- Capture snapshot to avoid redundant function calls + CREATE TEMP TABLE _snap AS SELECT * FROM pg_firstAid(); + + \echo '=== AWS RDS Health Check ===' + \echo 'Instance: ${{ inputs.db_identifier }}' + \echo 'Region: ${{ inputs.region }}' + \echo '' + + -- Show summary + SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects + FROM _snap + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + ELSE 4 + END; + \echo '' + + -- Show critical issues (RDS-specific considerations) + \echo '=== RDS-Specific Critical Issues ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _snap + WHERE severity = 'CRITICAL' + ORDER BY check_name; + \echo '' + + -- Show high priority issues + \echo '=== High Priority Issues ===' + SELECT severity, check_name, object_name, issue_description + FROM _snap + WHERE severity = 'HIGH' + ORDER BY check_name; + \echo '' + SQL + env: + PGPASSWORD: ${{ secrets.AWS_RDS_PASSWORD }} + + - name: Generate CSV Report + run: | + psql -h ${{ steps.rds-endpoint.outputs.host }} \ + -U ${{ secrets.AWS_RDS_USER }} \ + -d postgres \ + -c "\copy (SELECT * FROM pg_firstAid() ORDER BY severity, check_name) TO '${{ github.workspace }}/rds_health_check.csv' CSV HEADER" + env: + PGPASSWORD: ${{ secrets.AWS_RDS_PASSWORD }} + + - name: Post job summary + if: always() + run: | + { + echo "## ✅ AWS RDS Validation Complete" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Instance | ${{ inputs.db_identifier }} |" + echo "| Region | ${{ inputs.region }} |" + echo "| Report | \`rds_health_check.csv\` |" + } >> $GITHUB_STEP_SUMMARY + + - name: Upload AWS RDS Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: aws-rds-report-${{ inputs.db_identifier }} + path: ${{ github.workspace }}/rds_health_check.csv + retention-days: 7 + + # Job 2: GCP Cloud SQL Validation + validate-gcp-cloud-sql: + name: GCP Cloud SQL Validation + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.cloud_provider == 'gcp' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: ${{ secrets.GCP_PROJECT_ID }} + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.GCP_CLOUD_SQL_PASSWORD }} + + - name: Resolve Cloud SQL Endpoint + id: cloudsql-endpoint + run: | + # Get Cloud SQL endpoint + RESOLVED_HOST=$(gcloud sql instances describe "${{ inputs.db_identifier }}" \ + --project="${{ secrets.GCP_PROJECT_ID }}" \ + --format='value(ipAddresses[0].ipAddress)') + + if [ -z "$RESOLVED_HOST" ]; then + echo "::error::Could not resolve Cloud SQL instance ${{ inputs.db_identifier }}" + exit 1 + fi + + echo "host=$RESOLVED_HOST" >> $GITHUB_OUTPUT + echo "instance=$RESOLVED_HOST" + + # Get instance details + echo "Instance Details:" + gcloud sql instances describe "${{ inputs.db_identifier }}" \ + --project="${{ secrets.GCP_PROJECT_ID }}" \ + --format='table(name,state,backendType,diskSizeGb)' + env: + CLOUDSDK_CORE_PROJECT: ${{ secrets.GCP_PROJECT_ID }} + + - name: Install pgFirstAid on Cloud SQL + run: | + psql -h ${{ steps.cloudsql-endpoint.outputs.host }} \ + -U ${{ secrets.GCP_CLOUD_SQL_USER }} \ + -d postgres <<'SQL' + -- Check for pg_stat_statements extension + SELECT name FROM pg_extension WHERE extname = 'pg_stat_statements'; + + -- Install if not present + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + + -- Install pgFirstAid managed view + \i pgFirstAid.sql + \i view_pgFirstAid_managed.sql + + -- Verify installation + SELECT pg_firstAid(); + SQL + env: + PGPASSWORD: ${{ secrets.GCP_CLOUD_SQL_PASSWORD }} + + - name: Run Health Check on Cloud SQL + run: | + psql -h ${{ steps.cloudsql-endpoint.outputs.host }} \ + -U ${{ secrets.GCP_CLOUD_SQL_USER }} \ + -d postgres <<'SQL' + -- Capture snapshot to avoid redundant function calls + CREATE TEMP TABLE _snap AS SELECT * FROM pg_firstAid(); + + \echo '=== GCP Cloud SQL Health Check ===' + \echo 'Instance: ${{ inputs.db_identifier }}' + \echo 'Project: ${{ secrets.GCP_PROJECT_ID }}' + \echo '' + + -- Show summary + SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects + FROM _snap + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + ELSE 4 + END; + \echo '' + + -- Show all issues + \echo '=== All Issues ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _snap + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + ELSE 4 + END, + check_name; + \echo '' + SQL + env: + PGPASSWORD: ${{ secrets.GCP_CLOUD_SQL_PASSWORD }} + + - name: Generate CSV Report + run: | + psql -h ${{ steps.cloudsql-endpoint.outputs.host }} \ + -U ${{ secrets.GCP_CLOUD_SQL_USER }} \ + -d postgres \ + -c "\copy (SELECT * FROM pg_firstAid() ORDER BY severity, check_name) TO '${{ github.workspace }}/cloudsql_health_check.csv' CSV HEADER" + env: + PGPASSWORD: ${{ secrets.GCP_CLOUD_SQL_PASSWORD }} + + - name: Post job summary + if: always() + run: | + { + echo "## ✅ GCP Cloud SQL Validation Complete" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Instance | ${{ inputs.db_identifier }} |" + echo "| Project | ${{ secrets.GCP_PROJECT_ID }} |" + echo "| Report | \`cloudsql_health_check.csv\` |" + } >> $GITHUB_STEP_SUMMARY + + - name: Upload GCP Cloud SQL Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: gcp-cloudsql-report-${{ inputs.db_identifier }} + path: ${{ github.workspace }}/cloudsql_health_check.csv + retention-days: 7 + + # Job 3: Azure Database for PostgreSQL Validation + validate-azure-postgresql: + name: Azure Database for PostgreSQL Validation + runs-on: ubuntu-latest + timeout-minutes: 15 + if: inputs.cloud_provider == 'azure' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Azure credentials + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.AZURE_POSTGRESQL_PASSWORD }} + + - name: Resolve Azure PostgreSQL Endpoint + id: azure-endpoint + run: | + # Build resource group flag conditionally + RG_FLAG="" + if [ -n "${{ inputs.resource_group }}" ]; then + RG_FLAG="--resource-group ${{ inputs.resource_group }}" + fi + + # Get Azure PostgreSQL Flexible Server endpoint + RESOLVED_HOST=$(az postgres flexible-server show \ + --name "${{ inputs.db_identifier }}" \ + $RG_FLAG \ + --query 'properties.defaultHostName' \ + --output tsv) + + if [ -z "$RESOLVED_HOST" ]; then + echo "::error::Could not resolve Azure PostgreSQL server ${{ inputs.db_identifier }}" + exit 1 + fi + + echo "host=$RESOLVED_HOST" >> $GITHUB_OUTPUT + echo "server=$RESOLVED_HOST" + + # Get server details + echo "Server Details:" + az postgres flexible-server show \ + --name "${{ inputs.db_identifier }}" \ + $RG_FLAG \ + --query 'properties.{sku:sku.name,version:version,state:state}' \ + --output table + env: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Install pgFirstAid on Azure PostgreSQL + run: | + psql -h ${{ steps.azure-endpoint.outputs.host }} \ + -U ${{ secrets.AZURE_POSTGRESQL_USER }} \ + -d postgres <<'SQL' + -- Check for pg_stat_statements extension + SELECT name FROM pg_extension WHERE extname = 'pg_stat_statements'; + + -- Install if not present + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + + -- Install pgFirstAid + \i pgFirstAid.sql + \i view_pgFirstAid_managed.sql + + -- Verify installation + SELECT pg_firstAid(); + SQL + env: + PGPASSWORD: ${{ secrets.AZURE_POSTGRESQL_PASSWORD }} + + - name: Run Health Check on Azure PostgreSQL + run: | + psql -h ${{ steps.azure-endpoint.outputs.host }} \ + -U ${{ secrets.AZURE_POSTGRESQL_USER }} \ + -d postgres <<'SQL' + -- Capture snapshot to avoid redundant function calls + CREATE TEMP TABLE _snap AS SELECT * FROM pg_firstAid(); + + \echo '=== Azure Database for PostgreSQL Health Check ===' + \echo 'Server: ${{ inputs.db_identifier }}' + \echo 'Resource Group: ${{ inputs.resource_group }}' + \echo '' + + -- Show summary + SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects + FROM _snap + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + ELSE 4 + END; + \echo '' + + -- Show critical issues + \echo '=== Critical Issues ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _snap + WHERE severity = 'CRITICAL' + ORDER BY check_name; + \echo '' + SQL + env: + PGPASSWORD: ${{ secrets.AZURE_POSTGRESQL_PASSWORD }} + + - name: Generate CSV Report + run: | + psql -h ${{ steps.azure-endpoint.outputs.host }} \ + -U ${{ secrets.AZURE_POSTGRESQL_USER }} \ + -d postgres \ + -c "\copy (SELECT * FROM pg_firstAid() ORDER BY severity, check_name) TO '${{ github.workspace }}/azure_health_check.csv' CSV HEADER" + env: + PGPASSWORD: ${{ secrets.AZURE_POSTGRESQL_PASSWORD }} + + - name: Post job summary + if: always() + run: | + { + echo "## ✅ Azure Database for PostgreSQL Validation Complete" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Server | ${{ inputs.db_identifier }} |" + echo "| Resource Group | ${{ inputs.resource_group }} |" + echo "| Report | \`azure_health_check.csv\` |" + } >> $GITHUB_STEP_SUMMARY + + - name: Upload Azure Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: azure-postgresql-report-${{ inputs.db_identifier }} + path: ${{ github.workspace }}/azure_health_check.csv + retention-days: 7 diff --git a/workflows/pgfirstaid-pr-audit.yml b/workflows/pgfirstaid-pr-audit.yml new file mode 100644 index 0000000..fa6e5d8 --- /dev/null +++ b/workflows/pgfirstaid-pr-audit.yml @@ -0,0 +1,80 @@ +# pgFirstAid staging database health audit. +# +# Runs on every pull request update and posts a summary of database health +# findings as a PR comment. Re-runs update the same comment rather than +# spamming new ones. +# +# Setup: +# 1. Copy this file to .github/workflows/pgfirstaid-audit.yml in your repo. +# 2. Copy pgfirstaid_audit.py to .github/scripts/pgfirstaid_audit.py. +# 3. Add STAGING_DATABASE_URL as a repository secret (Settings → Secrets). +# 4. Adjust PGFIRSTAID_FAIL_SEVERITY below to match your team's tolerance. +# +# STAGING_DATABASE_URL format: +# postgresql://user:password@host:5432/dbname +# or any libpq-compatible connection string. +# +# The runner must have network access to your staging database. +# If it lives behind a VPC, see the Tailscale GitHub Action or a self-hosted +# runner as options for establishing connectivity. + +# pgFirstAid PR Audit +# +# Focused on PR-level developer feedback: runs pgFirstAid against a staging DB +# on every PR update and posts a severity summary as a PR comment. +# This is the only workflow in the suite that targets pull requests directly. +# +# Setup: +# 1. Copy this file to .github/workflows/pgfirstaid-audit.yml in your repo. +# 2. Copy pgfirstaid_audit.py to .github/scripts/pgfirstaid_audit.py. +# 3. Add STAGING_DATABASE_URL as a repository secret (Settings → Secrets). +# 4. Adjust PGFIRSTAID_FAIL_SEVERITY to match your team's tolerance. + +name: pgFirstAid Staging Audit + +on: + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: pgfirstaid-audit-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + # Pin to a tag for stability (e.g. v2.1.1). Override via workflow_dispatch. + PGFIRSTAID_VERSION: main + PGFIRSTAID_FAIL_SEVERITY: HIGH + +jobs: + pgfirstaid-audit: + name: Database Health Audit + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install audit dependencies + run: uv pip install psycopg2-binary + + - name: Run pgFirstAid audit + env: + DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PGFIRSTAID_VERSION: ${{ env.PGFIRSTAID_VERSION }} + PGFIRSTAID_FAIL_SEVERITY: ${{ env.PGFIRSTAID_FAIL_SEVERITY }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: python .github/scripts/pgfirstaid_audit.py diff --git a/workflows/pgfirstaid_audit.py b/workflows/pgfirstaid_audit.py new file mode 100644 index 0000000..c322284 --- /dev/null +++ b/workflows/pgfirstaid_audit.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +# pgFirstAid staging DB audit script. +# Fetches pgFirstAid.sql from GitHub, runs it against a target database, +# and posts results as a PR comment. Exits non-zero when findings meet or +# exceed the configured severity threshold. +# +# Required env vars: +# DATABASE_URL - PostgreSQL connection string +# GITHUB_TOKEN - automatically set by GitHub Actions +# GITHUB_REPOSITORY - automatically set by GitHub Actions (owner/repo) +# PR_NUMBER - pull request number (from github.event.pull_request.number) +# +# Optional env vars: +# PGFIRSTAID_VERSION - git ref to fetch pgFirstAid.sql from (default: main) +# PGFIRSTAID_FAIL_SEVERITY - CRITICAL | HIGH | MEDIUM | LOW | NONE (default: HIGH) +# NONE disables job failure but still posts results. + +import json +import logging +import os +import sys +import time +import urllib.error +import urllib.request +from typing import Any + +import psycopg2 +import psycopg2.extras + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] +SEVERITY_EMOJI = { + "CRITICAL": "🔴", + "HIGH": "🟠", + "MEDIUM": "🟡", + "LOW": "🔵", + "INFO": "ℹ️", +} +# Sentinel embedded in the comment body so we can find and update it on +# subsequent runs rather than posting a new comment every time. +COMMENT_SENTINEL = "" +GITHUB_API = "https://api.github.com" +PGFIRSTAID_RAW = ( + "https://raw.githubusercontent.com/randoneering/pgFirstAid/{version}/pgFirstAid.sql" +) + + +def load_config() -> dict[str, str]: + # PR_NUMBER is allowed to be empty (workflow_dispatch without a PR). + required = { + "database_url": os.environ.get("DATABASE_URL", ""), + "github_token": os.environ.get("GITHUB_TOKEN", ""), + "github_repository": os.environ.get("GITHUB_REPOSITORY", ""), + } + missing = [k for k, v in required.items() if not v] + if missing: + logger.error("Missing required environment variables: %s", ", ".join(missing)) + sys.exit(1) + + fail_severity = os.environ.get("PGFIRSTAID_FAIL_SEVERITY", "HIGH").upper() + if fail_severity not in [*SEVERITY_ORDER, "NONE"]: + logger.error( + "Invalid PGFIRSTAID_FAIL_SEVERITY '%s'. Must be one of: %s", + fail_severity, + ", ".join([*SEVERITY_ORDER, "NONE"]), + ) + sys.exit(1) + + return { + **required, + "pr_number": os.environ.get("PR_NUMBER", ""), + "pgfirstaid_version": os.environ.get("PGFIRSTAID_VERSION", "main"), + "fail_severity": fail_severity, + } + + +def _urlopen_with_retry(url: str, timeout: int = 30, max_retries: int = 3) -> bytes: + last_exc: Exception | None = None + for attempt in range(max_retries): + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 + return resp.read() + except (urllib.error.HTTPError, urllib.error.URLError) as e: + last_exc = e + if attempt < max_retries - 1: + wait = (attempt + 1) * 2 + logger.warning("Retry %d/%d after %ds: %s", attempt + 1, max_retries, wait, e) + time.sleep(wait) + logger.error("Request failed after %d retries: %s", max_retries, last_exc) + sys.exit(1) + + +def fetch_pgfirstaid_sql(version: str) -> str: + url = PGFIRSTAID_RAW.format(version=version) + logger.info("Fetching pgFirstAid.sql from %s", url) + return _urlopen_with_retry(url).decode("utf-8") + + +def run_audit(database_url: str, sql: str) -> list[dict[str, Any]]: + logger.info("Running pgFirstAid audit") + conn = None + try: + conn = psycopg2.connect(database_url, connect_timeout=10) + conn.autocommit = True + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + # Install/replace the function. + cur.execute(sql) + # Return rows in severity order so the comment reads cleanly. + cur.execute(""" + SELECT * + FROM pg_firstAid() + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END, + category, + check_name + """) + return [dict(row) for row in cur.fetchall()] + except psycopg2.OperationalError as e: + logger.error("Database connection failed: %s", e) + sys.exit(1) + finally: + if conn: + conn.close() + + +def severity_index(severity: str) -> int: + try: + return SEVERITY_ORDER.index(severity.upper()) + except ValueError: + # Unknown severities sort after INFO. + return len(SEVERITY_ORDER) + + +def should_fail(results: list[dict[str, Any]], fail_severity: str) -> bool: + if fail_severity == "NONE": + return False + threshold = severity_index(fail_severity) + return any(severity_index(r.get("severity", "INFO")) <= threshold for r in results) + + +def count_by_severity(results: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {s: 0 for s in SEVERITY_ORDER} + for row in results: + sev = row.get("severity", "INFO").upper() + counts[sev] = counts.get(sev, 0) + 1 + return counts + + +def _cell(value: Any, max_len: int = 120) -> str: + # Flatten and truncate cell content so it doesn't break the markdown table. + text = str(value or "").replace("\n", " ").replace("|", "\\|").strip() + if len(text) > max_len: + text = text[: max_len - 1] + "…" + return text + + +def format_comment( + results: list[dict[str, Any]], fail_severity: str, failed: bool +) -> str: + counts = count_by_severity(results) + + summary_rows = [ + f"| {SEVERITY_EMOJI.get(sev, '')} {sev} | {counts[sev]} |" + for sev in SEVERITY_ORDER + if counts.get(sev, 0) > 0 + ] + summary_table = ( + "| Severity | Count |\n|---|---|\n" + "\n".join(summary_rows) + if summary_rows + else "No issues found. ✅" + ) + + if results: + header = ( + "| Severity | Category | Check | Object | Issue | Recommended Action |\n" + "|---|---|---|---|---|---|" + ) + rows = [ + "| {emoji} {sev} | {cat} | {check} | {obj} | {issue} | {action} |".format( + emoji=SEVERITY_EMOJI.get(r.get("severity", ""), ""), + sev=_cell(r.get("severity")), + cat=_cell(r.get("category")), + check=_cell(r.get("check_name")), + obj=_cell(r.get("object_name")), + issue=_cell(r.get("issue_description")), + action=_cell(r.get("recommended_action")), + ) + for r in results + ] + full_table = header + "\n" + "\n".join(rows) + details_block = ( + f"
\nFull results ({len(results)} findings)" + f"\n\n{full_table}\n\n
" + ) + else: + details_block = "" + + if failed: + threshold_note = ( + f"\n> ⚠️ **Job failed** — findings at or above `{fail_severity}` threshold were found." + ) + elif fail_severity == "NONE": + threshold_note = "\n> ℹ️ Failure threshold is `NONE` — audit is informational only." + else: + threshold_note = ( + f"\n> ✅ No findings at or above the `{fail_severity}` threshold." + ) + + parts = [ + COMMENT_SENTINEL, + "### 🩺 pgFirstAid Audit", + "", + summary_table, + ] + if details_block: + parts += ["", details_block] + parts.append(threshold_note) + + return "\n".join(parts) + + +def github_request( + method: str, + url: str, + token: str, + body: dict[str, Any] | None = None, +) -> Any: + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "pgfirstaid-audit", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + logger.error("GitHub API error: HTTP %s — %s", e.code, e.read().decode()) + return None + + +def find_existing_comment(token: str, repo: str, pr_number: str) -> int | None: + # Paginate through all comments on the PR to find a previous audit post. + page = 1 + while True: + url = ( + f"{GITHUB_API}/repos/{repo}/issues/{pr_number}/comments" + f"?per_page=100&page={page}" + ) + result = github_request("GET", url, token) + if not isinstance(result, list) or len(result) == 0: + break + for comment in result: + if COMMENT_SENTINEL in comment.get("body", ""): + return int(comment["id"]) + page += 1 + return None + + +def post_or_update_comment( + token: str, repo: str, pr_number: str, body: str +) -> None: + existing_id = find_existing_comment(token, repo, pr_number) + if existing_id: + url = f"{GITHUB_API}/repos/{repo}/issues/comments/{existing_id}" + method = "PATCH" + logger.info("Updating existing comment %s", existing_id) + else: + url = f"{GITHUB_API}/repos/{repo}/issues/{pr_number}/comments" + method = "POST" + logger.info("Posting new comment on PR #%s", pr_number) + + github_request(method, url, token, {"body": body}) + + +def main() -> None: + config = load_config() + + sql = fetch_pgfirstaid_sql(config["pgfirstaid_version"]) + results = run_audit(config["database_url"], sql) + + failed = should_fail(results, config["fail_severity"]) + comment_body = format_comment(results, config["fail_severity"], failed) + + if config["pr_number"]: + post_or_update_comment( + config["github_token"], + config["github_repository"], + config["pr_number"], + comment_body, + ) + else: + # workflow_dispatch or other non-PR trigger — print results instead. + logger.info("No PR number found; printing audit results to stdout.") + print(comment_body) + + counts = count_by_severity(results) + for sev in SEVERITY_ORDER: + if counts.get(sev, 0) > 0: + logger.info("%s %s: %s", SEVERITY_EMOJI.get(sev, ""), sev, counts[sev]) + + if failed: + logger.error( + "Audit failed: findings at or above %s severity were found.", + config["fail_severity"], + ) + sys.exit(1) + + logger.info("Audit complete. No findings at or above the %s threshold.", config["fail_severity"]) + + +if __name__ == "__main__": + main() diff --git a/workflows/pre-post-migration-validate.yml b/workflows/pre-post-migration-validate.yml new file mode 100644 index 0000000..3fa86b9 --- /dev/null +++ b/workflows/pre-post-migration-validate.yml @@ -0,0 +1,380 @@ +# Pre/Post Migration Validation Workflow +# This workflow focuses only on migration safety: +# pre-snapshot baseline → apply migration → post-comparison → deployment gate. +# It is the ONLY workflow that gates deployments. +# Does NOT overlap with db-health-checks.yml (scheduled monitoring) or +# pgfirstaid-pr-audit.yml (PR comments). + +name: Pre/Post Migration Validation + +on: + pull_request: + paths: + - 'migrations/**' + - 'db/**' + - 'pgFirstAid.sql' + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + required: true + default: 'staging' + +concurrency: + group: migration-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Job 1: Capture baseline health before migrations + pre-migration-baseline: + name: Pre-Migration Health Baseline + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.PGPASSWORD }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pgFirstAid + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + DROP FUNCTION IF EXISTS pg_firstAid(); + \i pgFirstAid.sql + EOF + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Generate Baseline Report + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} <<'EOF' + CREATE TEMP TABLE _pre_health_snapshot AS SELECT * FROM pg_firstAid(); + \echo '=== Pre-Migration Health Baseline ===' + \echo '' + \echo 'Severity Summary:' + SELECT severity, COUNT(*) as issue_count + FROM _pre_health_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + \echo '' + \echo 'Critical Issues (blocking):' + SELECT severity, check_name, object_name, issue_description + FROM _pre_health_snapshot + WHERE severity = 'CRITICAL' + ORDER BY check_name; + \echo '' + \echo 'High Priority Issues:' + SELECT severity, check_name, object_name, issue_description + FROM _pre_health_snapshot + WHERE severity = 'HIGH' + ORDER BY check_name; + EOF + continue-on-error: true + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Save Baseline Data + run: | + # Create persistent snapshot table for cross-job comparison + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} <<'EOF' + DROP TABLE IF EXISTS _pre_health_snapshot; + CREATE UNLOGGED TABLE _pre_health_snapshot AS SELECT * FROM pg_firstAid(); + EOF + # Save severity counts as CSV + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} --csv \ + -c "SELECT severity, COUNT(*)::int AS count FROM _pre_health_snapshot GROUP BY severity ORDER BY 1;" \ + > ${{ github.workspace }}/baseline_severity.csv + # Save critical count + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} -t \ + -c "SELECT count(*) FROM _pre_health_snapshot WHERE severity = 'CRITICAL';" \ + | tr -d '[:space:]' > ${{ github.workspace }}/baseline_critical_count.txt + continue-on-error: true + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Upload Baseline Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: pre-migration-baseline + path: | + ${{ github.workspace }}/baseline_severity.csv + ${{ github.workspace }}/baseline_critical_count.txt + retention-days: 7 + + # Job 2: Apply migrations + apply-migrations: + name: Apply Database Migrations + runs-on: ubuntu-latest + needs: pre-migration-baseline + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Migrations + run: | + # Flyway example + # flyway migrate + + # Or Liquibase + # mvn liquibase:update + + # Or direct SQL + # psql -h $PGHOST -U $PGUSER -d $PGDATABASE -f migrations/apply.sql + + echo "Migrations applied successfully" + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + PGPORT: '5432' + FLYWAY_URL: jdbc:postgresql://${{ secrets.PGHOST }}:5432/${{ secrets.PGDATABASE }} + FLYWAY_USER: ${{ secrets.PGUSER }} + FLYWAY_PASSWORD: ${{ secrets.PGPASSWORD }} + + # Job 3: Validate post-migration health + post-migration-validation: + name: Post-Migration Health Validation + runs-on: ubuntu-latest + needs: apply-migrations + timeout-minutes: 15 + + steps: + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: ${{ secrets.PGPASSWORD }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Re-install pgFirstAid + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + DROP FUNCTION IF EXISTS pg_firstAid(); + \i pgFirstAid.sql + EOF + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Download baseline data + uses: actions/download-artifact@v4 + with: + name: pre-migration-baseline + path: ./ + + - name: Run Post-Migration Health Check + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} <<'EOF' + CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid(); + \echo '=== Post-Migration Health Report ===' + \echo '' + \echo 'Severity Summary:' + SELECT severity, COUNT(*) as issue_count + FROM _health_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + \echo '' + \echo 'All Issues (ordered by priority):' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _health_snapshot + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END, + check_name; + \echo '' + \echo 'New Issues (compared to baseline):' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _health_snapshot + WHERE (check_name, object_name) NOT IN ( + SELECT check_name, object_name FROM _pre_health_snapshot + ) + ORDER BY severity, check_name; + EOF + continue-on-error: true + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Compare Critical Issue Count + id: compare-critical + run: | + BASELINE_CRITICAL=$(cat baseline_critical_count.txt | tr -d '[:space:]' || echo "0") + CURRENT_CRITICAL=$(psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} \ + -t -c "SELECT count(*) FROM pg_firstAid() WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + + echo "baseline_critical=$BASELINE_CRITICAL" >> $GITHUB_OUTPUT + echo "current_critical=$CURRENT_CRITICAL" >> $GITHUB_OUTPUT + + if [ "$CURRENT_CRITICAL" -gt "$BASELINE_CRITICAL" ]; then + NEW_CRITICAL=$((CURRENT_CRITICAL - BASELINE_CRITICAL)) + echo "::error::Migration introduced $NEW_CRITICAL new critical issues!" + echo "Baseline: $BASELINE_CRITICAL | Current: $CURRENT_CRITICAL" + exit 1 + else + echo "No new critical issues introduced" + fi + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Check for New High Priority Issues + id: check-high + run: | + CURRENT_HIGH=$(psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} \ + -t -c "SELECT count(*) FROM pg_firstAid() WHERE severity = 'HIGH';" | tr -d '[:space:]') + + echo "high_issues=$CURRENT_HIGH" >> $GITHUB_OUTPUT + + if [ "$CURRENT_HIGH" -gt 0 ]; then + echo "Found $CURRENT_HIGH high priority issues:" + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} \ + -c "SELECT check_name, object_name, issue_description FROM pg_firstAid() WHERE severity = 'HIGH';" + echo "high_issues_warn=true" >> $GITHUB_OUTPUT + else + echo "No high priority issues" + echo "high_issues_warn=false" >> $GITHUB_OUTPUT + fi + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Save Post-Migration CSV + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} --csv \ + -c "SELECT severity, COUNT(*)::int AS count FROM pg_firstAid() GROUP BY severity ORDER BY 1;" \ + > ${{ github.workspace }}/post_migration_severity.csv + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} --csv \ + -c "SELECT severity, check_name, object_name, issue_description, recommended_action FROM pg_firstAid() ORDER BY severity, check_name;" \ + > ${{ github.workspace }}/post_migration_health.csv + continue-on-error: true + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Upload Post-Migration Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: post-migration-report + path: | + ${{ github.workspace }}/post_migration_severity.csv + ${{ github.workspace }}/post_migration_health.csv + retention-days: 7 + if-no-files-found: warn + + - name: Cleanup baseline snapshot + if: always() + run: | + psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} \ + -c "DROP TABLE IF EXISTS _pre_health_snapshot;" + env: + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + # Job 4: Summary and notification + generate-summary: + name: Generate Summary Report + runs-on: ubuntu-latest + needs: [pre-migration-baseline, post-migration-validation] + + steps: + - name: Download baseline artifacts + uses: actions/download-artifact@v4 + with: + name: pre-migration-baseline + path: baseline/ + + - name: Download post-migration artifacts + uses: actions/download-artifact@v4 + with: + name: post-migration-report + path: post-migration/ + + - name: Generate Summary + run: | + echo "=== MIGRATION VALIDATION SUMMARY ===" > summary.md + echo "" >> summary.md + echo "## Baseline (Pre-Migration)" >> summary.md + echo "" >> summary.md + echo "| Severity | Count |" >> summary.md + echo "|----------|-------|" >> summary.md + if [ -f "baseline/baseline_severity.csv" ]; then + awk -F',' 'NR>1 {printf "| %s | %s |\n", $1, $2}' baseline/baseline_severity.csv >> summary.md + else + echo "| Not Available | |" >> summary.md + fi + echo "" >> summary.md + echo "## Post-Migration" >> summary.md + echo "" >> summary.md + echo "| Severity | Count |" >> summary.md + echo "|----------|-------|" >> summary.md + if [ -f "post-migration/post_migration_severity.csv" ]; then + awk -F',' 'NR>1 {printf "| %s | %s |\n", $1, $2}' post-migration/post_migration_severity.csv >> summary.md + else + echo "| Not Available | |" >> summary.md + fi + echo "" >> summary.md + echo "## Critical Issue Change" >> summary.md + echo "" >> summary.md + BASELINE_CRIT=$(cat baseline/baseline_critical_count.txt 2>/dev/null || echo "N/A") + echo "Baseline: $BASELINE_CRIT critical issues" >> summary.md + echo "Current: $(psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} -t -c 'SELECT count(*) FROM pg_firstAid() WHERE severity = '\''CRITICAL'\'';' | tr -d '[:space:]') critical issues" >> summary.md + + cat summary.md + env: + PGHOST: ${{ secrets.PGHOST }} + PGUSER: ${{ secrets.PGUSER }} + PGDATABASE: ${{ secrets.PGDATABASE }} + PGPASSWORD: ${{ secrets.PGPASSWORD }} + + - name: Upload Summary + uses: actions/upload-artifact@v4 + with: + name: migration-validation-summary + path: summary.md + retention-days: 30 + + # Job 5: Block deployment if critical issues introduced + gate-check: + name: Gate Check (Block Deployment) + runs-on: ubuntu-latest + needs: [pre-migration-baseline, post-migration-validation, generate-summary] + if: success() || failure() + + steps: + - name: Check if migration should be blocked + run: | + if [ "${{ needs.post-migration-validation.result }}" != "success" ]; then + echo "::error::Migration validation failed. Please review pgFirstAid health checks." + exit 1 + fi + echo "Migration passed validation - safe to deploy" From 141276665f1890788e7706c39d6069395bd22c23 Mon Sep 17 00:00:00 2001 From: randoneering Date: Thu, 18 Jun 2026 04:12:41 +0000 Subject: [PATCH 2/9] feat(ci): testing for example workflows --- README.md | 14 + testing/local-workflows/.env.example | 31 ++ testing/local-workflows/.gitignore | 18 ++ testing/local-workflows/Makefile | 84 +++++ testing/local-workflows/docker-compose.yml | 35 +++ .../local-workflows/test_db_health_checks.sh | 146 +++++++++ .../test_managed_db_validate.sh | 116 +++++++ .../local-workflows/test_neon_before_after.sh | 293 +++++++++++++++++ testing/local-workflows/test_pr_audit.sh | 82 +++++ .../test_pre_post_migration.sh | 270 ++++++++++++++++ workflows/README.md | 143 +++++++-- workflows/neon-before-after-validate.yml | 297 ++++++++++++++++++ 12 files changed, 1503 insertions(+), 26 deletions(-) create mode 100644 testing/local-workflows/.env.example create mode 100644 testing/local-workflows/.gitignore create mode 100644 testing/local-workflows/Makefile create mode 100644 testing/local-workflows/docker-compose.yml create mode 100755 testing/local-workflows/test_db_health_checks.sh create mode 100755 testing/local-workflows/test_managed_db_validate.sh create mode 100755 testing/local-workflows/test_neon_before_after.sh create mode 100755 testing/local-workflows/test_pr_audit.sh create mode 100755 testing/local-workflows/test_pre_post_migration.sh create mode 100644 workflows/neon-before-after-validate.yml diff --git a/README.md b/README.md index 5c7ed39..397d154 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,20 @@ pgFirstAid is designed to be lightweight and safe to run on production systems: - Typical execution time: <1 second on most databases - No locking or blocking of user queries +## CI/CD Integration + +Drop-in GitHub Actions workflows in [`workflows/`](workflows/) for integrating pgFirstAid into your pipeline: + +| Workflow | Purpose | +|----------|---------| +| [`pgfirstaid-pr-audit.yml`](workflows/pgfirstaid-pr-audit.yml) | Posts a health audit on every PR | +| [`pre-post-migration-validate.yml`](workflows/pre-post-migration-validate.yml) | Gates deployments on migration safety | +| [`neon-before-after-validate.yml`](workflows/neon-before-after-validate.yml) | Isolated before/after checks via Neon branching | +| [`db-health-checks.yml`](workflows/db-health-checks.yml) | Scheduled daily health monitoring | +| [`managed-db-validate.yml`](workflows/managed-db-validate.yml) | Cloud-specific compatibility validation | + +Copy any workflow file to `.github/workflows/` in your repo. See [`workflows/README.md`](workflows/README.md) for setup and configuration. + ## Testing - Query and health-check coverage is validated with pgTAP assertions grouped by severity. diff --git a/testing/local-workflows/.env.example b/testing/local-workflows/.env.example new file mode 100644 index 0000000..6802499 --- /dev/null +++ b/testing/local-workflows/.env.example @@ -0,0 +1,31 @@ +# pgFirstAid Local Test Workflow — Connection Template +# +# Copy this file to .env and fill in your database connection details. +# cp .env.example .env +# +# These match the env vars the integration-pg-matrix.yml workflow uses +# on the NixOS self-hosted runner. Set them to any PostgreSQL 15-18 +# instance — local, Neon, DigitalOcean, or cloud-managed. + +# --- Connection --- +PGHOST=localhost +PGPORT=5432 +PGUSER=pgfirstaid +PGPASSWORD=pgfirstaid +PGDATABASE=pgfirstaid +PGSSLMODE=require + +# --- PR Audit --- +# DATABASE_URL is the libpq connection string used by pgfirstaid_audit.py +DATABASE_URL=postgresql://pgfirstaid:pgfirstaid@localhost:5432/pgfirstaid +PGFIRSTAID_FAIL_SEVERITY=HIGH + +# --- Managed DB label (optional) --- +# Set to aws / gcp / azure / direct when running test_managed_db_validate.sh +CLOUD_PROVIDER=direct + +# --- Neon Branching (for test_neon_before_after.sh) --- +# NEON_API_KEY — from https://console.neon.tech/app/settings/api-keys +# NEON_PROJECT_ID — from your Neon project Settings page +# NEON_API_KEY= +# NEON_PROJECT_ID= diff --git a/testing/local-workflows/.gitignore b/testing/local-workflows/.gitignore new file mode 100644 index 0000000..2d070b0 --- /dev/null +++ b/testing/local-workflows/.gitignore @@ -0,0 +1,18 @@ +# Secrets +.env + +# Reports +reports/ +*.csv +*.json +*.md + +# Temp artifacts +baseline_severity.csv +baseline_critical_count.txt +current_health_check.csv +full_health_check.csv +full_health_check.json +post_migration_severity.csv +post_migration_health.csv +comparison-report.md diff --git a/testing/local-workflows/Makefile b/testing/local-workflows/Makefile new file mode 100644 index 0000000..f7fbbae --- /dev/null +++ b/testing/local-workflows/Makefile @@ -0,0 +1,84 @@ +SHELL := bash + +# pgFirstAid Local Test Workflows +# +# Usage: +# make setup - copy .env.example -> .env (never overwrites) +# make test-health - run health check suite +# make test-managed - run managed DB validation +# make test-migration - run pre/post migration validation +# make test-audit - run PR audit script +# make test-neon - run Neon branching before/after validation +# make test-all - run all four standard suites sequentially +# make test-all-pg15 - run all four standard suites against PG_VERSION=15 +# make clean - remove reports/ + +# Load .env if present (silently skip if missing) +ifneq (,$(wildcard .env)) + include .env + export +endif + +# --- Default connection (override via .env or env vars) --- +PGHOST ?= localhost +PGPORT ?= 5432 +PGUSER ?= pgfirstaid +PGPASSWORD ?= pgfirstaid +PGDATABASE ?= pgfirstaid +PGSSLMODE ?= require +DATABASE_URL ?= postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE) +CLOUD_PROVIDER ?= direct + +# --- Check required tools --- +TOOLS := psql uv +$(foreach tool,$(TOOLS),\ + $(if $(shell command -v $(tool) 2>/dev/null),,\ + $(error $(tool) is required but not found in PATH))) + +setup: + @test -f .env || cp .env.example .env + @echo "Created .env from .env.example — edit then run make test-*" + +test-health: + @echo "=== Health Check Suite ===" + ./test_db_health_checks.sh + +test-managed: + @echo "=== Managed DB Validation ===" + CLOUD_PROVIDER=$(CLOUD_PROVIDER) ./test_managed_db_validate.sh + +test-migration: + @echo "=== Pre/Post Migration Validation ===" + ./test_pre_post_migration.sh + +test-audit: + @echo "=== PR Audit ===" + DATABASE_URL=$(DATABASE_URL) ./test_pr_audit.sh + +test-neon: + @echo "=== Neon Before/After Validation ===" + @test -n "$(NEON_API_KEY)" || { echo "NEON_API_KEY not set"; exit 1; } + @test -n "$(NEON_PROJECT_ID)" || { echo "NEON_PROJECT_ID not set"; exit 1; } + NEON_ROLE_NAME=$(NEON_ROLE_NAME) NEON_DATABASE_NAME=$(NEON_DATABASE_NAME) \ + ./test_neon_before_after.sh + +test-all: test-health test-managed test-migration test-audit + @echo "" + @echo "=== All test suites completed ===" + +# Convenience: run against a specific PG version using secrets-style naming +# PG_VERSION=15 make test-all-pg +test-all-pg: + PGHOST=$(PGHOST) PGPORT=$(PGPORT) PGUSER=$(PGUSER) \ + PGPASSWORD=$(PGPASSWORD) PGDATABASE=$(PGDATABASE) \ + DATABASE_URL=postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE) \ + $(MAKE) test-all + +clean: + rm -rf reports/ + rm -f baseline_severity.csv baseline_critical_count.txt + rm -f current_health_check.csv full_health_check.csv full_health_check.json + rm -f post_migration_severity.csv post_migration_health.csv + rm -f comparison-report.md + +.PHONY: setup test-health test-managed test-migration test-audit test-all test-all-pg clean diff --git a/testing/local-workflows/docker-compose.yml b/testing/local-workflows/docker-compose.yml new file mode 100644 index 0000000..7865aab --- /dev/null +++ b/testing/local-workflows/docker-compose.yml @@ -0,0 +1,35 @@ +# pgFirstAid Local Test Database (Path B — optional) +# +# Standalone PostgreSQL 16 instance for fully offline testing. +# Only needed if you do NOT have access to remote PG instances. +# +# Usage: +# docker compose up -d # start PG +# make test-all # run tests against it +# docker compose down -v # stop and delete data +# +# Connection defaults match .env.example: +# PGHOST=localhost PGPORT=5432 PGUSER=pgfirstaid +# PGPASSWORD=pgfirstaid PGDATABASE=pgfirstaid + +services: + postgres: + image: postgres:16-alpine + container_name: pgfirstaid-local + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: pgfirstaid + POSTGRES_PASSWORD: pgfirstaid + POSTGRES_DB: pgfirstaid + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pgfirstaid"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/testing/local-workflows/test_db_health_checks.sh b/testing/local-workflows/test_db_health_checks.sh new file mode 100755 index 0000000..ebc10c2 --- /dev/null +++ b/testing/local-workflows/test_db_health_checks.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# test_db_health_checks.sh +# +# Replicates the db-health-checks.yml GitHub Actions workflow locally. +# Installs pgFirstAid, runs the full health check with severity +# breakdowns, and exports CSV + JSON reports. +# +# Usage: +# export PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... PGDATABASE=... +# ./test_db_health_checks.sh +# +# All connection parameters default to localhost / pgfirstaid. +# You can also source a .env file before running. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPORTS_DIR="$(dirname "${BASH_SOURCE[0]}")/reports" +PREFIX="[health-check]" + +# ---- Config with defaults ---- +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-pgfirstaid}" +PGPASSWORD="${PGPASSWORD:-pgfirstaid}" +PGDATABASE="${PGDATABASE:-pgfirstaid}" +PGSSLMODE="${PGSSLMODE:-require}" +PGPASSFILE="$(mktemp)" +export PGPASSFILE PGSSLMODE + +echo "${PGHOST}:${PGPORT}:${PGDATABASE}:${PGUSER}:${PGPASSWORD}" > "$PGPASSFILE" +chmod 600 "$PGPASSFILE" +_cleanup() { rm -f "$PGPASSFILE"; } +trap _cleanup EXIT + +PSQL=(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1) + +# ---- 0. Verify tools ---- +for tool in psql; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$PREFIX ERROR: $tool not found. Install postgresql-client." + exit 1 + fi +done + +mkdir -p "$REPORTS_DIR" + +# ---- 1. Install pgFirstAid ---- +echo "$PREFIX Installing pgFirstAid..." +"${PSQL[@]}" -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" +"${PSQL[@]}" -f "${REPO_ROOT}/pgFirstAid.sql" +"${PSQL[@]}" -f "${REPO_ROOT}/view_pgFirstAid.sql" +"${PSQL[@]}" -f "${REPO_ROOT}/view_pgFirstAid_managed.sql" +echo "$PREFIX pgFirstAid installed." + +# ---- 2. Validate installation ---- +echo "$PREFIX Validating pg_firstAid() function..." +"${PSQL[@]}" -c "SELECT pg_firstAid();" > /dev/null +echo "$PREFIX Function responds OK." + +# ---- 3. Full health check with severity breakdown ---- +echo "" +echo "==============================================" +echo " DATABASE HEALTH CHECK REPORT" +echo "==============================================" +echo "" + +"${PSQL[@]}" <<'EOF' +CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid(); + +\echo '=== CRITICAL Issues (Must Fix) ===' +SELECT severity, check_name, object_name, issue_description, recommended_action, documentation_link +FROM _health_snapshot +WHERE severity = 'CRITICAL' +ORDER BY check_name; +\echo '' + +\echo '=== HIGH Priority Issues (Should Fix) ===' +SELECT severity, check_name, object_name, issue_description, recommended_action +FROM _health_snapshot +WHERE severity = 'HIGH' +ORDER BY check_name; +\echo '' + +\echo '=== MEDIUM Priority Issues (Monitor) ===' +SELECT severity, check_name, object_name, issue_description, recommended_action +FROM _health_snapshot +WHERE severity = 'MEDIUM' +ORDER BY check_name; +\echo '' + +\echo '=== LOW Priority Issues (Nice to Have) ===' +SELECT severity, check_name, object_name, issue_description +FROM _health_snapshot +WHERE severity = 'LOW' +ORDER BY check_name; +\echo '' + +\echo '=== INFO (General Information) ===' +SELECT severity, check_name, object_name +FROM _health_snapshot +WHERE severity = 'INFO' +ORDER BY check_name; +\echo '' + +\echo '============================================' +\echo ' SUMMARY STATISTICS' +\echo '============================================' +\echo '' +SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects +FROM _health_snapshot +GROUP BY severity +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; +EOF + +# ---- 4. CSV export ---- +echo "$PREFIX Exporting CSV report..." +"${PSQL[@]}" \ + -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ + -c "\copy (SELECT * FROM _health_snapshot ORDER BY severity, check_name) TO '${REPORTS_DIR}/full_health_check.csv' CSV HEADER" +echo "$PREFIX CSV report -> ${REPORTS_DIR}/full_health_check.csv" + +# ---- 5. JSON export ---- +echo "$PREFIX Exporting JSON report..." +"${PSQL[@]}" \ + -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ + -c "\copy (SELECT json_agg(to_json(d)) FROM _health_snapshot d) TO '${REPORTS_DIR}/full_health_check.json'" +echo "$PREFIX JSON report -> ${REPORTS_DIR}/full_health_check.json" + +# ---- 6. Summary ---- +echo "" +echo "$PREFIX Done. Reports saved to ${REPORTS_DIR}/" +echo " CSV: ${REPORTS_DIR}/full_health_check.csv" +echo " JSON: ${REPORTS_DIR}/full_health_check.json" +echo "" +echo "To compare with a previous baseline run, see test_pre_post_migration.sh" diff --git a/testing/local-workflows/test_managed_db_validate.sh b/testing/local-workflows/test_managed_db_validate.sh new file mode 100755 index 0000000..5da040a --- /dev/null +++ b/testing/local-workflows/test_managed_db_validate.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# test_managed_db_validate.sh +# +# Replicates the SQL validation portion of managed-db-validate.yml locally. +# Cloud auth steps (AWS CLI, gcloud, az) are not replicated — this script +# runs the same pgFirstAid SQL against whatever database you point it at. +# +# Usage: +# export PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... PGDATABASE=... +# CLOUD_PROVIDER=aws ./test_managed_db_validate.sh +# +# CLOUD_PROVIDER is optional — it only labels the output (aws/gcp/azure/direct). +# Defaults to "direct". + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPORTS_DIR="$(dirname "${BASH_SOURCE[0]}")/reports" +PREFIX="[managed-validate]" + +CLOUD_PROVIDER="${CLOUD_PROVIDER:-direct}" + +# ---- Config with defaults ---- +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-pgfirstaid}" +PGPASSWORD="${PGPASSWORD:-pgfirstaid}" +PGDATABASE="${PGDATABASE:-pgfirstaid}" +PGSSLMODE="${PGSSLMODE:-require}" +PGPASSFILE="$(mktemp)" +export PGPASSFILE PGSSLMODE + +echo "${PGHOST}:${PGPORT}:${PGDATABASE}:${PGUSER}:${PGPASSWORD}" > "$PGPASSFILE" +chmod 600 "$PGPASSFILE" +_cleanup() { rm -f "$PGPASSFILE"; } +trap _cleanup EXIT + +PSQL=(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1) + +# ---- 0. Verify tools ---- +for tool in psql; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$PREFIX ERROR: $tool not found." + exit 1 + fi +done + +mkdir -p "$REPORTS_DIR" + +# ---- 1. Install pgFirstAid ---- +echo "$PREFIX [$CLOUD_PROVIDER] Installing pgFirstAid function + managed view..." +"${PSQL[@]}" -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" +"${PSQL[@]}" -f "${REPO_ROOT}/pgFirstAid.sql" +"${PSQL[@]}" -f "${REPO_ROOT}/view_pgFirstAid_managed.sql" +echo "$PREFIX [$CLOUD_PROVIDER] pgFirstAid installed." + +# ---- 2. Verify installation ---- +"${PSQL[@]}" -c "SELECT pg_firstAid();" > /dev/null +echo "$PREFIX [$CLOUD_PROVIDER] Function responds OK." + +# ---- 3. Run health check ---- +echo "" +echo "==============================================" +echo " $CLOUD_PROVIDER Health Check" +echo " Host: $PGHOST:$PGPORT" +echo "==============================================" +echo "" + +"${PSQL[@]}" <<'EOF' +CREATE TEMP TABLE _snap AS SELECT * FROM pg_firstAid(); + +\echo '=== Severity Summary ===' +SELECT + severity, + COUNT(*) as issue_count, + COUNT(DISTINCT object_name) as affected_objects +FROM _snap +GROUP BY severity +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + ELSE 4 + END; +\echo '' + +\echo '=== Critical Issues ===' +SELECT severity, check_name, object_name, issue_description, recommended_action +FROM _snap +WHERE severity = 'CRITICAL' +ORDER BY check_name; +\echo '' + +\echo '=== High Priority Issues ===' +SELECT severity, check_name, object_name, issue_description +FROM _snap +WHERE severity = 'HIGH' +ORDER BY check_name; +\echo '' +EOF + +# ---- 4. CSV export via \copy ---- +CSV_PATH="${REPORTS_DIR}/${CLOUD_PROVIDER}_health_check.csv" +echo "$PREFIX [$CLOUD_PROVIDER] Exporting CSV report..." +"${PSQL[@]}" \ + -c "\copy (SELECT * FROM pg_firstAid() ORDER BY severity, check_name) TO '${CSV_PATH}' CSV HEADER" +echo "$PREFIX [$CLOUD_PROVIDER] Report -> ${CSV_PATH}" + +# ---- 5. Summary ---- +echo "" +echo "==============================================" +echo " $CLOUD_PROVIDER Validation Complete" +echo " Host: $PGHOST:$PGPORT" +echo " Report: ${CSV_PATH}" +echo "==============================================" diff --git a/testing/local-workflows/test_neon_before_after.sh b/testing/local-workflows/test_neon_before_after.sh new file mode 100755 index 0000000..eaa2d13 --- /dev/null +++ b/testing/local-workflows/test_neon_before_after.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# test_neon_before_after.sh +# +# Uses Neon's instant database branching to run before/after health checks +# on an isolated copy of your data. Detects regressions from any change +# that affects query behavior on the target database: +# +# - SQL migrations (schema changes, index additions) +# - Application code deploys (new ORM queries, join patterns) +# - Infrastructure changes (connection pools, work_mem, PGBouncer) +# - Configuration changes (timeouts, statement timeouts) +# +# This is the local equivalent of .github/workflows/neon-before-after-validate.yml. +# +# Prerequisites: +# neonctl CLI -- npm install -g neonctl OR brew install neonctl +# NEON_API_KEY -- export NEON_API_KEY=... + +# Usage: +# export NEON_API_KEY=... NEON_PROJECT_ID=... +# ./test_neon_before_after.sh [--role-name ROLE] [--database-name DB] +# +# Optional env vars: +# NEON_BRANCH_NAME - branch name (default: auto-generated) +# NEON_PARENT_BRANCH - parent branch to clone (default: project default) +# NEON_ROLE_NAME - role for connection string (default: auto-detect) +# NEON_DATABASE_NAME - database for connection string (default: pgFirstAid) +# CHANGE_DIR - directory with .sql change files (default: auto) +# PGFIRSTAID_FAIL_SEVERITY - CRITICAL | HIGH | MEDIUM | LOW | NONE (default: HIGH) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PREFIX="[neon-beforeafter]" + +NEON_API_KEY="${NEON_API_KEY:-}" +NEON_PROJECT_ID="${NEON_PROJECT_ID:-}" +NEON_BRANCH_NAME="${NEON_BRANCH_NAME:-pgfa-beforeafter-$(date +%s)}" +NEON_PARENT_BRANCH="${NEON_PARENT_BRANCH:-}" +NEON_ROLE_NAME="${NEON_ROLE_NAME:-}" +NEON_DATABASE_NAME="${NEON_DATABASE_NAME:-pgFirstAid}" +CHANGE_DIR="${CHANGE_DIR:-}" +PGFIRSTAID_FAIL_SEVERITY="${PGFIRSTAID_FAIL_SEVERITY:-HIGH}" + +# ---- CLI arg parsing ---- +while [[ $# -gt 0 ]]; do + case "$1" in + --role-name) + NEON_ROLE_NAME="$2" + shift 2 + ;; + --database-name) + NEON_DATABASE_NAME="$2" + shift 2 + ;; + --help) + echo "Usage: $0 [--role-name ROLE] [--database-name DB]" + echo "" + echo "Env vars: NEON_API_KEY (req), NEON_PROJECT_ID (req), NEON_ROLE_NAME, NEON_DATABASE_NAME, NEON_BRANCH_NAME, NEON_PARENT_BRANCH, CHANGE_DIR, PGFIRSTAID_FAIL_SEVERITY" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# ---- 0. Pre-flight ---- +for tool in psql neonctl; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "$PREFIX ERROR: $tool not found." + echo " Install psql: apt install postgresql-client" + echo " Install neonctl: npm install -g neonctl" + exit 1 + fi +done + +if [ -z "$NEON_API_KEY" ]; then + echo "$PREFIX ERROR: NEON_API_KEY is required." + echo " export NEON_API_KEY=..." + echo " Get one at https://console.neon.tech/app/settings/api-keys" + exit 1 +fi + +if [ -z "$NEON_PROJECT_ID" ]; then + echo "$PREFIX ERROR: NEON_PROJECT_ID is required." + echo " export NEON_PROJECT_ID=..." + echo " Find it in your Neon project Settings page." + exit 1 +fi + +# ---- 1. Create Neon branch ---- +echo "$PREFIX Creating branch '$NEON_BRANCH_NAME'..." +BRANCH_ARGS=( + --project-id "$NEON_PROJECT_ID" + --name "$NEON_BRANCH_NAME" + --output json +) +if [ -n "$NEON_PARENT_BRANCH" ]; then + BRANCH_ARGS+=(--parent "$NEON_PARENT_BRANCH") +fi + +BRANCH_OUTPUT=$(neonctl branches create "${BRANCH_ARGS[@]}") +BRANCH_ID=$(echo "$BRANCH_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['branch']['id'])") +echo "$PREFIX Branch ID: $BRANCH_ID" + +_cleanup() { + echo "" + echo "$PREFIX Cleaning up -- deleting branch '$NEON_BRANCH_NAME'..." + neonctl branches delete "$BRANCH_ID" --project-id "$NEON_PROJECT_ID" 2>/dev/null || true + echo "$PREFIX Branch deleted." +} +trap _cleanup EXIT + +# ---- 2. Get connection string ---- +echo "$PREFIX Getting connection string..." +ROLE_ARG="" +if [ -n "$NEON_ROLE_NAME" ]; then + ROLE_ARG="--role-name $NEON_ROLE_NAME" +fi +DB_URL=$(neonctl connection-string "$NEON_BRANCH_NAME" \ + --project-id "$NEON_PROJECT_ID" \ + --database-name "$NEON_DATABASE_NAME" \ + $ROLE_ARG) +echo "$PREFIX Connecting to branch..." + +# Wait for compute to be ready (Neon cold-start can take a moment) +echo "$PREFIX Waiting for compute to be ready..." +for i in $(seq 1 15); do + if psql "$DB_URL" -c "SELECT 1" >/dev/null 2>&1; then + echo "$PREFIX Compute ready." + break + fi + if [ "$i" -eq 15 ]; then + echo "$PREFIX ERROR: Compute did not become ready within 15s." + exit 1 + fi + sleep 2 +done + +# ---- 3. Install pgFirstAid ---- +echo "$PREFIX Installing pgFirstAid on branch..." +psql "$DB_URL" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" +psql "$DB_URL" -v ON_ERROR_STOP=1 -f "${REPO_ROOT}/pgFirstAid.sql" +psql "$DB_URL" -v ON_ERROR_STOP=1 -f "${REPO_ROOT}/view_pgFirstAid.sql" +psql "$DB_URL" -v ON_ERROR_STOP=1 -f "${REPO_ROOT}/view_pgFirstAid_managed.sql" +echo "$PREFIX pgFirstAid installed." + +# ---- 4. Pre-change baseline ---- +echo "" +echo "==============================================" +echo " PRE-CHANGE HEALTH BASELINE" +echo "==============================================" +echo "" + +psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' +CREATE UNLOGGED TABLE _pre_change_snapshot AS SELECT * FROM pg_firstAid(); + +\echo 'Severity Summary:' +SELECT severity, COUNT(*) as issue_count +FROM _pre_change_snapshot +GROUP BY severity +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; +\echo '' +\echo 'Critical & High Issues:' +SELECT severity, check_name, object_name, issue_description +FROM _pre_change_snapshot +WHERE severity IN ('CRITICAL', 'HIGH') +ORDER BY severity, check_name; +EOF + +BASELINE_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM _pre_change_snapshot WHERE severity = 'CRITICAL';" | tr -d '[:space:]') +echo "$PREFIX Baseline critical issues: $BASELINE_CRITICAL" + +# ---- 5. BEFORE / AFTER BOUNDARY -- Apply changes here ---- +echo "" +echo "==============================================" +echo " BEFORE / AFTER BOUNDARY" +echo "==============================================" +echo "" +echo " Pre-change baseline captured above." +echo " Apply your change (migration SQL, app config, etc.)" +echo " on this branch before the post-change comparison." +echo "" + +CHANGE_FILES="" +if [ -n "$CHANGE_DIR" ]; then + CHANGE_FILES=$(find "$CHANGE_DIR" -name '*.sql' -type f | sort || true) +fi + +if [ -z "$CHANGE_FILES" ]; then + if [ -d "${REPO_ROOT}/migrations" ]; then + CHANGE_FILES=$(find "${REPO_ROOT}/migrations" -name '*.sql' -type f | sort || true) + fi +fi + +if [ -n "$CHANGE_FILES" ]; then + echo "$PREFIX Applying SQL change files:" + while IFS= read -r f; do + echo " $f" + psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$f" + done <<< "$CHANGE_FILES" + echo "$PREFIX Changes applied." +else + echo " No .sql change files found in CHANGE_DIR or REPO_ROOT/migrations/." + echo "" + echo " Apply your change manually in another terminal. Examples:" + echo "" + echo " # SQL migration:" + echo " psql \"$DB_URL\" -f /path/to/your/migration.sql" + echo "" + echo " # Run app test suite against this branch URL:" + echo " export DATABASE_URL=\"$DB_URL\"" + echo " ./run_tests.sh" + echo "" + echo " When ready, press Enter to run the post-change" + echo " health comparison." + echo "" + read -r -p " Press Enter after applying your changes to continue... " +fi + +# ---- 6. Post-change comparison ---- +echo "" +echo "==============================================" +echo " POST-CHANGE HEALTH" +echo "==============================================" +echo "" + +psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' +CREATE TEMP TABLE _post_change_snapshot AS SELECT * FROM pg_firstAid(); + +\echo 'Severity Summary:' +SELECT severity, COUNT(*) as issue_count +FROM _post_change_snapshot +GROUP BY severity +ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; +\echo '' +\echo 'New Issues Since Baseline:' +SELECT severity, check_name, object_name, issue_description, recommended_action +FROM _post_change_snapshot +WHERE (check_name, object_name) NOT IN ( + SELECT check_name, object_name FROM _pre_change_snapshot +) +ORDER BY severity, check_name; +EOF + +# ---- 7. Gate check ---- +echo "" +echo "==============================================" +echo " GATE CHECK" +echo "==============================================" +echo "" + +CURRENT_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM pg_firstAid() WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + +echo " Baseline critical: $BASELINE_CRITICAL" +echo " Current critical: $CURRENT_CRITICAL" + +if [ "$CURRENT_CRITICAL" -gt "$BASELINE_CRITICAL" ]; then + NEW_CRITICAL=$((CURRENT_CRITICAL - BASELINE_CRITICAL)) + echo "" + echo " BLOCKED: Change introduces $NEW_CRITICAL new critical issue(s)!" + echo "" + echo " New critical findings:" + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + SELECT check_name, object_name, issue_description + FROM pg_firstAid() + WHERE severity = 'CRITICAL' + AND (check_name, object_name) NOT IN ( + SELECT check_name, object_name FROM _pre_change_snapshot + ); +EOF + exit 1 +else + echo "" + echo " PASSED: No new critical issues. Safe to deploy." + exit 0 +fi diff --git a/testing/local-workflows/test_pr_audit.sh b/testing/local-workflows/test_pr_audit.sh new file mode 100755 index 0000000..76b19f1 --- /dev/null +++ b/testing/local-workflows/test_pr_audit.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# test_pr_audit.sh +# +# Replicates the pgfirstaid-pr-audit.yml workflow locally. +# Runs the pgfirstaid_audit.py Python script via uv against the +# target database, mirroring the CI PR audit step. +# +# The script fetches pgFirstAid.sql from GitHub, connects to the +# database, runs the full audit, and prints a markdown summary. +# Exits non-zero when findings meet or exceed the configured +# severity threshold (PGFIRSTAID_FAIL_SEVERITY). +# +# Usage: +# export DATABASE_URL=postgresql://user:pass@host:5432/dbname +# ./test_pr_audit.sh +# +# Optional env vars: +# PGFIRSTAID_VERSION - git ref (default: main) +# PGFIRSTAID_FAIL_SEVERITY - CRITICAL | HIGH | MEDIUM | LOW | NONE (default: HIGH) +# +# Without a PR_NUMBER, the script prints results to stdout instead of +# posting a GitHub PR comment — exactly like workflow_dispatch runs. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PREFIX="[pr-audit]" + +# ---- Config ---- +DATABASE_URL="${DATABASE_URL:-}" +PGFIRSTAID_VERSION="${PGFIRSTAID_VERSION:-main}" +PGFIRSTAID_FAIL_SEVERITY="${PGFIRSTAID_FAIL_SEVERITY:-HIGH}" + +# ---- 0. Pre-flight checks ---- +if [ -z "$DATABASE_URL" ]; then + echo "$PREFIX ERROR: DATABASE_URL is required." + echo "" + echo " export DATABASE_URL=postgresql://user:pass@host:5432/dbname" + echo "" + echo " You can also build it from component env vars:" + echo "" + echo ' DATABASE_URL="postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}:${PGPORT}/${PGDATABASE}"' + exit 1 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "$PREFIX ERROR: uv not found. Install from https://docs.astral.sh/uv/" + exit 1 +fi + +# ---- 1. Install Python dependencies ---- +echo "$PREFIX Installing psycopg2-binary via uv..." +uv sync 2>/dev/null || true +uv pip install --quiet psycopg2-binary 2>/dev/null || uv pip install psycopg2-binary + +# ---- 2. Run the audit script ---- +echo "$PREFIX Running pgFirstAid audit..." +echo "$PREFIX version=$PGFIRSTAID_VERSION fail=$PGFIRSTAID_FAIL_SEVERITY" +echo "" + +# The audit script requires GITHUB_TOKEN and GITHUB_REPOSITORY even for +# local runs (validate_config checks them). Set dummy values — they are +# only used for the GitHub API call, which is skipped when PR_NUMBER is +# empty (the "workflow_dispatch" path in the script). +EXIT_CODE=0 +DATABASE_URL="$DATABASE_URL" \ +GITHUB_TOKEN="local-dev-dummy" \ +GITHUB_REPOSITORY="pgfirstaid/pgfirstaid" \ +PR_NUMBER="" \ +PGFIRSTAID_VERSION="$PGFIRSTAID_VERSION" \ +PGFIRSTAID_FAIL_SEVERITY="$PGFIRSTAID_FAIL_SEVERITY" \ + uv run python "${REPO_ROOT}/workflows/pgfirstaid_audit.py" || EXIT_CODE=$? + +# ---- 3. Report ---- +echo "" +if [ "$EXIT_CODE" -ne 0 ]; then + echo "$PREFIX Audit failed with exit code $EXIT_CODE." + echo " Findings at or above PGFIRSTAID_FAIL_SEVERITY=$PGFIRSTAID_FAIL_SEVERITY were found." + exit "$EXIT_CODE" +fi + +echo "$PREFIX Audit passed. No findings at or above the $PGFIRSTAID_FAIL_SEVERITY threshold." diff --git a/testing/local-workflows/test_pre_post_migration.sh b/testing/local-workflows/test_pre_post_migration.sh new file mode 100755 index 0000000..2e91170 --- /dev/null +++ b/testing/local-workflows/test_pre_post_migration.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# test_pre_post_migration.sh +# +# Replicates the pre-post-migration-validate.yml workflow locally. +# +# Flow: +# 1. Pre-migration baseline — install pgFirstAid, snapshot health +# 2. Apply test migrations — create a PK-less table + duplicate index +# 3. Post-migration validation — compare health against baseline +# 4. Critical issue count comparison — exit 1 if new CRITICALs appear +# 5. Cleanup — drop test objects +# +# The test migrations are realistic schema changes that should trigger +# "Missing Primary Key" and "Duplicate Index" health checks. +# +# Usage: +# export PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... PGDATABASE=... +# ./test_pre_post_migration.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REPORTS_DIR="$(dirname "${BASH_SOURCE[0]}")/reports" +PREFIX="[migration]" + +# ---- Config with defaults ---- +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-pgfirstaid}" +PGPASSWORD="${PGPASSWORD:-pgfirstaid}" +PGDATABASE="${PGDATABASE:-pgfirstaid}" +PGSSLMODE="${PGSSLMODE:-require}" +PGPASSFILE="$(mktemp)" +export PGPASSFILE PGSSLMODE + +echo "${PGHOST}:${PGPORT}:${PGDATABASE}:${PGUSER}:${PGPASSWORD}" > "$PGPASSFILE" +chmod 600 "$PGPASSFILE" +_cleanup_passfile() { rm -f "$PGPASSFILE"; } +trap _cleanup_passfile EXIT + +PSQL=(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -v ON_ERROR_STOP=1) + +# Schema we'll create and then drop +TEST_SCHEMA="pgfirstaid_migration_test" + +mkdir -p "$REPORTS_DIR" + +# ---- 1. Pre-migration baseline ---- +echo "$PREFIX === Step 1/5: Pre-migration baseline ===" + +# Install pgFirstAid +"${PSQL[@]}" -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements" +"${PSQL[@]}" -c "DROP FUNCTION IF EXISTS pg_firstAid()" +"${PSQL[@]}" -f "${REPO_ROOT}/pgFirstAid.sql" + +# Capture baseline (single session — temp tables die with the connection) +"${PSQL[@]}" < "$REPORTS_DIR/post_migration_severity.csv" +"${PSQL[@]}" --csv \ + -c "SELECT severity, check_name, object_name, issue_description, recommended_action FROM pg_firstAid() ORDER BY severity, check_name;" \ + > "$REPORTS_DIR/post_migration_health.csv" +echo "$PREFIX Post-migration reports saved to ${REPORTS_DIR}/" + +# ---- 5. Cleanup ---- +echo "" +echo "$PREFIX === Step 5/5: Cleanup ===" + +"${PSQL[@]}" < -> Job failed — findings at or above `HIGH` threshold were found. +> Job failed - findings at or above `HIGH` threshold were found. ``` --- -### 2. `pre-post-migration-validate.yml` — **Migration Safety Gate** +### 2. `pre-post-migration-validate.yml` - **Migration Safety Gate** -Validates database health before and after migrations. This is the **only workflow that gates deployments** — it blocks if migrations introduce new critical issues. +Validates database health before and after migrations against your **existing staging database**. This is the **only workflow that gates deployments** - it blocks if migrations introduce new critical issues. **Use Case:** Essential for any project with database migrations. Add to your CI to prevent migration regressions. @@ -133,9 +133,46 @@ jobs: environment: staging ``` +**Alternative:** For an isolated copy of your data without affecting staging, see [Neon Before/After Validation](#3-neon-before-after-validateyml--neon-isolated-branch-validation) below - it creates an instant Neon branch so the comparison runs on a production clone with zero side effects. + --- -### 3. `db-health-checks.yml` — **Scheduled Health Monitoring** +### 3. `neon-before-after-validate.yml` - **Neon Isolated Branch Validation** + +Uses [Neon's instant database branching](https://neon.tech/docs/manage/branches) to run before/after health checks on an isolated copy of your data - no production risk. Detects regressions from **any** change that affects query behavior: SQL migrations, application code deploys, infrastructure changes, or configuration tweaks. + +**Use Case:** Deploy with confidence when you need a production-faithful copy for health comparison. Requires a Neon project. + +**Triggers:** Pull requests on `migrations/**` + workflow_dispatch + +**Key Features:** +- Creates a Neon branch (instant copy-on-write clone of your data) +- Captures pre-change health baseline +- Applies SQL changes or point your app test suite at the branch +- Compares post-change health against baseline +- Posts results as a PR comment (on PR events) +- Blocks deployment if new critical issues appear +- Deletes the Neon branch automatically (even on failure) + +**Secrets required:** + +| Secret | Value | +|---|---| +| `NEON_API_KEY` | API key from https://console.neon.tech/app/settings/api-keys | +| `NEON_PROJECT_ID` | Neon project ID from project settings page | + +**Alternative - local testing with `neonctl`:** +```bash +export NEON_API_KEY=... NEON_PROJECT_ID=... +./testing/local-workflows/test_neon_before_after.sh \ + --role-name neondb_owner \ + --database-name pgFirstAid +``` +See `testing/local-workflows/.env.example` for all configuration options. + +
+ +### 4. `db-health-checks.yml` - **Scheduled Health Monitoring** Tracks database health trends over time via daily cron. Generates full reports and compares against previous baselines to detect degradation. @@ -168,7 +205,7 @@ jobs: --- -### 4. `managed-db-validate.yml` — **Cloud Compatibility Validation** +### 5. `managed-db-validate.yml` - **Cloud Compatibility Validation** Validates pgFirstAid against a specific cloud-managed PostgreSQL instance (AWS RDS, GCP Cloud SQL, or Azure). @@ -229,9 +266,61 @@ The `pg_firstAid()` function returns health issues organized by severity: HIGH | Structural Health | Missing Statistics | orders | Statistics not updated recently | Run ANALYZE on orders table ``` +## Local Testing + +Before pushing to CI, validate against a real Neon branch locally: + +```bash +# Install neonctl +npm install -g neonctl + +# Set credentials +export NEON_API_KEY=... NEON_PROJECT_ID=... + +# Run before/after validation (creates branch, runs checks, deletes branch) +./testing/local-workflows/test_neon_before_after.sh \ + --role-name neondb_owner \ + --database-name pgFirstAid + +# Or run standalone health checks against any Postgres +./testing/local-workflows/test_db_health_checks.sh +``` + +See `testing/local-workflows/.env.example` and `testing/local-workflows/TESTING_INSTRUCTIONS.md` for setup details. + ## Integrating with Migration Tools -### Flyway +### Neon Branching (recommended for production-like validation) + +Uses a Neon branch as an isolated clone - run Flyway or Liquibase against it without touching your real database. + +```yaml +- name: Create Neon branch + uses: neondatabase/create-branch-action@v6 + id: create-branch + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + api_key: ${{ secrets.NEON_API_KEY }} + +- name: Pre-Migration Health Check + run: psql "${{ steps.create-branch.outputs.db_url }}" -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" + +- name: Apply Migrations + run: flyway migrate -url="${{ steps.create-branch.outputs.db_url }}" + +- name: Post-Migration Health Check + run: psql "${{ steps.create-branch.outputs.db_url }}" -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" + +- name: Delete Neon branch + if: always() + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: ${{ steps.create-branch.outputs.branch_id }} + api_key: ${{ secrets.NEON_API_KEY }} +``` + +### Flyway (direct against staging) ```yaml - name: Pre-Migration Health Check @@ -245,7 +334,7 @@ The `pg_firstAid()` function returns health issues organized by severity: psql -c "SELECT count(*) FROM pg_firstAid() WHERE severity='CRITICAL';" ``` -### Liquibase +### Liquibase (direct against staging) ```yaml - name: Pre-Migration Health Check @@ -284,6 +373,8 @@ CREATE EXTENSION IF NOT EXISTS pg_stat_statements; \i view_pgFirstAid.sql ``` +(For the Neon branching workflow, the install step handles this automatically.) + ### Permission errors If you don't have superuser access, use the managed view: diff --git a/workflows/neon-before-after-validate.yml b/workflows/neon-before-after-validate.yml new file mode 100644 index 0000000..ee795e8 --- /dev/null +++ b/workflows/neon-before-after-validate.yml @@ -0,0 +1,297 @@ +# Neon Before/After Validation +# +# Uses Neon's instant database branching to run before/after health checks +# on an isolated copy of your data — no production risk. Detects regressions +# from any change that affects query behavior on the target database: +# +# - SQL migrations (schema changes, index additions) +# - Application code deploys (new ORM queries, join patterns) +# - Infrastructure changes (connection pools, work_mem, PGBouncer) +# - Configuration changes (timeouts, statement timeouts) +# +# Flow: +# 1. Create a Neon branch (instant, copy-on-write clone) +# 2. Install pgFirstAid on the branch +# 3. Capture pre-change health baseline +# 4. Apply the change (SQL files, or point your app test suite at the +# branch via the db_url output) +# 5. Capture post-change health +# 6. Compare and report new issues +# 7. Delete the Neon branch +# +# Setup: +# - Add NEON_API_KEY as a repository secret (Settings -> Actions) +# - Add NEON_PROJECT_ID as a repository variable +# - The parent branch must have representative data so the clone +# produces meaningful health check results. +# +# Required secrets: +# NEON_API_KEY -- from https://console.neon.tech/app/settings/api-keys +# +# Required variables: +# NEON_PROJECT_ID -- from your Neon project's Settings page +# +# Change files: +# When triggered on pull_request, the workflow auto-discovers .sql files +# under migrations/ and applies them as the change. For app or infra +# changes, use workflow_dispatch and either: +# (a) pass your SQL files via the `change_files` input, or +# (b) leave it blank and apply changes outside this workflow +# (you only need the pre- and post- comparison jobs). + +name: Neon Before/After Validation + +on: + pull_request: + paths: + - 'migrations/**' + - 'pgFirstAid.sql' + - 'view_pgFirstAid.sql' + - 'view_pgFirstAid_managed.sql' + workflow_dispatch: + inputs: + branch_name: + description: 'Neon branch name (default: auto-generated)' + required: false + default: '' + change_files: + description: 'Space-separated SQL files to apply as the change (optional)' + required: false + default: '' + +concurrency: + group: neon-beforeafter-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +env: + PGFIRSTAID_FAIL_SEVERITY: HIGH + BRANCH_PREFIX: pgfa-beforeafter + +jobs: + before-after-validation: + name: Before/After Health Validation + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: 'ephemeral' + + - name: Set branch name + id: branch-name + run: | + if [ -n "${{ inputs.branch_name }}" ]; then + echo "name=${{ inputs.branch_name }}" >> "$GITHUB_OUTPUT" + else + echo "name=${BRANCH_PREFIX}-pr-${{ github.event.number || github.run_id }}-$(date +%s)" >> "$GITHUB_OUTPUT" + fi + + - name: Create Neon branch + id: create-branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: ${{ steps.branch-name.outputs.name }} + api_key: ${{ secrets.NEON_API_KEY }} + expires_at: ${{ fromJSON('{"push":"","pull_request":""}')[github.event_name] || '' }} + + - name: Install pgFirstAid on branch + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + \i pgFirstAid.sql + \i view_pgFirstAid.sql + \i view_pgFirstAid_managed.sql + EOF + + - name: Pre-change baseline + id: baseline + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE UNLOGGED TABLE _pre_change_snapshot AS SELECT * FROM pg_firstAid(); + \echo '=== PRE-CHANGE HEALTH BASELINE ===' + \echo '' + SELECT severity, COUNT(*) as issue_count + FROM _pre_change_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + \echo '' + SELECT severity, check_name, object_name, issue_description + FROM _pre_change_snapshot + WHERE severity IN ('CRITICAL', 'HIGH') + ORDER BY severity, check_name; + EOF + + - name: Apply changes + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + echo "========================================================" + echo " ⬇ BEFORE / AFTER BOUNDARY ⬇" + echo "========================================================" + echo "" + echo " Pre-change baseline captured above." + echo "" + echo " The change being tested:" + echo " ${{ github.event_name == 'pull_request' && format('PR #{0}', github.event.number) || 'manual dispatch' }}" + echo "" + echo " Now applying SQL changes..." + echo "" + + if [ -n "${{ inputs.change_files }}" ]; then + FILES="${{ inputs.change_files }}" + else + FILES=$(find migrations/ -name '*.sql' -type f 2>/dev/null | sort || echo "") + fi + + if [ -z "$FILES" ]; then + echo "::warning::No SQL files found in migrations/ or via change_files input." + echo "::warning::If your change requires SQL run it outside this workflow." + echo "::warning::Post-change comparison will run against unmodified branch." + else + for f in $FILES; do + echo "Applying: $f" + psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$f" + done + fi + + - name: Post-change comparison + id: comparison + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE TEMP TABLE _post_change_snapshot AS SELECT * FROM pg_firstAid(); + + \echo '=== POST-CHANGE HEALTH ===' + \echo '' + SELECT severity, COUNT(*) as issue_count + FROM _post_change_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + + \echo '' + \echo '=== NEW ISSUES SINCE BASELINE ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _post_change_snapshot + WHERE (check_name, object_name) NOT IN ( + SELECT check_name, object_name FROM _pre_change_snapshot + ) + ORDER BY severity, check_name; + EOF + + - name: Compare critical counts + id: gate + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + BASELINE_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM _pre_change_snapshot WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + CURRENT_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM pg_firstAid() WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + + echo "baseline=$BASELINE_CRITICAL" >> "$GITHUB_OUTPUT" + echo "current=$CURRENT_CRITICAL" >> "$GITHUB_OUTPUT" + echo "diff=$((CURRENT_CRITICAL - BASELINE_CRITICAL))" >> "$GITHUB_OUTPUT" + + echo "Baseline CRITICAL: $BASELINE_CRITICAL" + echo "Current CRITICAL: $CURRENT_CRITICAL" + + if [ "$CURRENT_CRITICAL" -gt "$BASELINE_CRITICAL" ]; then + echo "status=blocked" >> "$GITHUB_OUTPUT" + echo "::error::Change introduces $((CURRENT_CRITICAL - BASELINE_CRITICAL)) new critical issue(s)!" + else + echo "status=passed" >> "$GITHUB_OUTPUT" + fi + + - name: Post PR comment + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + with: + script: | + const baseline = ${{ steps.gate.outputs.baseline }}; + const current = ${{ steps.gate.outputs.current }}; + const diff = ${{ steps.gate.outputs.diff }}; + const status = '${{ steps.gate.outputs.status }}'; + + const body = [ + '', + '### Neon Before/After Validation', + '', + '| Metric | Value |', + '|--------|-------|', + '| Branch ID | `${{ steps.create-branch.outputs.branch_id }}` |', + '| Baseline CRITICAL | ' + baseline + ' |', + '| Post-change CRITICAL | ' + current + ' |', + '| New CRITICAL issues | ' + diff + ' |', + '', + status === 'passed' + ? '**No new critical issues -- safe to deploy.**' + : '**Change introduces ' + diff + ' new critical issue(s) -- review before merging.**', + '', + '> Neon branch deleted automatically after workflow completion.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes('pgfirstaid-neon-beforeafter')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Delete Neon branch + if: always() + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: ${{ steps.create-branch.outputs.branch_id }} + api_key: ${{ secrets.NEON_API_KEY }} + + - name: Final gate + if: steps.gate.outputs.status == 'blocked' + run: | + echo "::error::Change blocked: ${{ steps.gate.outputs.diff }} new critical issue(s) introduced." + exit 1 From dae79350116600d4247653cb7bd33f35994094e3 Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:44:03 +0000 Subject: [PATCH 3/9] fix(ci): resolve greptile comments/review suggestions --- workflows/db-health-checks.yml | 2 +- workflows/neon-before-after-validate.yml | 1 - workflows/pgfirstaid-pr-audit.yml | 29 ++++++----------------- workflows/pre-post-migration-validate.yml | 8 ++----- 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/workflows/db-health-checks.yml b/workflows/db-health-checks.yml index c6dc949..68e9455 100644 --- a/workflows/db-health-checks.yml +++ b/workflows/db-health-checks.yml @@ -129,7 +129,7 @@ jobs: - name: Generate CSV Report run: | - psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" \ + psql -h "${{ env.PGHOST }}" -U "${{ env.PGUSER }}" -d "${{ env.PGDATABASE }}" --csv \ -c "CREATE TEMP TABLE _health_snapshot AS SELECT * FROM pg_firstAid();" \ -c "SELECT * FROM _health_snapshot ORDER BY severity, check_name;" \ > "${{ github.workspace }}/full_health_check.csv" diff --git a/workflows/neon-before-after-validate.yml b/workflows/neon-before-after-validate.yml index ee795e8..9ddf489 100644 --- a/workflows/neon-before-after-validate.yml +++ b/workflows/neon-before-after-validate.yml @@ -104,7 +104,6 @@ jobs: project_id: ${{ vars.NEON_PROJECT_ID }} branch_name: ${{ steps.branch-name.outputs.name }} api_key: ${{ secrets.NEON_API_KEY }} - expires_at: ${{ fromJSON('{"push":"","pull_request":""}')[github.event_name] || '' }} - name: Install pgFirstAid on branch env: diff --git a/workflows/pgfirstaid-pr-audit.yml b/workflows/pgfirstaid-pr-audit.yml index fa6e5d8..e4b356c 100644 --- a/workflows/pgfirstaid-pr-audit.yml +++ b/workflows/pgfirstaid-pr-audit.yml @@ -1,8 +1,8 @@ -# pgFirstAid staging database health audit. +# pgFirstAid PR Audit # -# Runs on every pull request update and posts a summary of database health -# findings as a PR comment. Re-runs update the same comment rather than -# spamming new ones. +# Runs pgFirstAid against a staging DB on every PR update and posts a severity +# summary as a PR comment. Re-runs update the same comment rather than spamming +# new ones. # # Setup: # 1. Copy this file to .github/workflows/pgfirstaid-audit.yml in your repo. @@ -10,25 +10,10 @@ # 3. Add STAGING_DATABASE_URL as a repository secret (Settings → Secrets). # 4. Adjust PGFIRSTAID_FAIL_SEVERITY below to match your team's tolerance. # -# STAGING_DATABASE_URL format: -# postgresql://user:password@host:5432/dbname -# or any libpq-compatible connection string. -# +# STAGING_DATABASE_URL format: postgresql://user:password@host:5432/dbname # The runner must have network access to your staging database. # If it lives behind a VPC, see the Tailscale GitHub Action or a self-hosted -# runner as options for establishing connectivity. - -# pgFirstAid PR Audit -# -# Focused on PR-level developer feedback: runs pgFirstAid against a staging DB -# on every PR update and posts a severity summary as a PR comment. -# This is the only workflow in the suite that targets pull requests directly. -# -# Setup: -# 1. Copy this file to .github/workflows/pgfirstaid-audit.yml in your repo. -# 2. Copy pgfirstaid_audit.py to .github/scripts/pgfirstaid_audit.py. -# 3. Add STAGING_DATABASE_URL as a repository secret (Settings → Secrets). -# 4. Adjust PGFIRSTAID_FAIL_SEVERITY to match your team's tolerance. +# runner for connectivity. name: pgFirstAid Staging Audit @@ -77,4 +62,4 @@ jobs: PGFIRSTAID_VERSION: ${{ env.PGFIRSTAID_VERSION }} PGFIRSTAID_FAIL_SEVERITY: ${{ env.PGFIRSTAID_FAIL_SEVERITY }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: python .github/scripts/pgfirstaid_audit.py + run: uv run python .github/scripts/pgfirstaid_audit.py diff --git a/workflows/pre-post-migration-validate.yml b/workflows/pre-post-migration-validate.yml index 3fa86b9..701e269 100644 --- a/workflows/pre-post-migration-validate.yml +++ b/workflows/pre-post-migration-validate.yml @@ -347,14 +347,10 @@ jobs: echo "" >> summary.md BASELINE_CRIT=$(cat baseline/baseline_critical_count.txt 2>/dev/null || echo "N/A") echo "Baseline: $BASELINE_CRIT critical issues" >> summary.md - echo "Current: $(psql -h ${{ secrets.PGHOST }} -U ${{ secrets.PGUSER }} -d ${{ secrets.PGDATABASE }} -t -c 'SELECT count(*) FROM pg_firstAid() WHERE severity = '\''CRITICAL'\'';' | tr -d '[:space:]') critical issues" >> summary.md + CURRENT_CRIT=$(awk -F',' '$1 == "CRITICAL" {print $2}' post-migration/post_migration_severity.csv 2>/dev/null || echo "N/A") + echo "Current: $CURRENT_CRIT critical issues" >> summary.md cat summary.md - env: - PGHOST: ${{ secrets.PGHOST }} - PGUSER: ${{ secrets.PGUSER }} - PGDATABASE: ${{ secrets.PGDATABASE }} - PGPASSWORD: ${{ secrets.PGPASSWORD }} - name: Upload Summary uses: actions/upload-artifact@v4 From 210fe440121ed8ea437e757adc64bd50d8dbcf04 Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:48:10 +0000 Subject: [PATCH 4/9] fix(ci): updating ci due to invalid labels --- .github/PULL_REQUEST_TEMPLATE.md | 15 + .github/workflows/.release-drafter.yml.swp | Bin 0 -> 1024 bytes .../workflows/neon-before-after-validate.yml | 296 ++++++++++++++++++ .github/workflows/nixos-local-test.yml | 147 +++++++++ 4 files changed, 458 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/.release-drafter.yml.swp create mode 100644 .github/workflows/neon-before-after-validate.yml create mode 100644 .github/workflows/nixos-local-test.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f8fd27f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + + + +## What's in this PR + + + +## Notes to reviewers + + + +## Testing + + diff --git a/.github/workflows/.release-drafter.yml.swp b/.github/workflows/.release-drafter.yml.swp new file mode 100644 index 0000000000000000000000000000000000000000..43a4e1b9be159206ea27fa6cb0add27f2d75e172 GIT binary patch literal 1024 zcmYc?$V<%2S1{KzVn6{Z#@Y-;iFqmcd8w&InR)3bl4zWi)G`A@G%;-4qSTz!#Nt%l fl%mA6lGGx-%G?}m%0|UTLtr!nXcGdV=*9v7m{}3u literal 0 HcmV?d00001 diff --git a/.github/workflows/neon-before-after-validate.yml b/.github/workflows/neon-before-after-validate.yml new file mode 100644 index 0000000..9ddf489 --- /dev/null +++ b/.github/workflows/neon-before-after-validate.yml @@ -0,0 +1,296 @@ +# Neon Before/After Validation +# +# Uses Neon's instant database branching to run before/after health checks +# on an isolated copy of your data — no production risk. Detects regressions +# from any change that affects query behavior on the target database: +# +# - SQL migrations (schema changes, index additions) +# - Application code deploys (new ORM queries, join patterns) +# - Infrastructure changes (connection pools, work_mem, PGBouncer) +# - Configuration changes (timeouts, statement timeouts) +# +# Flow: +# 1. Create a Neon branch (instant, copy-on-write clone) +# 2. Install pgFirstAid on the branch +# 3. Capture pre-change health baseline +# 4. Apply the change (SQL files, or point your app test suite at the +# branch via the db_url output) +# 5. Capture post-change health +# 6. Compare and report new issues +# 7. Delete the Neon branch +# +# Setup: +# - Add NEON_API_KEY as a repository secret (Settings -> Actions) +# - Add NEON_PROJECT_ID as a repository variable +# - The parent branch must have representative data so the clone +# produces meaningful health check results. +# +# Required secrets: +# NEON_API_KEY -- from https://console.neon.tech/app/settings/api-keys +# +# Required variables: +# NEON_PROJECT_ID -- from your Neon project's Settings page +# +# Change files: +# When triggered on pull_request, the workflow auto-discovers .sql files +# under migrations/ and applies them as the change. For app or infra +# changes, use workflow_dispatch and either: +# (a) pass your SQL files via the `change_files` input, or +# (b) leave it blank and apply changes outside this workflow +# (you only need the pre- and post- comparison jobs). + +name: Neon Before/After Validation + +on: + pull_request: + paths: + - 'migrations/**' + - 'pgFirstAid.sql' + - 'view_pgFirstAid.sql' + - 'view_pgFirstAid_managed.sql' + workflow_dispatch: + inputs: + branch_name: + description: 'Neon branch name (default: auto-generated)' + required: false + default: '' + change_files: + description: 'Space-separated SQL files to apply as the change (optional)' + required: false + default: '' + +concurrency: + group: neon-beforeafter-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +env: + PGFIRSTAID_FAIL_SEVERITY: HIGH + BRANCH_PREFIX: pgfa-beforeafter + +jobs: + before-after-validation: + name: Before/After Health Validation + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up PostgreSQL client + uses: shogo82148/actions-setup-postgres@v1 + with: + postgres-version: 16 + postgres-password: 'ephemeral' + + - name: Set branch name + id: branch-name + run: | + if [ -n "${{ inputs.branch_name }}" ]; then + echo "name=${{ inputs.branch_name }}" >> "$GITHUB_OUTPUT" + else + echo "name=${BRANCH_PREFIX}-pr-${{ github.event.number || github.run_id }}-$(date +%s)" >> "$GITHUB_OUTPUT" + fi + + - name: Create Neon branch + id: create-branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: ${{ steps.branch-name.outputs.name }} + api_key: ${{ secrets.NEON_API_KEY }} + + - name: Install pgFirstAid on branch + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + \i pgFirstAid.sql + \i view_pgFirstAid.sql + \i view_pgFirstAid_managed.sql + EOF + + - name: Pre-change baseline + id: baseline + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE UNLOGGED TABLE _pre_change_snapshot AS SELECT * FROM pg_firstAid(); + \echo '=== PRE-CHANGE HEALTH BASELINE ===' + \echo '' + SELECT severity, COUNT(*) as issue_count + FROM _pre_change_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + \echo '' + SELECT severity, check_name, object_name, issue_description + FROM _pre_change_snapshot + WHERE severity IN ('CRITICAL', 'HIGH') + ORDER BY severity, check_name; + EOF + + - name: Apply changes + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + echo "========================================================" + echo " ⬇ BEFORE / AFTER BOUNDARY ⬇" + echo "========================================================" + echo "" + echo " Pre-change baseline captured above." + echo "" + echo " The change being tested:" + echo " ${{ github.event_name == 'pull_request' && format('PR #{0}', github.event.number) || 'manual dispatch' }}" + echo "" + echo " Now applying SQL changes..." + echo "" + + if [ -n "${{ inputs.change_files }}" ]; then + FILES="${{ inputs.change_files }}" + else + FILES=$(find migrations/ -name '*.sql' -type f 2>/dev/null | sort || echo "") + fi + + if [ -z "$FILES" ]; then + echo "::warning::No SQL files found in migrations/ or via change_files input." + echo "::warning::If your change requires SQL run it outside this workflow." + echo "::warning::Post-change comparison will run against unmodified branch." + else + for f in $FILES; do + echo "Applying: $f" + psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$f" + done + fi + + - name: Post-change comparison + id: comparison + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + psql "$DB_URL" -v ON_ERROR_STOP=1 <<'EOF' + CREATE TEMP TABLE _post_change_snapshot AS SELECT * FROM pg_firstAid(); + + \echo '=== POST-CHANGE HEALTH ===' + \echo '' + SELECT severity, COUNT(*) as issue_count + FROM _post_change_snapshot + GROUP BY severity + ORDER BY + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + ELSE 5 + END; + + \echo '' + \echo '=== NEW ISSUES SINCE BASELINE ===' + SELECT severity, check_name, object_name, issue_description, recommended_action + FROM _post_change_snapshot + WHERE (check_name, object_name) NOT IN ( + SELECT check_name, object_name FROM _pre_change_snapshot + ) + ORDER BY severity, check_name; + EOF + + - name: Compare critical counts + id: gate + env: + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + BASELINE_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM _pre_change_snapshot WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + CURRENT_CRITICAL=$(psql "$DB_URL" -t -c "SELECT count(*) FROM pg_firstAid() WHERE severity = 'CRITICAL';" | tr -d '[:space:]') + + echo "baseline=$BASELINE_CRITICAL" >> "$GITHUB_OUTPUT" + echo "current=$CURRENT_CRITICAL" >> "$GITHUB_OUTPUT" + echo "diff=$((CURRENT_CRITICAL - BASELINE_CRITICAL))" >> "$GITHUB_OUTPUT" + + echo "Baseline CRITICAL: $BASELINE_CRITICAL" + echo "Current CRITICAL: $CURRENT_CRITICAL" + + if [ "$CURRENT_CRITICAL" -gt "$BASELINE_CRITICAL" ]; then + echo "status=blocked" >> "$GITHUB_OUTPUT" + echo "::error::Change introduces $((CURRENT_CRITICAL - BASELINE_CRITICAL)) new critical issue(s)!" + else + echo "status=passed" >> "$GITHUB_OUTPUT" + fi + + - name: Post PR comment + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + with: + script: | + const baseline = ${{ steps.gate.outputs.baseline }}; + const current = ${{ steps.gate.outputs.current }}; + const diff = ${{ steps.gate.outputs.diff }}; + const status = '${{ steps.gate.outputs.status }}'; + + const body = [ + '', + '### Neon Before/After Validation', + '', + '| Metric | Value |', + '|--------|-------|', + '| Branch ID | `${{ steps.create-branch.outputs.branch_id }}` |', + '| Baseline CRITICAL | ' + baseline + ' |', + '| Post-change CRITICAL | ' + current + ' |', + '| New CRITICAL issues | ' + diff + ' |', + '', + status === 'passed' + ? '**No new critical issues -- safe to deploy.**' + : '**Change introduces ' + diff + ' new critical issue(s) -- review before merging.**', + '', + '> Neon branch deleted automatically after workflow completion.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.includes('pgfirstaid-neon-beforeafter')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Delete Neon branch + if: always() + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: ${{ steps.create-branch.outputs.branch_id }} + api_key: ${{ secrets.NEON_API_KEY }} + + - name: Final gate + if: steps.gate.outputs.status == 'blocked' + run: | + echo "::error::Change blocked: ${{ steps.gate.outputs.diff }} new critical issue(s) introduced." + exit 1 diff --git a/.github/workflows/nixos-local-test.yml b/.github/workflows/nixos-local-test.yml new file mode 100644 index 0000000..3b09af5 --- /dev/null +++ b/.github/workflows/nixos-local-test.yml @@ -0,0 +1,147 @@ +# NixOS Runner — Local Test Workflow +# +# Runs all four test suites (health checks, managed DB validation, migration +# validation, PR audit) on the self-hosted NixOS runner. Point it at any of +# your PG15-PG18 instances by selecting the postgres_version. +# +# The NixOS runner already has psql, uv, and network access to the test PG +# instances (Neon / DigitalOcean). No Docker or local PG install needed. +# +# Usage: +# 1. Go to Actions → NixOS Local Test Workflow → Run workflow +# 2. Select postgres_version (15/16/17/18) +# 3. Optionally set cloud_provider label for the managed DB test +# +# The cloud auth steps (AWS/GCP/Azure CLI) in managed-db-validate.yml are +# NOT replicated here — they require real cloud credentials and live cloud +# DBs, and remain manual-dispatch only in CI. + +name: NixOS Local Test Workflow + +on: + workflow_dispatch: + inputs: + postgres_version: + description: 'PostgreSQL major version to test against' + required: true + type: choice + options: + - '15' + - '16' + - '17' + - '18' + default: '16' + cloud_provider: + description: 'Cloud provider label (for managed DB test output)' + required: false + type: choice + options: + - direct + - aws + - gcp + - azure + default: 'direct' + +concurrency: + group: nixos-local-test-${{ inputs.postgres_version }}-${{ github.run_id }} + cancel-in-progress: true + +jobs: + local-test: + runs-on: [self-hosted, nix, nixos, x86_64-linux] + permissions: + contents: read + defaults: + run: + shell: bash -l {0} + + env: + PG_VERSION: ${{ inputs.postgres_version }} + # Connection — map the selected version to secrets + PGHOST: ${{ inputs.postgres_version == '15' && secrets.PG15_HOST || inputs.postgres_version == '16' && secrets.PG16_HOST || inputs.postgres_version == '17' && secrets.PG17_HOST || secrets.PG18_HOST }} + PGPORT: ${{ inputs.postgres_version == '15' && secrets.PG15_PORT || inputs.postgres_version == '16' && secrets.PG16_PORT || inputs.postgres_version == '17' && secrets.PG17_PORT || secrets.PG18_PORT }} + PGUSER: ${{ inputs.postgres_version == '15' && secrets.PG15_USER || inputs.postgres_version == '16' && secrets.PG16_USER || inputs.postgres_version == '17' && secrets.PG17_USER || secrets.PG18_USER }} + PGPASSWORD: ${{ inputs.postgres_version == '15' && secrets.PG15_PASSWORD || inputs.postgres_version == '16' && secrets.PG16_PASSWORD || inputs.postgres_version == '17' && secrets.PG17_PASSWORD || secrets.PG18_PASSWORD }} + PGDATABASE: ${{ inputs.postgres_version == '15' && secrets.PG15_DATABASE || inputs.postgres_version == '16' && secrets.PG16_DATABASE || inputs.postgres_version == '17' && secrets.PG17_DATABASE || secrets.PG18_DATABASE }} + PGSSLMODE: require + DATABASE_URL: ${{ inputs.postgres_version == '15' && secrets.PG15_DATABASE_URL || inputs.postgres_version == '16' && secrets.PG16_DATABASE_URL || inputs.postgres_version == '17' && secrets.PG17_DATABASE_URL || secrets.PG18_DATABASE_URL }} + CLOUD_PROVIDER: ${{ inputs.cloud_provider }} + PGFIRSTAID_FAIL_SEVERITY: HIGH + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Add Nix profile paths + run: | + echo "/run/current-system/sw/bin" >> "$GITHUB_PATH" + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH" + echo "/etc/profiles/per-user/$USER/bin" >> "$GITHUB_PATH" + + - name: Validate PG connection vars + run: | + missing=0 + for var in PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE; do + if [ -z "${!var}" ]; then + echo "::error::Missing required secret/env: ${var} for PG${{ inputs.postgres_version }}" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then exit 1; fi + echo "Target: PG${{ inputs.postgres_version }} @ $PGHOST:$PGPORT" + + - name: Verify psql + uv + run: | + command -v psql >/dev/null 2>&1 || { echo "::error::psql not found"; exit 1; } + psql --version + command -v uv >/dev/null 2>&1 || { echo "::error::uv not found"; exit 1; } + uv --version + + - name: Test — Health Check Suite + working-directory: testing/local-workflows + run: | + mkdir -p reports + ./test_db_health_checks.sh + + - name: Test — Managed DB Validation + working-directory: testing/local-workflows + run: | + ./test_managed_db_validate.sh + + - name: Test — Pre/Post Migration Validation + working-directory: testing/local-workflows + run: | + ./test_pre_post_migration.sh + continue-on-error: true + + - name: Test — PR Audit Script + working-directory: testing/local-workflows + run: | + uv pip install --quiet psycopg2-binary 2>/dev/null || uv pip install psycopg2-binary + ./test_pr_audit.sh + continue-on-error: true + + - name: Upload reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-reports-pg${{ inputs.postgres_version }} + path: testing/local-workflows/reports/ + retention-days: 14 + + - name: Post summary + if: always() + run: | + { + echo "## NixOS Local Test Results — PG${{ inputs.postgres_version }}" + echo "" + echo "| Suite | Status |" + echo "|-------|--------|" + echo "| Health Check | ${{ job.status == 'success' && '✅' || '❌' }} |" + echo "| Managed DB Validate | ${{ job.status == 'success' && '✅' || '❌' }} |" + echo "| Migration Validate | ${{ job.status == 'success' && '✅' || '❌' }} |" + echo "| PR Audit | ${{ job.status == 'success' && '✅' || '❌' }} |" + echo "" + echo "Cloud provider label: ${{ inputs.cloud_provider }}" + } >> "$GITHUB_STEP_SUMMARY" From 3cb89da53e6f670f0dfd0ee2178f51e1f40adb79 Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:50:07 +0000 Subject: [PATCH 5/9] fix(ci): resolve greptile comments/review suggestions --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 9da86c6..5787eba 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -20,7 +20,7 @@ jobs: if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork == false permissions: pull-requests: write - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, pgfirstaid-ci] steps: - name: Apply release-drafter labels uses: release-drafter/release-drafter/autolabeler@v6 # v6.1.0 From c901f82eaaa9884a1bee88f1273e2e76ac89c9db Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:52:29 +0000 Subject: [PATCH 6/9] fix(ci): added release-drafter --- .github/workflows/release-drafter.yml | 33 +++++---------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5787eba..982056c 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -2,39 +2,18 @@ name: Release Drafter on: push: - branches: - - main - pull_request_target: - branches: - - main - types: - - opened - - reopened - - synchronize + branches: [main] + pull_request: + types: [opened, reopened, synchronize] permissions: contents: read + pull-requests: write jobs: - autolabel-pr: - if: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork == false - permissions: - pull-requests: write - runs-on: [self-hosted, linux, pgfirstaid-ci] - steps: - - name: Apply release-drafter labels - uses: release-drafter/release-drafter/autolabeler@v6 # v6.1.0 - - update-release-draft: - if: github.event_name == 'push' - permissions: - contents: write - pull-requests: read + update: runs-on: [self-hosted, nix, nixos, x86_64-linux] steps: - - name: Draft release notes - uses: release-drafter/release-drafter@v6 # v6.1.0 - with: - config-name: release-drafter.yml + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 0dc961398978ac702b3c80aac2ac9362051cd372 Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:52:50 +0000 Subject: [PATCH 7/9] fix(ci): added release-drafter --- .github/PULL_REQUEST_TEMPLATE.md | 15 --------------- .github/workflows/.release-drafter.yml.swp | Bin 1024 -> 0 bytes 2 files changed, 15 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/workflows/.release-drafter.yml.swp diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index f8fd27f..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,15 +0,0 @@ -## Summary - - - -## What's in this PR - - - -## Notes to reviewers - - - -## Testing - - diff --git a/.github/workflows/.release-drafter.yml.swp b/.github/workflows/.release-drafter.yml.swp deleted file mode 100644 index 43a4e1b9be159206ea27fa6cb0add27f2d75e172..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1024 zcmYc?$V<%2S1{KzVn6{Z#@Y-;iFqmcd8w&InR)3bl4zWi)G`A@G%;-4qSTz!#Nt%l fl%mA6lGGx-%G?}m%0|UTLtr!nXcGdV=*9v7m{}3u From 61398ca92b1331a1f029c344ad2fe1b4b10a8daf Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 01:55:57 +0000 Subject: [PATCH 8/9] fix(ci): downgrading drafter to v5 --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 982056c..6882756 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -14,6 +14,6 @@ jobs: update: runs-on: [self-hosted, nix, nixos, x86_64-linux] steps: - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From a2679f7dde6530dafada2d7c6549da6e57dbe3d4 Mon Sep 17 00:00:00 2001 From: randoneering Date: Fri, 19 Jun 2026 02:05:40 +0000 Subject: [PATCH 9/9] fix(ci): downgrading drafter to v5 --- .github/release-drafter.yml | 37 +-------------------------- .github/workflows/release-drafter.yml | 1 + 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 690bc81..164c33b 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,42 +1,7 @@ name-template: "v$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" -autolabeler: - - label: "feat" - branch: - - '/feat\/.+/' - - '/feature\/.+/' - - label: "fix" - branch: - - '/fix\/.+/' - - label: "docs" - branch: - - '/doc\/.+/' - - '/docs\/.+/' - - label: "test" - branch: - - '/test\/.+/' - - label: "refactor" - branch: - - '/refactor\/.+/' - - label: "chore" - branch: - - '/chore\/.+/' - - label: "perf" - branch: - - '/perf\/.+/' - - label: "style" - branch: - - '/style\/.+/' - - label: "ci" - branch: - - '/ci\/.+/' - - label: "build" - branch: - - '/build\/.+/' - - label: "revert" - branch: - - '/revert\/.+/' +# Autolabeling handled by .github/workflows/release-drafter.yml on the NixOS runner. categories: - title: "Features" diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 6882756..d5219c5 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + branches: [main] types: [opened, reopened, synchronize] permissions: