OpenAPI Reference Validation #559
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: OpenAPI Reference Validation | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| - docs-v2 | |
| push: | |
| branches: | |
| - main | |
| - docs-v2 | |
| schedule: | |
| - cron: '35 4 * * *' | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| audit: | |
| name: openapi-reference-audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'npm' | |
| cache-dependency-path: tests/package-lock.json | |
| - name: Install test dependencies | |
| env: | |
| PUPPETEER_SKIP_DOWNLOAD: 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2; do | |
| echo "npm ci attempt ${attempt}/2" | |
| if timeout 8m npm --prefix tests ci --no-audit --no-fund; then | |
| exit 0 | |
| else | |
| status=$? | |
| fi | |
| if [ "$status" -eq 124 ]; then | |
| echo "::warning::npm ci timed out after 8 minutes (attempt ${attempt}/2)." | |
| else | |
| echo "::warning::npm ci failed with exit code ${status} (attempt ${attempt}/2)." | |
| fi | |
| if [ "$attempt" -lt 2 ]; then | |
| sleep 20 | |
| fi | |
| done | |
| echo "::error::Failed to install test dependencies after 2 attempts." | |
| exit 1 | |
| - name: Run strict OpenAPI audit (initial) | |
| id: initial_audit | |
| run: | | |
| set +e | |
| node tests/integration/openapi-reference-audit.js \ | |
| --full \ | |
| --strict \ | |
| --report /tmp/openapi-audit-initial.md \ | |
| --report-json /tmp/openapi-audit-initial.json | |
| EXIT_CODE=$? | |
| echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Apply safe autofix (non-PR) | |
| id: autofix | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| set +e | |
| node tests/integration/openapi-reference-audit.js \ | |
| --full \ | |
| --fix \ | |
| --write \ | |
| --report /tmp/openapi-audit-fix.md \ | |
| --report-json /tmp/openapi-audit-fix.json | |
| FIX_EXIT_CODE=$? | |
| echo "exit_code=$FIX_EXIT_CODE" >> "$GITHUB_OUTPUT" | |
| if [ "$FIX_EXIT_CODE" -eq 0 ] && ! git diff --quiet; then | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| exit 0 | |
| - name: Run strict OpenAPI audit (final) | |
| id: final_audit | |
| run: | | |
| set +e | |
| node tests/integration/openapi-reference-audit.js \ | |
| --full \ | |
| --strict \ | |
| --report /tmp/openapi-audit-final.md \ | |
| --report-json /tmp/openapi-audit-final.json | |
| EXIT_CODE=$? | |
| echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Summarize audit results | |
| id: summary | |
| run: | | |
| node <<'NODE' | |
| const fs = require('fs'); | |
| const summaryPath = process.env.GITHUB_STEP_SUMMARY; | |
| const outputPath = process.env.GITHUB_OUTPUT; | |
| const reportPath = '/tmp/openapi-audit-final.json'; | |
| const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; | |
| let report = null; | |
| if (fs.existsSync(reportPath)) { | |
| report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); | |
| } | |
| const totalFailures = report?.summary?.totalFailures ?? 0; | |
| const totalReferences = report?.summary?.totalReferences ?? 0; | |
| const totalFiles = report?.summary?.totalFiles ?? 0; | |
| const findings = Array.isArray(report?.findings) ? report.findings : []; | |
| const topFindings = findings | |
| .slice(0, 20) | |
| .map((finding) => { | |
| const ref = finding.reference ? ` (${finding.reference})` : ''; | |
| return `- ${finding.type}: ${finding.file}:${finding.line}${ref}`; | |
| }) | |
| .join('\n') || '- none'; | |
| fs.appendFileSync(outputPath, `total_failures=${totalFailures}\n`); | |
| fs.appendFileSync(outputPath, `total_references=${totalReferences}\n`); | |
| fs.appendFileSync(outputPath, `total_files=${totalFiles}\n`); | |
| fs.appendFileSync(outputPath, `run_url=${runUrl}\n`); | |
| fs.appendFileSync(outputPath, `top_findings<<EOF\n${topFindings}\nEOF\n`); | |
| const lines = [ | |
| '## OpenAPI Reference Validation', | |
| '', | |
| `- Run: ${runUrl}`, | |
| `- Files analyzed: ${totalFiles}`, | |
| `- References analyzed: ${totalReferences}`, | |
| `- Findings: ${totalFailures}`, | |
| '', | |
| '### Top findings', | |
| '', | |
| topFindings, | |
| '' | |
| ]; | |
| fs.appendFileSync(summaryPath, `${lines.join('\n')}\n`); | |
| NODE | |
| - name: Upload OpenAPI audit artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: openapi-reference-audit-report | |
| path: | | |
| /tmp/openapi-audit-initial.md | |
| /tmp/openapi-audit-initial.json | |
| /tmp/openapi-audit-fix.md | |
| /tmp/openapi-audit-fix.json | |
| /tmp/openapi-audit-final.md | |
| /tmp/openapi-audit-final.json | |
| retention-days: 7 | |
| - name: Determine autofix PR base branch | |
| id: pr_base | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| if [ "${{ github.event_name }}" = "schedule" ]; then | |
| echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "base=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Create or update autofix PR | |
| if: github.event_name != 'pull_request' && steps.autofix.outputs.changed == 'true' | |
| uses: peter-evans/create-pull-request@v6 | |
| with: | |
| branch: automation/openapi-autofix | |
| base: ${{ steps.pr_base.outputs.base }} | |
| title: "chore(ci): safe OpenAPI reference autofixes" | |
| commit-message: "chore(ci): apply safe OpenAPI reference autofixes" | |
| body: | | |
| This PR contains safe, deterministic OpenAPI reference normalization fixes. | |
| - Run URL: ${{ steps.summary.outputs.run_url }} | |
| - Files analyzed: ${{ steps.summary.outputs.total_files }} | |
| - References analyzed: ${{ steps.summary.outputs.total_references }} | |
| - Findings after fix: ${{ steps.summary.outputs.total_failures }} | |
| Top findings (if any): | |
| ${{ steps.summary.outputs.top_findings }} | |
| Reproduce locally: | |
| `node tests/integration/openapi-reference-audit.js --full --strict --report /tmp/openapi-audit.md --report-json /tmp/openapi-audit.json` | |
| labels: | | |
| docs-v2 | |
| type: bug | |
| area: ci-cd | |
| - name: Ensure OpenAPI audit labels exist | |
| if: github.event_name != 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const labels = [ | |
| { name: 'docs-v2', color: '0e8a16', description: 'Docs V2 maintenance and tooling' }, | |
| { name: 'help wanted', color: '008672', description: 'Extra attention is needed' }, | |
| { name: 'status: needs-triage', color: 'f9d0c4', description: 'Awaiting maintainer triage' }, | |
| { name: 'type: bug', color: 'd73a4a', description: 'Behavior is incorrect' }, | |
| { name: 'area: ci-cd', color: '5319e7', description: 'CI/CD automation and workflows' } | |
| ]; | |
| for (const label of labels) { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: label.name }); | |
| core.info(`Label exists: ${label.name}`); | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: label.name, | |
| color: label.color, | |
| description: label.description | |
| }); | |
| core.info(`Created label: ${label.name}`); | |
| } catch (createError) { | |
| if (createError.status === 422) { | |
| core.info(`Label already exists (race-safe): ${label.name}`); | |
| } else { | |
| throw createError; | |
| } | |
| } | |
| } | |
| } | |
| - name: Sync rolling OpenAPI failure issue | |
| if: github.event_name != 'pull_request' | |
| uses: actions/github-script@v7 | |
| env: | |
| OPENAPI_AUDIT_JSON: /tmp/openapi-audit-final.json | |
| RUN_URL: ${{ steps.summary.outputs.run_url }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const helpers = require(path.join( | |
| process.env.GITHUB_WORKSPACE || process.cwd(), | |
| 'tests/utils/openapi-rolling-issue.js' | |
| )); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const reportPath = process.env.OPENAPI_AUDIT_JSON; | |
| const runUrl = process.env.RUN_URL || `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; | |
| if (!fs.existsSync(reportPath)) { | |
| core.warning(`Audit report not found at ${reportPath}; skipping issue sync.`); | |
| return; | |
| } | |
| const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); | |
| const findings = Array.isArray(report.findings) ? report.findings : []; | |
| const totalFailures = report.summary?.totalFailures || 0; | |
| const topFindings = helpers.buildTopFindings(findings, 30); | |
| async function findExistingIssue() { | |
| const query = `repo:${owner}/${repo} is:issue in:body "openapi-reference-audit"`; | |
| const result = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 20 }); | |
| const issue = helpers.findMarkerIssue( | |
| result.data.items || [], | |
| helpers.ROLLING_ISSUE_MARKER | |
| ); | |
| if (!issue) return null; | |
| const issueResp = await github.rest.issues.get({ owner, repo, issue_number: issue.number }); | |
| return issueResp.data; | |
| } | |
| const existing = await findExistingIssue(); | |
| const issueAction = helpers.getIssueAction({ | |
| existingIssue: existing, | |
| totalFailures | |
| }); | |
| if (issueAction === 'close') { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: existing.number, | |
| body: helpers.buildResolutionComment(runUrl) | |
| }); | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: existing.number, | |
| state: 'closed' | |
| }); | |
| core.info(`Closed resolved rolling issue #${existing.number}`); | |
| return; | |
| } | |
| if (issueAction === 'noop') { | |
| core.info('No unresolved failures and no rolling issue updates required.'); | |
| return; | |
| } | |
| const body = helpers.buildIssueBody({ | |
| runUrl, | |
| topFindings, | |
| totalFailures, | |
| totalFiles: report.summary?.totalFiles || 0, | |
| totalReferences: report.summary?.totalReferences || 0 | |
| }); | |
| if (issueAction === 'create') { | |
| const created = await github.rest.issues.create({ | |
| owner, | |
| repo, | |
| title: helpers.ROLLING_ISSUE_TITLE, | |
| body, | |
| labels: helpers.ROLLING_ISSUE_LABELS | |
| }); | |
| core.info(`Created rolling issue #${created.data.number}`); | |
| return; | |
| } | |
| await github.rest.issues.update({ | |
| owner, | |
| repo, | |
| issue_number: existing.number, | |
| title: helpers.ROLLING_ISSUE_TITLE, | |
| body, | |
| labels: helpers.ROLLING_ISSUE_LABELS, | |
| state: 'open' | |
| }); | |
| core.info(`Updated rolling issue #${existing.number}`); | |
| - name: Fail workflow when unresolved findings remain | |
| if: steps.final_audit.outputs.exit_code != '0' | |
| run: | | |
| echo "OpenAPI reference validation failed with unresolved findings." | |
| exit 1 |