From 6178bf97a7adcad06f224e5978b893df3a540a86 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Sun, 5 Jul 2026 16:01:24 +0800 Subject: [PATCH] Add listing-conformance CI: PR check + nightly sweep PR job runs the static harness on listing dirs the PR touches (~60-90s, node-only, no monorepo checkout) and posts/updates a summary comment. Nightly sweep runs it over every listing dir and maintains a single 'Catalog conformance sweep' issue (auto-closes when clean). Complements ci-lint/ci-test; never replaces them. Co-Authored-By: Claude Fable 5 --- .github/workflows/conformance.yaml | 94 ++++++++++++++++++ scripts/conformance/ci/render-summary.cjs | 112 ++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 .github/workflows/conformance.yaml create mode 100644 scripts/conformance/ci/render-summary.cjs diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 00000000..e9238a93 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,94 @@ +name: Listing Conformance + +on: + pull_request: + schedule: + - cron: '17 3 * * *' # nightly whole-catalog sweep + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + # ---- PR job: static gates on the listing dirs this PR touches (~60-90s) ---- + changed-listings: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + - name: Detect changed listing dirs + id: dirs + run: | + git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ + | cut -d/ -f1 | sort -u \ + | while read -r d; do + [ -d "$d" ] || continue + ls "$d"/*Listing/*.json >/dev/null 2>&1 && echo "$d" + done > changed-listings.txt || true + echo "dirs=$(paste -sd' ' changed-listings.txt)" >> "$GITHUB_OUTPUT" + echo "Changed listing dirs:"; cat changed-listings.txt + - name: Run static conformance + id: run + if: steps.dirs.outputs.dirs != '' + run: | + status=0 + mkdir -p conformance-reports + for d in ${{ steps.dirs.outputs.dirs }}; do + node scripts/conformance/run.mjs "$d" --phase static \ + --json "conformance-reports/${d}.json" || status=1 + done + echo "status=$status" >> "$GITHUB_OUTPUT" + exit 0 + - name: Post summary comment + if: always() && steps.dirs.outputs.dirs != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const script = require('./scripts/conformance/ci/render-summary.cjs'); + await script({ github, context, core, reportsDir: 'conformance-reports', mode: 'pr' }); + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() && steps.dirs.outputs.dirs != '' + with: + name: conformance-reports + path: conformance-reports/ + - name: Fail on conformance errors + if: steps.run.outputs.status == '1' + run: | + echo "One or more listing dirs have conformance errors (see comment / artifact)." + exit 1 + + # ---- nightly sweep: static gates over EVERY listing dir; informational ---- + nightly-sweep: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + - name: Sweep all listings + run: | + mkdir -p conformance-reports + for d in $(ls -d */ fields/*/ 2>/dev/null | sed 's#/$##'); do + [ -d "$d" ] || continue + ls "$d"/*Listing/*.json >/dev/null 2>&1 || continue + node scripts/conformance/run.mjs "$d" --phase static \ + --json "conformance-reports/$(echo "$d" | tr '/' '__').json" || true + done + - name: Update sweep issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const script = require('./scripts/conformance/ci/render-summary.cjs'); + await script({ github, context, core, reportsDir: 'conformance-reports', mode: 'sweep' }); + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-conformance-reports + path: conformance-reports/ diff --git a/scripts/conformance/ci/render-summary.cjs b/scripts/conformance/ci/render-summary.cjs new file mode 100644 index 00000000..853bf4d2 --- /dev/null +++ b/scripts/conformance/ci/render-summary.cjs @@ -0,0 +1,112 @@ +// Renders conformance report JSONs into (a) a PR comment or (b) the pinned +// nightly-sweep issue. Invoked by actions/github-script from conformance.yaml. +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); + +const PR_MARKER = ''; +const SWEEP_TITLE = 'Catalog conformance sweep'; + +function loadReports(reportsDir) { + if (!fs.existsSync(reportsDir)) return []; + return fs + .readdirSync(reportsDir) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(fs.readFileSync(path.join(reportsDir, f), 'utf8'))); +} + +function statusEmoji(r) { + if (r.summary.result === 'fail') return '❌'; + if (r.summary.warnings > 0) return '⚠️'; + return '✅'; +} + +function detailsFor(r, { maxFindings = 30 } = {}) { + const rows = []; + for (const gate of r.gates) { + for (const f of gate.findings) { + if (f.waived) continue; + rows.push( + `| ${f.severity === 'error' ? '🔴' : f.severity === 'warn' ? '🟡' : 'ℹ️'} \`${f.rule}\` | \`${f.file}\` | ${f.message.replace(/\|/g, '\\|').slice(0, 140)} |` + ); + } + } + if (rows.length === 0) return ''; + const shown = rows.slice(0, maxFindings); + const more = rows.length > shown.length ? `\n\n…and ${rows.length - shown.length} more (see artifact).` : ''; + return [ + '
Findings', + '', + '| | rule | file | message |'.replace('| |', '| sev |'), + '|---|---|---|---|', + ...shown, + more, + '
', + ].join('\n'); +} + +function renderBody(reports, heading) { + const lines = [ + heading, + '', + '| listing | result | errors | warnings | waived |', + '|---|---|---|---|---|', + ]; + for (const r of reports.sort((a, b) => a.listingDir.localeCompare(b.listingDir))) { + lines.push( + `| \`${r.listingDir}\` | ${statusEmoji(r)} ${r.summary.result} | ${r.summary.errors} | ${r.summary.warnings} | ${r.summary.waived} |` + ); + } + lines.push(''); + for (const r of reports) { + if (r.summary.errors + r.summary.warnings > 0) { + lines.push(`### \`${r.listingDir}\``, detailsFor(r), ''); + } + } + lines.push('', `_harness ${reports[0]?.harnessVersion ?? '?'} · [gate docs](../blob/main/scripts/conformance/README.md)_`); + return lines.join('\n'); +} + +module.exports = async function render({ github, context, reportsDir, mode }) { + const reports = loadReports(reportsDir); + if (reports.length === 0) return; + + if (mode === 'pr') { + const body = `${PR_MARKER}\n${renderBody(reports, '## Listing conformance')}`; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 }); + const mine = comments.data.find((c) => c.body?.includes(PR_MARKER)); + if (mine) { + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + return; + } + + // ---- sweep mode: create/update/close the single pinned issue + const { owner, repo } = context.repo; + const failing = reports.filter((r) => r.summary.result === 'fail'); + const body = renderBody( + reports, + `## Nightly conformance sweep — ${failing.length}/${reports.length} listing(s) failing` + ); + const open = await github.rest.issues.listForRepo({ owner, repo, state: 'open', per_page: 100 }); + const existing = open.data.find((i) => i.title === SWEEP_TITLE); + if (failing.length === 0) { + if (existing) { + await github.rest.issues.createComment({ + owner, repo, issue_number: existing.number, + body: 'Sweep is clean — closing. 🎉', + }); + await github.rest.issues.update({ owner, repo, issue_number: existing.number, state: 'closed' }); + } + return; + } + if (existing) { + await github.rest.issues.update({ owner, repo, issue_number: existing.number, body }); + } else { + await github.rest.issues.create({ owner, repo, title: SWEEP_TITLE, body }); + } +};