Skip to content

feat(obs): Phase-0 oauth+approve trace wire (additive, un-deployed) (… #962

feat(obs): Phase-0 oauth+approve trace wire (additive, un-deployed) (…

feat(obs): Phase-0 oauth+approve trace wire (additive, un-deployed) (… #962

Workflow file for this run

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