ci: Add live validators #6
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
| # Auto-label PRs based on changed file paths. | |
| # Works identically on centrifuge/protocol and centrifuge/protocol-internal. | |
| # | |
| # Labeling rules (all matching labels are applied): | |
| # | |
| # Scope labels: | |
| # scope:contracts — any file under src/ (excluding */interfaces/ dirs) | |
| # scope:tests — any file under test/ | |
| # scope:invariant — invariant/fuzz test files (test/**/recon*, echidna.yaml, medusa.json) | |
| # scope:scripts — any file under script/ (unless scope:tooling applies) | |
| # scope:tooling — ALL files under script/ AND none contain "deploy" in path | |
| # scope:infra — .github/, lib/, foundry.toml, slither.config.json, | |
| # remappings.txt, .gitmodules | |
| # scope:env — any file under env/ | |
| # | |
| # Audit flag: | |
| # audit:needed — if scope:contracts was applied | |
| # | |
| # Type labels (at most one, requires ALL files to match): | |
| # type:test — only non-infra files are under test/ | |
| # type:docs — only .md files or files under docs/ | |
| # type:chore — only infra/config files | |
| # | |
| # The workflow only ADDS labels, never removes them. | |
| name: Auto Label PRs | |
| on: | |
| pull_request: | |
| types: [opened, synchronize] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| auto-label: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Apply labels based on changed files | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const prNumber = context.payload.pull_request.number; | |
| // ── Step 1: Fetch all changed files (paginated) ── | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number: prNumber, per_page: 100 } | |
| ); | |
| const filenames = files.map(f => f.filename); | |
| if (filenames.length === 0) { | |
| core.info('No changed files found, skipping.'); | |
| return; | |
| } | |
| // ── Step 2: Classify each file ── | |
| const INFRA_EXACT = new Set([ | |
| 'foundry.toml', 'slither.config.json', 'remappings.txt', '.gitmodules' | |
| ]); | |
| // Fuzzer config files at repo root | |
| const INVARIANT_EXACT = new Set(['echidna.yaml', 'medusa.json']); | |
| let hasContract = false; | |
| let hasTest = false; | |
| let hasInvariant = false; // recon/echidna/medusa invariant tests | |
| let hasScript = false; | |
| let hasScriptDeploy = false; // script file with "deploy" in path | |
| let hasInfra = false; | |
| let hasEnv = false; | |
| let hasDocs = false; | |
| let hasOther = false; // files that don't fit any category above | |
| for (const f of filenames) { | |
| const lower = f.toLowerCase(); | |
| let matched = false; | |
| // Contract source (excluding interface directories scattered | |
| // throughout src/, e.g. src/core/hub/interfaces/, src/misc/interfaces/) | |
| if (f.startsWith('src/') && !f.includes('/interfaces/')) { | |
| hasContract = true; | |
| matched = true; | |
| } | |
| // Interface-only files are not contracts but still belong to src/ | |
| if (f.startsWith('src/') && f.includes('/interfaces/')) { | |
| matched = true; | |
| } | |
| // Tests | |
| if (f.startsWith('test/')) { | |
| hasTest = true; | |
| matched = true; | |
| // Invariant/fuzz tests live under test/**/recon* | |
| if (f.includes('/recon')) { | |
| hasInvariant = true; | |
| } | |
| } | |
| // Root-level fuzzer configs (echidna.yaml, medusa.json) | |
| if (INVARIANT_EXACT.has(f)) { | |
| hasInvariant = true; | |
| hasTest = true; | |
| matched = true; | |
| } | |
| // Scripts | |
| if (f.startsWith('script/')) { | |
| hasScript = true; | |
| matched = true; | |
| // broadcast/ is gitignored so only "deploy" matters here | |
| if (lower.includes('deploy')) { | |
| hasScriptDeploy = true; | |
| } | |
| } | |
| // Infra: .github/, lib/, or specific root config files | |
| if ( | |
| f.startsWith('.github/') || | |
| f.startsWith('lib/') || | |
| INFRA_EXACT.has(f) | |
| ) { | |
| hasInfra = true; | |
| matched = true; | |
| } | |
| // Env: deployment addresses and network configs | |
| if (f.startsWith('env/')) { | |
| hasEnv = true; | |
| matched = true; | |
| } | |
| // Docs: .md files anywhere or anything under docs/ | |
| if (f.endsWith('.md') || f.startsWith('docs/')) { | |
| hasDocs = true; | |
| matched = true; | |
| } | |
| if (!matched) { | |
| hasOther = true; | |
| } | |
| } | |
| // ── Step 3: Determine scope labels ── | |
| const labels = []; | |
| if (hasContract) { | |
| labels.push('scope:contracts'); | |
| } | |
| if (hasTest) { | |
| labels.push('scope:tests'); | |
| } | |
| if (hasInvariant) { | |
| labels.push('scope:invariant'); | |
| } | |
| // scope:tooling vs scope:scripts — mutually exclusive | |
| if (hasScript) { | |
| const allFilesAreScripts = filenames.every(f => f.startsWith('script/')); | |
| if (allFilesAreScripts && !hasScriptDeploy) { | |
| labels.push('scope:tooling'); | |
| } else { | |
| labels.push('scope:scripts'); | |
| } | |
| } | |
| if (hasInfra) { | |
| labels.push('scope:infra'); | |
| } | |
| if (hasEnv) { | |
| labels.push('scope:env'); | |
| } | |
| // ── Step 4: Audit flag ── | |
| if (hasContract) { | |
| labels.push('audit:needed'); | |
| } | |
| // ── Step 5: Type labels (at most one) ── | |
| // type:test — only non-infra files are tests (includes invariant) | |
| if (hasTest && !hasContract && !hasScript && !hasDocs && !hasEnv && !hasOther) { | |
| labels.push('type:test'); | |
| } | |
| // type:docs — every file is .md or under docs/ | |
| else if (hasDocs && !hasContract && !hasTest && !hasScript && !hasInfra && !hasEnv && !hasOther) { | |
| labels.push('type:docs'); | |
| } | |
| // type:chore — every file is infra/config | |
| else if (hasInfra && !hasContract && !hasTest && !hasScript && !hasDocs && !hasEnv && !hasOther) { | |
| labels.push('type:chore'); | |
| } | |
| if (labels.length === 0) { | |
| core.info('No labels to apply.'); | |
| return; | |
| } | |
| // ── Step 6: Deduplicate against existing PR labels ── | |
| const { data: prData } = await github.rest.pulls.get({ | |
| owner, repo, pull_number: prNumber | |
| }); | |
| const existingLabels = new Set(prData.labels.map(l => l.name)); | |
| const newLabels = labels.filter(l => !existingLabels.has(l)); | |
| if (newLabels.length > 0) { | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNumber, labels: newLabels | |
| }); | |
| core.info(`Added labels: ${newLabels.join(', ')}`); | |
| } else { | |
| core.info('All labels already present, nothing to add.'); | |
| } | |
| // ── Step 7: Comment on first run only ── | |
| const MARKER = '<!-- auto-label -->'; | |
| const comments = await github.paginate( | |
| github.rest.issues.listComments, | |
| { owner, repo, issue_number: prNumber, per_page: 100 } | |
| ); | |
| const alreadyCommented = comments.some(c => c.body.includes(MARKER)); | |
| if (!alreadyCommented && newLabels.length > 0) { | |
| const labelList = labels.map(l => '`' + l + '`').join(', '); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: prNumber, | |
| body: `${MARKER}\nAuto-labeled: ${labelList}. Please verify and add type/version labels manually.` | |
| }); | |
| } |