feat(obs): Phase-0 oauth+approve trace wire (additive, un-deployed) (… #962
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: Cascade Gate | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| statuses: write | |
| concurrency: | |
| group: cascade-gate-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| NEXT_TELEMETRY_DISABLED: '1' | |
| jobs: | |
| quality-gate: | |
| name: Quality Gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22.x | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: TypeScript check | |
| id: typecheck | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| npx tsc --noEmit | |
| exit_code=$? | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: ESLint | |
| id: eslint | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| npx eslint app/ components/ lib/ --max-warnings 0 -f json -o eslint-results.json | |
| exit_code=$? | |
| if [ ! -f eslint-results.json ]; then | |
| echo '[]' > eslint-results.json | |
| fi | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Brand scan | |
| id: brand_scan | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| npm run brand:scan | |
| exit_code=$? | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Unit tests | |
| id: tests | |
| shell: bash | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| npx vitest run --reporter=json --outputFile=test-results.json | |
| exit_code=$? | |
| if [ ! -f test-results.json ]; then | |
| echo '{}' > test-results.json | |
| fi | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Build | |
| id: build | |
| shell: bash | |
| continue-on-error: true | |
| env: | |
| NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co | |
| NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| run: | | |
| set +e | |
| npm run build | |
| exit_code=$? | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| - name: Calculate score inputs | |
| shell: bash | |
| env: | |
| TYPECHECK_EXIT_CODE: ${{ steps.typecheck.outputs.exit_code }} | |
| ESLINT_EXIT_CODE: ${{ steps.eslint.outputs.exit_code }} | |
| BRAND_EXIT_CODE: ${{ steps.brand_scan.outputs.exit_code }} | |
| TEST_EXIT_CODE: ${{ steps.tests.outputs.exit_code }} | |
| BUILD_EXIT_CODE: ${{ steps.build.outputs.exit_code }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| node <<'NODE' | |
| const fs = require('fs'); | |
| const safeNumber = (value, fallback = 1) => { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : fallback; | |
| }; | |
| const readJson = (path, fallback) => { | |
| try { | |
| return JSON.parse(fs.readFileSync(path, 'utf8')); | |
| } catch { | |
| return fallback; | |
| } | |
| }; | |
| const eslint = readJson('eslint-results.json', []); | |
| const vitest = readJson('test-results.json', {}); | |
| const eslintWarnings = Array.isArray(eslint) | |
| ? eslint.reduce((sum, file) => sum + Number(file.warningCount || 0), 0) | |
| : Number(eslint.warningCount || 0); | |
| const eslintErrors = Array.isArray(eslint) | |
| ? eslint.reduce((sum, file) => sum + Number(file.errorCount || 0), 0) | |
| : Number(eslint.errorCount || 0); | |
| const payload = { | |
| typecheckExitCode: safeNumber(process.env.TYPECHECK_EXIT_CODE), | |
| eslintExitCode: safeNumber(process.env.ESLINT_EXIT_CODE), | |
| brandExitCode: safeNumber(process.env.BRAND_EXIT_CODE), | |
| testExitCode: safeNumber(process.env.TEST_EXIT_CODE), | |
| buildExitCode: safeNumber(process.env.BUILD_EXIT_CODE), | |
| eslintWarnings, | |
| eslintErrors, | |
| vitest, | |
| sha: process.env.GITHUB_SHA, | |
| runId: process.env.GITHUB_RUN_ID, | |
| prNumber: process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : null, | |
| }; | |
| fs.writeFileSync('cascade-input.json', JSON.stringify(payload, null, 2)); | |
| NODE | |
| - name: Upload quality gate artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: cascade-quality-gate-results | |
| path: | | |
| cascade-input.json | |
| eslint-results.json | |
| test-results.json | |
| if-no-files-found: warn | |
| cascade-score: | |
| name: Cascade Score | |
| runs-on: ubuntu-latest | |
| needs: quality-gate | |
| if: always() | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Download quality gate artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: cascade-quality-gate-results | |
| path: artifacts | |
| - name: Calculate adversarial cascade score | |
| id: score | |
| shell: bash | |
| run: | | |
| node <<'NODE' | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const artifactsDir = path.join(process.cwd(), 'artifacts'); | |
| const readJson = (file, fallback) => { | |
| try { | |
| return JSON.parse(fs.readFileSync(path.join(artifactsDir, file), 'utf8')); | |
| } catch { | |
| return fallback; | |
| } | |
| }; | |
| const scoreInput = readJson('cascade-input.json', {}); | |
| const eslintResults = readJson('eslint-results.json', []); | |
| const vitestResults = readJson('test-results.json', scoreInput.vitest || {}); | |
| const asNumber = (value, fallback = 0) => { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : fallback; | |
| }; | |
| const extractTestMetrics = (report) => { | |
| const directTotal = asNumber(report?.numTotalTests, 0); | |
| const directPassed = asNumber(report?.numPassedTests, 0); | |
| const directFailed = asNumber(report?.numFailedTests, 0); | |
| const directPending = asNumber(report?.numPendingTests ?? report?.numSkippedTests, 0); | |
| if (directTotal > 0) { | |
| return { total: directTotal, passed: directPassed, failed: directFailed, pending: directPending }; | |
| } | |
| if (Array.isArray(report?.testResults)) { | |
| let total = 0; | |
| let passed = 0; | |
| let failed = 0; | |
| let pending = 0; | |
| for (const suite of report.testResults) { | |
| if (Array.isArray(suite.assertionResults) && suite.assertionResults.length > 0) { | |
| total += suite.assertionResults.length; | |
| for (const assertion of suite.assertionResults) { | |
| switch (assertion.status) { | |
| case 'passed': | |
| passed += 1; | |
| break; | |
| case 'failed': | |
| failed += 1; | |
| break; | |
| default: | |
| pending += 1; | |
| break; | |
| } | |
| } | |
| continue; | |
| } | |
| total += asNumber(suite.numTotalTests, 0); | |
| passed += asNumber(suite.numPassingTests, 0); | |
| failed += asNumber(suite.numFailingTests, 0); | |
| pending += asNumber(suite.numPendingTests ?? suite.numSkippedTests, 0); | |
| } | |
| return { total, passed, failed, pending }; | |
| } | |
| return { total: 0, passed: 0, failed: 0, pending: 0 }; | |
| }; | |
| const eslintWarnings = Array.isArray(eslintResults) | |
| ? eslintResults.reduce((sum, file) => sum + asNumber(file.warningCount, 0), 0) | |
| : asNumber(eslintResults.warningCount, 0); | |
| const eslintErrors = Array.isArray(eslintResults) | |
| ? eslintResults.reduce((sum, file) => sum + asNumber(file.errorCount, 0), 0) | |
| : asNumber(eslintResults.errorCount, 0); | |
| const typecheckPass = asNumber(scoreInput.typecheckExitCode, 1) === 0; | |
| const brandPass = asNumber(scoreInput.brandExitCode, 1) === 0; | |
| const buildPass = asNumber(scoreInput.buildExitCode, 1) === 0; | |
| const testMetrics = extractTestMetrics(vitestResults); | |
| const testPassRate = testMetrics.total > 0 | |
| ? testMetrics.passed / testMetrics.total | |
| : asNumber(scoreInput.testExitCode, 1) === 0 | |
| ? 1 | |
| : 0; | |
| const scores = { | |
| typecheck: typecheckPass ? 20 : 0, | |
| eslint: eslintErrors > 0 ? 0 : Math.max(0, 20 - eslintWarnings), | |
| brand: brandPass ? 15 : 0, | |
| tests: Math.round(testPassRate * 25), | |
| build: buildPass ? 20 : 0, | |
| }; | |
| const totalScore = scores.typecheck + scores.eslint + scores.brand + scores.tests + scores.build; | |
| const threshold = 85; | |
| const passing = totalScore >= threshold; | |
| const testPassRatePct = Math.round(testPassRate * 100); | |
| const eslintStatus = eslintErrors > 0 | |
| ? `❌ (${eslintErrors} error${eslintErrors === 1 ? '' : 's'})` | |
| : eslintWarnings > 0 | |
| ? `⚠️ (${eslintWarnings} warning${eslintWarnings === 1 ? '' : 's'})` | |
| : '✅'; | |
| const testStatus = testPassRatePct === 100 | |
| ? '✅' | |
| : testPassRatePct > 0 | |
| ? `⚠️ (${testPassRatePct}% pass rate)` | |
| : '❌'; | |
| const resultLabel = passing ? 'PASS ✅' : 'FAIL ❌'; | |
| const resultState = passing ? 'PASS' : 'FAIL'; | |
| const markdown = [ | |
| '<!-- cascade-quality-score -->', | |
| `## 🛡️ Cascade Quality Score: ${totalScore}/100`, | |
| '', | |
| '| Category | Score | Status |', | |
| '|----------|-------|--------|', | |
| `| TypeScript | ${scores.typecheck}/20 | ${typecheckPass ? '✅' : '❌'} |`, | |
| `| ESLint | ${scores.eslint}/20 | ${eslintStatus} |`, | |
| `| Brand Compliance | ${scores.brand}/15 | ${brandPass ? '✅' : '❌'} |`, | |
| `| Test Suite | ${scores.tests}/25 | ${testStatus} |`, | |
| `| Build | ${scores.build}/20 | ${buildPass ? '✅' : '❌'} |`, | |
| '', | |
| `**Threshold:** ${threshold}/100 | **Result:** ${resultLabel}`, | |
| ].join('\n'); | |
| const result = { | |
| totalScore, | |
| threshold, | |
| passing, | |
| resultLabel, | |
| resultState, | |
| scores, | |
| eslintWarnings, | |
| eslintErrors, | |
| testMetrics, | |
| testPassRatePct, | |
| markdown, | |
| }; | |
| fs.writeFileSync(path.join(artifactsDir, 'cascade-score.json'), JSON.stringify(result, null, 2)); | |
| fs.writeFileSync(path.join(artifactsDir, 'cascade-comment.md'), markdown); | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, `total_score=${totalScore}\n`); | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, `threshold=${threshold}\n`); | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, `passing=${passing}\n`); | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, `result_label=${resultLabel}\n`); | |
| fs.appendFileSync(process.env.GITHUB_OUTPUT, `result_state=${resultState}\n`); | |
| NODE | |
| - name: Publish workflow summary | |
| if: always() | |
| run: | | |
| if [ -f artifacts/cascade-comment.md ]; then | |
| cat artifacts/cascade-comment.md >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Comment on pull request | |
| if: github.event_name == 'pull_request' | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const marker = '<!-- cascade-quality-score -->'; | |
| const body = fs.readFileSync('artifacts/cascade-comment.md', 'utf8'); | |
| const issue_number = context.issue.number; | |
| try { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existing.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number, | |
| body, | |
| }); | |
| } | |
| } catch (error) { | |
| core.warning(`Unable to post cascade score comment: ${error.message}`); | |
| } | |
| - name: Set commit status | |
| if: github.event_name == 'push' | |
| uses: actions/github-script@v9 | |
| env: | |
| TOTAL_SCORE: ${{ steps.score.outputs.total_score }} | |
| RESULT_STATE: ${{ steps.score.outputs.result_state }} | |
| THRESHOLD: ${{ steps.score.outputs.threshold }} | |
| with: | |
| script: | | |
| try { | |
| const totalScore = Number(process.env.TOTAL_SCORE || '0'); | |
| const threshold = Number(process.env.THRESHOLD || '85'); | |
| await github.rest.repos.createCommitStatus({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| sha: context.sha, | |
| state: totalScore >= threshold ? 'success' : 'failure', | |
| context: 'Cascade Quality Score', | |
| description: `Score ${totalScore}/100 (${process.env.RESULT_STATE})`, | |
| target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, | |
| }); | |
| } catch (error) { | |
| core.warning(`Unable to set cascade score status: ${error.message}`); | |
| } | |
| - name: Enforce score threshold | |
| if: always() && fromJSON(steps.score.outputs.total_score || '0') < 85 | |
| env: | |
| TOTAL_SCORE: ${{ steps.score.outputs.total_score }} | |
| run: | | |
| echo "Cascade score ${TOTAL_SCORE}/100 is below the required 85/100 threshold." | |
| exit 1 |