feat(postcodes/PH): bulk-import 1,391 Philippines postcodes via PHLPost (#1039) #92
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Validator | |
| on: | |
| pull_request: | |
| types: [opened, edited, synchronize] | |
| paths: | |
| - 'contributions/**' | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| contents: read | |
| concurrency: | |
| group: pr-validator-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| validate: | |
| name: Validate Contribution | |
| runs-on: ubuntu-latest | |
| if: github.event.pull_request.draft == false | |
| steps: | |
| - name: Remove stale label on new activity | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| name: 'stale', | |
| }); | |
| core.info('Removed stale label due to new activity.'); | |
| } catch { | |
| // Label was not present, that is fine | |
| } | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Install dependencies | |
| run: cd .github/scripts && npm ci --omit=dev | |
| # Stage 1: PR Format Check (Idea 1 & 5) | |
| - name: Validate PR format | |
| id: pr_format | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/validate-pr-format.js | |
| # Stage 2 & 3: Diff Analysis - Auto-label + Critical Detection (Ideas 6, 12, 13) | |
| - name: Analyse diff | |
| id: diff | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/analyse-diff.js | |
| # Stage 4 & 5: JSON Lint + Schema Validation (Ideas 2 & 15) | |
| - name: Validate schema | |
| id: schema | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/validate-schema.js | |
| # Stage 6: Cross-Reference Validation (Idea 9) | |
| - name: Validate cross-references | |
| id: crossref | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/validate-cross-reference.js | |
| # Stage 7: Coordinate Bounds Check (Idea 8) | |
| - name: Check coordinate bounds | |
| id: coords | |
| continue-on-error: true | |
| run: node .github/scripts/validate-coordinates.js | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Stage 8: Duplicate Detection (Idea 7) | |
| - name: Detect duplicates | |
| id: duplicates | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/detect-duplicates.js | |
| # Stage 9: Source URL Check (Idea 16) | |
| - name: Check source URLs | |
| id: urls | |
| continue-on-error: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node .github/scripts/check-source-urls.js | |
| # Post consolidated validation report | |
| - name: Post validation report | |
| if: always() | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_FORMAT_RESULTS: ${{ steps.pr_format.outputs.results }} | |
| PR_FORMAT_NEEDS_CLARIFICATION: ${{ steps.pr_format.outputs.needs_clarification }} | |
| DIFF_LABELS: ${{ steps.diff.outputs.labels }} | |
| DIFF_IS_CRITICAL: ${{ steps.diff.outputs.is_critical }} | |
| DIFF_CRITICAL_REASONS: ${{ steps.diff.outputs.critical_reasons }} | |
| DIFF_TOTAL_RECORDS: ${{ steps.diff.outputs.total_records }} | |
| DIFF_IS_LARGE: ${{ steps.diff.outputs.is_large }} | |
| SCHEMA_ERRORS: ${{ steps.schema.outputs.errors }} | |
| SCHEMA_WARNINGS: ${{ steps.schema.outputs.warnings }} | |
| SCHEMA_RECORD_COUNT: ${{ steps.schema.outputs.record_count }} | |
| SCHEMA_HAS_ERRORS: ${{ steps.schema.outputs.has_errors }} | |
| CROSSREF_ERRORS: ${{ steps.crossref.outputs.errors }} | |
| CROSSREF_VALID: ${{ steps.crossref.outputs.valid }} | |
| CROSSREF_HAS_ERRORS: ${{ steps.crossref.outputs.has_errors }} | |
| COORD_WARNINGS: ${{ steps.coords.outputs.warnings }} | |
| COORD_CHECKED: ${{ steps.coords.outputs.checked }} | |
| DUP_WARNINGS: ${{ steps.duplicates.outputs.warnings }} | |
| DUP_CHECKED: ${{ steps.duplicates.outputs.checked }} | |
| URL_ERRORS: ${{ steps.urls.outputs.errors }} | |
| URL_VALID: ${{ steps.urls.outputs.valid }} | |
| with: | |
| script: | | |
| const prFormatResults = JSON.parse(process.env.PR_FORMAT_RESULTS || '{}'); | |
| const needsClarification = process.env.PR_FORMAT_NEEDS_CLARIFICATION === 'true'; | |
| const labels = JSON.parse(process.env.DIFF_LABELS || '[]'); | |
| const isCritical = process.env.DIFF_IS_CRITICAL === 'true'; | |
| const criticalReasons = JSON.parse(process.env.DIFF_CRITICAL_REASONS || '[]'); | |
| const totalRecords = parseInt(process.env.DIFF_TOTAL_RECORDS || '0'); | |
| const isLarge = process.env.DIFF_IS_LARGE === 'true'; | |
| const schemaErrors = JSON.parse(process.env.SCHEMA_ERRORS || '[]'); | |
| const schemaWarnings = JSON.parse(process.env.SCHEMA_WARNINGS || '[]'); | |
| const schemaRecordCount = parseInt(process.env.SCHEMA_RECORD_COUNT || '0'); | |
| const schemaHasErrors = process.env.SCHEMA_HAS_ERRORS === 'true'; | |
| const crossrefErrors = JSON.parse(process.env.CROSSREF_ERRORS || '[]'); | |
| const crossrefValid = parseInt(process.env.CROSSREF_VALID || '0'); | |
| const crossrefHasErrors = process.env.CROSSREF_HAS_ERRORS === 'true'; | |
| const coordWarnings = JSON.parse(process.env.COORD_WARNINGS || '[]'); | |
| const coordChecked = parseInt(process.env.COORD_CHECKED || '0'); | |
| const dupWarnings = JSON.parse(process.env.DUP_WARNINGS || '[]'); | |
| const dupChecked = parseInt(process.env.DUP_CHECKED || '0'); | |
| const urlErrors = JSON.parse(process.env.URL_ERRORS || '[]'); | |
| const urlValid = parseInt(process.env.URL_VALID || '0'); | |
| // Build report | |
| let report = '## CSC Validation Report\n\n'; | |
| // PR Format section | |
| report += '### PR Format\n'; | |
| for (const [key, val] of Object.entries(prFormatResults)) { | |
| const icon = val.pass ? ':white_check_mark:' : ':x:'; | |
| report += `- ${icon} ${val.label}\n`; | |
| } | |
| report += '\n'; | |
| // Labels | |
| if (labels.length > 0) { | |
| report += `**Labels applied:** ${labels.map(l => '`' + l + '`').join(', ')}\n\n`; | |
| } | |
| // Critical warning | |
| if (isCritical) { | |
| report += '### :rotating_light: Critical Change Detected\n'; | |
| report += '> This PR contains changes that require explicit maintainer approval.\n\n'; | |
| for (const reason of criticalReasons) { | |
| report += `- ${reason}\n`; | |
| } | |
| report += '\n'; | |
| } | |
| // Large contribution warning | |
| if (isLarge) { | |
| report += '### :warning: Large Contribution\n'; | |
| report += `> This PR contains ${totalRecords} records. Large contributions require manual review.\n\n`; | |
| } | |
| // Schema Validation | |
| report += '### Schema Validation'; | |
| if (schemaRecordCount > 0) { | |
| report += ` (${schemaRecordCount} records)\n`; | |
| } else { | |
| report += '\n'; | |
| } | |
| if (schemaErrors.length > 0) { | |
| report += '\n**Errors (blocking):**\n'; | |
| for (const err of schemaErrors.slice(0, 20)) { | |
| report += `- :x: ${err}\n`; | |
| } | |
| if (schemaErrors.length > 20) { | |
| report += `- _...and ${schemaErrors.length - 20} more errors_\n`; | |
| } | |
| } | |
| if (schemaWarnings.length > 0) { | |
| report += '\n**Warnings:**\n'; | |
| for (const warn of schemaWarnings.slice(0, 10)) { | |
| report += `- :warning: ${warn}\n`; | |
| } | |
| if (schemaWarnings.length > 10) { | |
| report += `- _...and ${schemaWarnings.length - 10} more warnings_\n`; | |
| } | |
| } | |
| if (schemaErrors.length === 0 && schemaWarnings.length === 0 && schemaRecordCount > 0) { | |
| report += `:white_check_mark: All records passed validation\n`; | |
| } | |
| report += '\n'; | |
| // Cross-Reference | |
| if (crossrefValid > 0 || crossrefErrors.length > 0) { | |
| report += '### Cross-Reference Validation\n'; | |
| if (crossrefValid > 0) { | |
| report += `:white_check_mark: ${crossrefValid} reference(s) verified\n`; | |
| } | |
| for (const err of crossrefErrors.slice(0, 10)) { | |
| report += `- :x: ${err}\n`; | |
| } | |
| report += '\n'; | |
| } | |
| // Coordinate Bounds | |
| if (coordChecked > 0 || coordWarnings.length > 0) { | |
| report += '### Geo-Bounds Check\n'; | |
| if (coordWarnings.length > 0) { | |
| for (const warn of coordWarnings.slice(0, 10)) { | |
| report += `- :warning: ${warn}\n`; | |
| } | |
| } else { | |
| report += `:white_check_mark: All ${coordChecked} coordinate(s) within expected country bounds\n`; | |
| } | |
| report += '\n'; | |
| } | |
| // Duplicate Detection | |
| if (dupChecked > 0 || dupWarnings.length > 0) { | |
| report += '### Duplicate Detection\n'; | |
| if (dupWarnings.length > 0) { | |
| for (const warn of dupWarnings.slice(0, 10)) { | |
| report += `- :warning: ${warn}\n`; | |
| } | |
| } else { | |
| report += `:white_check_mark: No duplicates found among ${dupChecked} record(s)\n`; | |
| } | |
| report += '\n'; | |
| } | |
| // Source URLs | |
| if (urlValid > 0 || urlErrors.length > 0) { | |
| report += '### Source URL Verification\n'; | |
| if (urlValid > 0) { | |
| report += `:white_check_mark: ${urlValid} source URL(s) accessible\n`; | |
| } | |
| for (const err of urlErrors) { | |
| report += `- :warning: ${err}\n`; | |
| } | |
| report += '\n'; | |
| } | |
| // Summary | |
| const totalErrors = schemaErrors.length + crossrefErrors.length; | |
| const totalWarnings = schemaWarnings.length + coordWarnings.length + dupWarnings.length + urlErrors.length; | |
| report += '---\n'; | |
| if (needsClarification) { | |
| report += ':speech_balloon: **This PR modifies data but has no linked issue or clear description.** '; | |
| report += 'Please provide context about what this change does and why, or link a related issue.\n\n'; | |
| } | |
| if (totalErrors === 0 && totalWarnings === 0) { | |
| report += ':white_check_mark: **All checks passed** | Status: Ready for review\n'; | |
| } else if (totalErrors === 0) { | |
| report += `:white_check_mark: **0 errors, ${totalWarnings} warning(s)** | Status: Ready for review (with warnings)\n`; | |
| } else { | |
| report += `:x: **${totalErrors} error(s), ${totalWarnings} warning(s)** | Status: Changes required\n`; | |
| report += '\nPlease fix the errors above and push a new commit. '; | |
| report += 'Refer to our [Contribution Guidelines](https://github.com/dr5hn/countries-states-cities-database/blob/master/.github/CONTRIBUTING.md) for details.\n'; | |
| } | |
| // Find existing bot comment to update (avoid spam) | |
| const COMMENT_MARKER = '## CSC Validation Report'; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| per_page: 100, | |
| }); | |
| const botComment = comments.find( | |
| (c) => | |
| (c.user.login === 'github-actions[bot]' || c.user.type === 'Bot') && | |
| c.body.includes(COMMENT_MARKER) | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: report, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| body: report, | |
| }); | |
| } | |
| // Apply status labels | |
| const statusLabels = []; | |
| if (totalErrors > 0) { | |
| statusLabels.push('needs-changes'); | |
| } else { | |
| statusLabels.push('ready-for-review'); | |
| } | |
| try { | |
| // Remove conflicting status labels first | |
| const labelsToRemove = ['needs-changes', 'ready-for-review'].filter( | |
| (l) => !statusLabels.includes(l) | |
| ); | |
| for (const label of labelsToRemove) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| name: label, | |
| }); | |
| } catch { | |
| // Label might not exist yet | |
| } | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.payload.pull_request.number, | |
| labels: statusLabels, | |
| }); | |
| } catch (err) { | |
| core.warning(`Could not update status labels: ${err.message}`); | |
| } | |
| # Slack notification for critical PRs | |
| - name: Notify Slack (critical) | |
| if: steps.diff.outputs.is_critical == 'true' | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| DIFF_CRITICAL_REASONS: ${{ steps.diff.outputs.critical_reasons }} | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| if (!process.env.SLACK_WEBHOOK_URL) { | |
| core.info('SLACK_WEBHOOK_URL not set. Skipping notification.'); | |
| return; | |
| } | |
| const { sendSlackMessage, buildCriticalAlert } = require('./.github/scripts/slack-notify.js'); | |
| const pr = context.payload.pull_request; | |
| const reasons = JSON.parse(process.env.DIFF_CRITICAL_REASONS || '[]'); | |
| await sendSlackMessage(process.env.SLACK_WEBHOOK_URL, buildCriticalAlert({ | |
| prNumber: pr.number, | |
| prTitle: pr.title, | |
| prUrl: pr.html_url, | |
| author: pr.user.login, | |
| reasons, | |
| })); | |
| # Slack notification for large contributions | |
| - name: Notify Slack (large contribution) | |
| if: steps.diff.outputs.is_large == 'true' | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| DIFF_TOTAL_RECORDS: ${{ steps.diff.outputs.total_records }} | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| if (!process.env.SLACK_WEBHOOK_URL) { | |
| core.info('SLACK_WEBHOOK_URL not set. Skipping notification.'); | |
| return; | |
| } | |
| const { sendSlackMessage, buildLargeContributionWarning } = require('./.github/scripts/slack-notify.js'); | |
| const pr = context.payload.pull_request; | |
| await sendSlackMessage(process.env.SLACK_WEBHOOK_URL, buildLargeContributionWarning({ | |
| prNumber: pr.number, | |
| prTitle: pr.title, | |
| prUrl: pr.html_url, | |
| author: pr.user.login, | |
| recordCount: process.env.DIFF_TOTAL_RECORDS, | |
| })); |