Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/conformance.yaml
Original file line number Diff line number Diff line change
@@ -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/
112 changes: 112 additions & 0 deletions scripts/conformance/ci/render-summary.cjs
Original file line number Diff line number Diff line change
@@ -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 = '<!-- listing-conformance-report -->';
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 [
'<details><summary>Findings</summary>',
'',
'| | rule | file | message |'.replace('| |', '| sev |'),
'|---|---|---|---|',
...shown,
more,
'</details>',
].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 });
}
};
Loading