1.3.0 prep #263
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: Pull Request | |
| on: | |
| pull_request: | |
| branches: [main] | |
| types: [opened, synchronize, reopened, labeled] | |
| # Cancel in-progress runs when a new commit is pushed | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Auto-label PRs based on changed files | |
| label: | |
| name: Auto-label PR | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Label PR | |
| uses: actions/labeler@v5 | |
| with: | |
| repo-token: ${{ secrets.GITHUB_TOKEN }} | |
| sync-labels: true | |
| # Run linting and type checks | |
| lint: | |
| name: Lint & Type Check | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: 'npm' | |
| - uses: mlugg/setup-zig@v2 | |
| with: | |
| version: "0.15.2" | |
| - name: Install dependencies | |
| run: npm install | |
| - name: Run ESLint + Zig fmt | |
| run: npm run lint | |
| - name: Type check | |
| run: npm run typecheck | |
| - name: Zig dead code check | |
| run: npm run report:zig-dead-code | |
| # Run full test suite with coverage | |
| test: | |
| name: Tests & Coverage | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: 'npm' | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.13' | |
| - uses: mlugg/setup-zig@v2 | |
| with: | |
| version: "0.15.2" | |
| - name: Install dependencies | |
| run: | | |
| pip install numpy | |
| npm install | |
| - name: Build for testing | |
| run: npm run build:test | |
| - name: Run tests with coverage | |
| run: npm run test:coverage | |
| - name: Zig export fn test coverage (≥95%) | |
| run: npm run report:zig-coverage | |
| - name: DType coverage report | |
| id: dtype-coverage | |
| if: always() | |
| run: | | |
| npm run report:dtype-coverage -- --summary 2>&1 | tee dtype-coverage.txt || true | |
| - name: Generate coverage summary | |
| id: coverage | |
| run: | | |
| node -e " | |
| const coverage = require('./coverage/coverage-summary.json'); | |
| const total = coverage.total; | |
| const formatMetric = (metric) => { | |
| const pct = metric.pct; | |
| const emoji = pct >= 90 ? '🟢' : pct >= 80 ? '🟡' : pct >= 70 ? '🟠' : '🔴'; | |
| return \`\${emoji} \${pct}%\`; | |
| }; | |
| let comment = '## 📊 Coverage Report\n\n'; | |
| comment += '### Vitest Coverage\n\n'; | |
| comment += '| Metric | Coverage | Threshold | Status |\n'; | |
| comment += '|--------|----------|-----------|--------|\n'; | |
| comment += \`| Statements | \${formatMetric(total.statements)} | 80% | \${total.statements.pct >= 80 ? '✅' : '❌'} |\n\`; | |
| comment += \`| Branches | \${formatMetric(total.branches)} | 75% | \${total.branches.pct >= 75 ? '✅' : '❌'} |\n\`; | |
| comment += \`| Functions | \${formatMetric(total.functions)} | 95% | \${total.functions.pct >= 95 ? '✅' : '❌'} |\n\`; | |
| comment += \`| Lines | \${formatMetric(total.lines)} | 80% | \${total.lines.pct >= 80 ? '✅' : '❌'} |\n\`; | |
| const allPass = total.statements.pct >= 80 && | |
| total.branches.pct >= 75 && | |
| total.functions.pct >= 95 && | |
| total.lines.pct >= 80; | |
| if (allPass) { | |
| comment += '\n✅ All coverage thresholds met!\n'; | |
| } else { | |
| comment += '\n⚠️ Some coverage thresholds not met. Please add tests.\n'; | |
| } | |
| // Append dtype coverage summary if available | |
| const fs = require('fs'); | |
| if (fs.existsSync('dtype-coverage.txt')) { | |
| const dtypeSummary = fs.readFileSync('dtype-coverage.txt', 'utf-8').trim(); | |
| if (dtypeSummary) { | |
| comment += '\n### DType Sweep Coverage\n\n'; | |
| comment += '<details><summary>Click to expand</summary>\n\n'; | |
| comment += '\`\`\`\n' + dtypeSummary + '\n\`\`\`\n'; | |
| comment += '</details>\n'; | |
| } | |
| } | |
| console.log('COMMENT<<EOF'); | |
| console.log(comment); | |
| console.log('EOF'); | |
| " > coverage-comment.txt | |
| echo "comment<<EOF" >> $GITHUB_OUTPUT | |
| cat coverage-comment.txt | grep -A 1000 "COMMENT<<EOF" | grep -B 1000 "^EOF$" | grep -v "COMMENT<<EOF" | grep -v "^EOF$" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| - name: Comment PR with coverage | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const comment = `${{ steps.coverage.outputs.comment }}`; | |
| // Find existing coverage comment | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('📊 Coverage Report') | |
| ); | |
| if (botComment) { | |
| // Update existing comment | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: comment | |
| }); | |
| } else { | |
| // Create new comment | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: comment | |
| }); | |
| } | |
| # Compare committed benchmark results against main baseline | |
| benchmark: | |
| name: Benchmark Regression Check | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Fetch main branch | |
| run: git fetch origin main:refs/remotes/origin/main | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| - name: Compare against main baseline | |
| id: compare | |
| run: | | |
| node -e " | |
| const fs = require('fs'); | |
| const { execSync } = require('child_process'); | |
| // Load PR results (committed in this branch) | |
| if (!fs.existsSync('benchmarks/results/latest-full.json')) { | |
| console.log('No latest-full.json in PR branch — skipping'); | |
| fs.writeFileSync('bench-comment.md', '## Benchmark Regression Check\n\nNo benchmark results in this branch.\n'); | |
| process.exit(0); | |
| } | |
| const pr = JSON.parse(fs.readFileSync('benchmarks/results/latest-full.json', 'utf8')); | |
| // Load baseline from main | |
| let baseline; | |
| try { | |
| const raw = execSync('git show origin/main:benchmarks/results/latest-full.json', { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }); | |
| baseline = JSON.parse(raw); | |
| } catch { | |
| console.log('No baseline on main — skipping'); | |
| fs.writeFileSync('bench-comment.md', '## Benchmark Regression Check\n\nNo baseline found on main.\n'); | |
| process.exit(0); | |
| } | |
| const threshold = 1.25; | |
| const prOps = pr.results || []; | |
| const mainOps = baseline.results || []; | |
| const regressions = []; | |
| prOps.forEach(prOp => { | |
| const mainOp = mainOps.find(m => m.name === prOp.name); | |
| if (mainOp && prOp.numpyjs && mainOp.numpyjs) { | |
| const ratio = prOp.numpyjs.mean_ms / mainOp.numpyjs.mean_ms; | |
| if (ratio > threshold) { | |
| regressions.push({ | |
| name: prOp.name, | |
| category: prOp.category, | |
| main_ms: mainOp.numpyjs.mean_ms, | |
| pr_ms: prOp.numpyjs.mean_ms, | |
| change: ((ratio - 1) * 100).toFixed(1) | |
| }); | |
| } | |
| } | |
| }); | |
| let comment = '## Benchmark Regression Check\n\n'; | |
| if (regressions.length === 0) { | |
| comment += 'No regressions >25% detected.\n'; | |
| } else { | |
| comment += '| Operation | Main (ms) | PR (ms) | Change |\n'; | |
| comment += '|-|-|-|-|\n'; | |
| regressions.forEach(r => { | |
| comment += \`| \${r.name} | \${r.main_ms.toFixed(4)} | \${r.pr_ms.toFixed(4)} | +\${r.change}% |\n\`; | |
| }); | |
| comment += '\n**Warning**: ' + regressions.length + ' operation(s) regressed >25%.\n'; | |
| } | |
| fs.writeFileSync('bench-comment.md', comment); | |
| const has = regressions.length > 0 ? 'true' : 'false'; | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, 'has_regression=' + has + '\n'); | |
| " | |
| - name: Comment PR | |
| if: always() && hashFiles('bench-comment.md') != '' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const comment = fs.readFileSync('bench-comment.md', 'utf8'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const botComment = comments.find(c => | |
| c.user.type === 'Bot' && c.body.includes('Benchmark Regression Check') | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: comment | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: comment | |
| }); | |
| } | |
| - name: Fail if regression detected | |
| if: steps.compare.outputs.has_regression == 'true' | |
| run: | | |
| echo "::error::Performance regression detected (>25%). See PR comment." | |
| exit 1 |