diff --git a/.github/scripts/check-required-pr-context.mjs b/.github/scripts/check-required-pr-context.mjs index e2f075329dc..a0f1bd20906 100644 --- a/.github/scripts/check-required-pr-context.mjs +++ b/.github/scripts/check-required-pr-context.mjs @@ -18,10 +18,7 @@ export function verifyRequiredPrContext(parseYaml) { } const gate = providers[0].job const condition = gate.if?.replace(/^\$\{\{\s*|\s*\}\}$/g, '').trim() - if ( - condition !== - "!cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.draft != true)" - ) { + if (condition !== '!cancelled() && github.event.pull_request.draft != true') { throw new Error('Required PR aggregate must run unless cancelled or the PR is a draft.') } if (gate['continue-on-error'] || gate.steps?.some((step) => step['continue-on-error'])) { diff --git a/.github/workflows/main-health-alert.yml b/.github/workflows/main-health-alert.yml deleted file mode 100644 index 74d0c3cd926..00000000000 --- a/.github/workflows/main-health-alert.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Main health alert - -# Main pushes run the full PR Checks; tell Slack when main turns red and once when it recovers. -on: - workflow_run: - workflows: [PR Checks] - types: [completed] - branches: [main] - -permissions: - contents: read - actions: read - pull-requests: read - -# No concurrency group: a pending run in a group is replaced, which would drop alerts in a burst. -jobs: - alert: - if: >- - github.event.workflow_run.event == 'push' && - github.event.workflow_run.conclusion != 'cancelled' && - github.event.workflow_run.conclusion != 'skipped' - runs-on: ubuntu-slim - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - sparse-checkout: | - /config/scripts/main-health-alert.mjs - sparse-checkout-cone-mode: false - persist-credentials: false - - - name: Report main health transitions - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - # Optional JSON of GitHub login to Slack member ID; unmapped people get a GitHub profile link. - SLACK_USER_IDS: ${{ vars.SLACK_USER_IDS_BY_GITHUB_LOGIN }} - run: node config/scripts/main-health-alert.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f77898d9ffc..52cb2fb1c0c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,5 +1,5 @@ name: PR Checks -run-name: "${{ github.event_name == 'push' && 'main' || format('PR {0}', github.event.pull_request.number) }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" +run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" on: pull_request: @@ -9,14 +9,10 @@ on: - reopened - ready_for_review - converted_to_draft - # Main runs every check PRs may scope or skip, so a red main is visible to its alert. - push: - branches: [main] -# Each main commit keeps its own run; only a PR's superseded runs cancel. concurrency: - group: ${{ github.event_name == 'push' && format('main-checks-{0}', github.sha) || format('pr-checks-{0}', github.event.pull_request.number) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read @@ -140,15 +136,12 @@ jobs: if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then MOBILE_CI_ARGS=(--mobile-ci-tools --daemon-init-test-support) fi - # An empty diff fails closed to every job, which is what a main push runs. - if [ "$GITHUB_EVENT_NAME" = push ]; then CHANGED=''; MOBILE_CI_ARGS=(); fi printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT" # Reuse the path-detector checkout instead of queuing another runner. - name: Filter changed E2E specs id: e2e_filter - # Advisory E2E lanes stay PR-only; main has their nightly schedules. - if: github.event_name == 'pull_request' && github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true' + if: github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true' run: | set -euo pipefail BASE="${{ github.event.pull_request.base.sha }}" @@ -234,22 +227,6 @@ jobs: bun-version-file: config/.bun-version no-cache: true - # Per-file passes check only changed paths on PRs; main pushes, detection failures and toolchain edits check everything. - - name: Select per-file lint scope - id: lint-scope - if: needs.code_paths.outputs.static_analysis == 'true' - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - scope=full - if [ "$GITHUB_EVENT_NAME" = pull_request ] && - DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" && - git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/lint-changes"; then - scope="$(node config/scripts/lint-change-scope.mjs "$RUNNER_TEMP/lint-changes" "$RUNNER_TEMP/lint-scope")" || scope=full - fi - echo "Per-file lint scope: $scope" - echo "scope=$scope" >> "$GITHUB_OUTPUT" - # Run gate scripts directly to avoid repeated package-manager startup. - name: Check process-host imports if: '!cancelled()' @@ -281,12 +258,9 @@ jobs: env: PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} - LINT_SCOPE: ${{ steps.lint-scope.outputs.scope }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - if [ "$LINT_SCOPE" = changed ]; then mapfile -d '' paths < "$RUNNER_TEMP/lint-scope/oxlint"; else paths=(.); fi - [ "${#paths[@]}" -gt 0 ] || { echo 'No changed files for this pass.'; exit 0; } - node node_modules/oxlint/bin/oxlint --format github --no-error-on-unmatched-pattern "${paths[@]}" + node node_modules/oxlint/bin/oxlint --format github # JS plugins make Oxlint hand every AST to Node, so they run apart from the native root lint. - name: Enforce JS-plugin lint rules @@ -296,12 +270,9 @@ jobs: env: PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} - LINT_SCOPE: ${{ steps.lint-scope.outputs.scope }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - if [ "$LINT_SCOPE" = changed ]; then mapfile -d '' paths < "$RUNNER_TEMP/lint-scope/js-plugins"; else paths=(src config tests mobile native .github); fi - [ "${#paths[@]}" -gt 0 ] || { echo 'No changed files for this pass.'; exit 0; } - node node_modules/oxlint/bin/oxlint --config config/oxlint-js-plugins.json --format github --no-error-on-unmatched-pattern "${paths[@]}" + node node_modules/oxlint/bin/oxlint --config config/oxlint-js-plugins.json --format github src config tests mobile native .github # Its single JS-plugin thread ran alone for ~12s as a foreground tail; overlapping it fills idle cores. - name: Reject low-evidence patterns @@ -311,33 +282,21 @@ jobs: env: PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} - LINT_SCOPE: ${{ steps.lint-scope.outputs.scope }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - if [ "$LINT_SCOPE" = changed ]; then mapfile -d '' paths < "$RUNNER_TEMP/lint-scope/anti-slop"; else paths=(src config tests mobile); fi - [ "${#paths[@]}" -gt 0 ] || { echo 'No changed files for this pass.'; exit 0; } node config/scripts/sync-anti-slop-plugin.mjs - bun --smol node_modules/oxlint/bin/oxlint --config config/oxlint-anti-slop.json --deny-warnings --no-error-on-unmatched-pattern "${paths[@]}" + bun --smol node_modules/oxlint/bin/oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings - name: Enforce ast-grep rules if: needs.code_paths.outputs.static_analysis == 'true' - env: - LINT_SCOPE: ${{ steps.lint-scope.outputs.scope }} run: | node_modules/.bin/ast-grep test --config config/sgconfig.yml --skip-snapshot-tests - scope=() - if [ "$LINT_SCOPE" = changed ]; then scope=(--paths-from="$RUNNER_TEMP/lint-scope/ast-grep"); fi - node config/scripts/ast-grep-gates.mjs --format=github "${scope[@]}" + node config/scripts/ast-grep-gates.mjs --format=github - # Checks every changed path the pre-commit hook may have skipped. + # Why the full tree: it takes ~2s, and the pre-commit hook is skippable. - name: Check formatting if: needs.code_paths.outputs.static_analysis == 'true' - env: - LINT_SCOPE: ${{ steps.lint-scope.outputs.scope }} - run: | - if [ "$LINT_SCOPE" = changed ]; then mapfile -d '' paths < "$RUNNER_TEMP/lint-scope/oxfmt"; else paths=(.); fi - [ "${#paths[@]}" -gt 0 ] || { echo 'No changed files for this pass.'; exit 0; } - node node_modules/oxfmt/bin/oxfmt --check --no-error-on-unmatched-pattern "${paths[@]}" + run: node node_modules/oxfmt/bin/oxfmt --check . - wait: root-lint @@ -413,10 +372,9 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - # Main pushes and detection failures run the full check; renames retain the removed input path. + # Detection failures run the full check; renames retain the removed input path. DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" - if [ "$GITHUB_EVENT_NAME" = pull_request ] && - git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then echo "Localization extraction inputs are unchanged." else @@ -458,8 +416,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" - # Main pushes always replay the rollback. - if [ "$GITHUB_EVENT_NAME" = pull_request ] && git diff --quiet "$DIFF_BASE" HEAD -- \ + if git diff --quiet "$DIFF_BASE" HEAD -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ @@ -503,10 +460,9 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - # Main pushes and detection failures run the smoke; renames retain the removed input path. + # Detection failures run the smoke; renames retain the removed input path. DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" - if [ "$GITHUB_EVENT_NAME" = pull_request ] && - git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" && + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" && scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then echo "Orcad terminal smoke inputs are unchanged." echo 'run=false' >> "$GITHUB_OUTPUT" @@ -1043,9 +999,7 @@ jobs: # R3: a runtime launcher swap must not also bump the daemon protocol (D7.1). # The label is read from the event payload, so push again after applying it. - # Main pushes carry no labels; the merged PR already passed this with its label. - name: Runtime launcher change does not bump daemon protocol - if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP: ${{ contains(github.event.pull_request.labels.*.name, 'allow-runtime-launcher-protocol-bump') }} @@ -1290,7 +1244,7 @@ jobs: # Kept briefly so a reviewer can run this exact AppImage on a real Linux desktop. - uses: actions/upload-artifact@v7 with: - name: orca-linux-appimage-${{ github.event.pull_request.head.sha || github.sha }} + name: orca-linux-appimage-${{ github.event.pull_request.head.sha }} path: dist/orca-linux.AppImage retention-days: 3 compression-level: 0 @@ -1585,7 +1539,7 @@ jobs: verify: name: PR checks passed # Publish an explicit result after every required dependency settles. - if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} + if: ${{ !cancelled() && github.event.pull_request.draft != true }} needs: - code_paths - preflight diff --git a/config/scripts/ast-grep-gates.mjs b/config/scripts/ast-grep-gates.mjs index b87b4580d70..e604c6e5dbd 100644 --- a/config/scripts/ast-grep-gates.mjs +++ b/config/scripts/ast-grep-gates.mjs @@ -1,6 +1,5 @@ // One ast-grep scan for both gates: the lint gate (warning and up) and the flaky-test ratchet (`test-*` rules). import { spawnSync } from 'node:child_process' -import { readFileSync } from 'node:fs' import process from 'node:process' import { pathToFileURL } from 'node:url' import { @@ -55,12 +54,7 @@ export function main(root = process.cwd(), argv = []) { const format = argv .find((argument) => argument.startsWith('--format=')) ?.slice('--format='.length) - // A scoped PR scan reads its NUL-delimited changed paths; an empty scope has nothing to match. - const pathsFrom = argv - .find((argument) => argument.startsWith('--paths-from=')) - ?.slice('--paths-from='.length) - const paths = pathsFrom ? readFileSync(pathsFrom, 'utf8').split('\0').filter(Boolean) : undefined - const matches = paths?.length === 0 ? [] : scanAstGrep(root, { paths }) + const matches = scanAstGrep(root) const files = lintFindingFiles(matches) const lint = files.length > 0 ? reportLintFindings(root, files, format) : 0 const flaky = checkFlakyTestPatterns(root, undefined, matches) diff --git a/config/scripts/git-pull-request-diff-base.mjs b/config/scripts/git-pull-request-diff-base.mjs index 09eb7e91a25..69faf920af2 100644 --- a/config/scripts/git-pull-request-diff-base.mjs +++ b/config/scripts/git-pull-request-diff-base.mjs @@ -6,10 +6,6 @@ export function selectPullRequestDiffBase(requestedBase, headParents, eventName) if (eventName === 'pull_request' && headParents.length >= 2) { return headParents[0] } - // A main push has no payload base; its first parent is the main tip it landed on. - if (eventName === 'push' && !requestedBase && headParents.length >= 1) { - return headParents[0] - } return requestedBase } diff --git a/config/scripts/git-pull-request-diff-base.test.mjs b/config/scripts/git-pull-request-diff-base.test.mjs index 2487c7f1cf6..4c1e0b8497f 100644 --- a/config/scripts/git-pull-request-diff-base.test.mjs +++ b/config/scripts/git-pull-request-diff-base.test.mjs @@ -16,10 +16,4 @@ describe('pull request diff base selection', () => { 'requested-base' ) }) - - it('uses the first parent for main pushes, which carry no payload base', () => { - expect(selectPullRequestDiffBase('', ['previous-main'], 'push')).toBe('previous-main') - expect(selectPullRequestDiffBase(undefined, ['previous-main'], 'push')).toBe('previous-main') - expect(selectPullRequestDiffBase('', [], 'push')).toBe('') - }) }) diff --git a/config/scripts/lint-change-scope.mjs b/config/scripts/lint-change-scope.mjs deleted file mode 100644 index 95fe56f21e5..00000000000 --- a/config/scripts/lint-change-scope.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import { AST_GREP_SCAN_PATHS } from './ast-grep-scan.mjs' - -// Per-file passes only: each finding depends on one file plus the lint toolchain, so unchanged files cannot regress. -export const LINT_PASS_ROOTS = { - oxlint: null, - 'js-plugins': ['src', 'config', 'tests', 'mobile', 'native', '.github'], - 'anti-slop': ['src', 'config', 'tests', 'mobile'], - 'ast-grep': AST_GREP_SCAN_PATHS, - oxfmt: null -} - -// Past this the scoped run saves little, and long path lists approach command-line limits. -export const MAX_SCOPED_FILES = 1500 - -const TOOLCHAIN_FILES = new Set([ - 'package.json', - 'pnpm-lock.yaml', - 'pnpm-workspace.yaml', - '.npmrc', - '.gitignore', - '.prettierignore', - '.oxfmtrc.json', - '.github/workflows/pr.yml', - 'config/sgconfig.yml', - 'config/flaky-test-pattern-baseline.txt', - 'config/scripts/ast-grep-gates.mjs', - 'config/scripts/ast-grep-scan.mjs', - 'config/scripts/check-flaky-test-pattern-ratchet.mjs', - 'config/scripts/sync-anti-slop-plugin.mjs' -]) - -const TOOLCHAIN_PREFIXES = [ - 'config/oxlint-plugins/', - 'config/ast-grep/', - 'config/scripts/lint-change-scope' -] - -// docs/site enables oxlint's import plugin, whose rules can read sibling modules. -const WHOLE_DIRECTORY_SCOPES = ['docs/site/'] - -export function lintToolchainChange(changedFiles) { - return changedFiles.find( - (file) => - TOOLCHAIN_FILES.has(file) || - TOOLCHAIN_PREFIXES.some((prefix) => file.startsWith(prefix)) || - /(?:^|\/)\.oxlintrc\.json$/.test(file) || - /(?:^|\/)package\.json$/.test(file) || - /^config\/oxlint-[^/]+\.json$/.test(file) - ) -} - -function underRoots(file, roots) { - return roots === null || roots.some((root) => file === root || file.startsWith(`${root}/`)) -} - -/** - * Returns `{ scope: 'full', reason }` when any pass must lint the whole tree, otherwise the existing - * changed paths per pass. Deleted paths drop out; a pass with no paths has nothing to check. - */ -export function planLintScope(changedFiles, { exists = existsSync } = {}) { - if (changedFiles.length === 0) { - return { scope: 'full', reason: 'No changed paths were detected.' } - } - const toolchain = lintToolchainChange(changedFiles) - if (toolchain) { - return { scope: 'full', reason: `Lint toolchain input changed: ${toolchain}` } - } - if (changedFiles.length > MAX_SCOPED_FILES) { - return { - scope: 'full', - reason: `${changedFiles.length} changed paths exceed the scoped limit.` - } - } - const present = [...new Set(changedFiles)].filter((file) => exists(file)).sort() - const passes = Object.fromEntries( - Object.entries(LINT_PASS_ROOTS).map(([pass, roots]) => [ - pass, - present.filter((file) => underRoots(file, roots)) - ]) - ) - for (const directory of WHOLE_DIRECTORY_SCOPES) { - if (changedFiles.some((file) => file.startsWith(directory))) { - passes.oxlint = [ - ...passes.oxlint.filter((file) => !file.startsWith(directory)), - directory.slice(0, -1) - ] - } - } - return { scope: 'changed', reason: `${present.length} changed path(s) exist.`, passes } -} - -// Usage: lint-change-scope.mjs -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [changedPath, outputDirectory] = process.argv.slice(2) - const plan = planLintScope(readFileSync(changedPath, 'utf8').split('\0').filter(Boolean)) - mkdirSync(outputDirectory, { recursive: true }) - for (const [pass, files] of Object.entries(plan.passes ?? {})) { - writeFileSync(path.join(outputDirectory, pass), files.map((file) => `${file}\0`).join('')) - } - console.error(plan.reason) - process.stdout.write(`${plan.scope}\n`) -} diff --git a/config/scripts/lint-change-scope.test.mjs b/config/scripts/lint-change-scope.test.mjs deleted file mode 100644 index 450d00acb09..00000000000 --- a/config/scripts/lint-change-scope.test.mjs +++ /dev/null @@ -1,123 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' -import { MAX_SCOPED_FILES, planLintScope } from './lint-change-scope.mjs' - -const exists = (file) => !file.startsWith('deleted/') && !file.includes('/deleted-') - -describe('per-file lint scope', () => { - it.each([ - '.oxlintrc.json', - 'mobile/.oxlintrc.json', - 'docs/site/.oxlintrc.json', - 'config/oxlint-js-plugins.json', - 'config/oxlint-anti-slop.json', - 'config/oxlint-plugins/ime-enter-guard.mjs', - 'config/sgconfig.yml', - 'config/ast-grep/rules/no-repeated-collator.yml', - 'config/flaky-test-pattern-baseline.txt', - '.oxfmtrc.json', - '.gitignore', - 'package.json', - 'mobile/package.json', - 'pnpm-lock.yaml', - '.github/workflows/pr.yml', - 'config/scripts/ast-grep-gates.mjs', - 'config/scripts/lint-change-scope.mjs' - ])('checks everything when a lint toolchain input changes: %s', (file) => { - expect(planLintScope(['src/main/a.ts', file], { exists }).scope).toBe('full') - }) - - it('checks everything when the diff is empty or too large to scope', () => { - expect(planLintScope([], { exists }).scope).toBe('full') - const many = Array.from({ length: MAX_SCOPED_FILES + 1 }, (_, i) => `src/main/f${i}.ts`) - expect(planLintScope(many, { exists }).scope).toBe('full') - }) - - it('routes existing changed paths to each pass by its scanned roots', () => { - const plan = planLintScope( - [ - 'src/main/a.ts', - 'src/main/deleted-b.ts', - 'docs/guide.md', - '.github/scripts/check.mjs', - 'native/addon.ts', - 'mobile/src/c.tsx', - 'src/main/a.ts' - ], - { exists } - ) - expect(plan.scope).toBe('changed') - expect(plan.passes).toEqual({ - oxlint: [ - '.github/scripts/check.mjs', - 'docs/guide.md', - 'mobile/src/c.tsx', - 'native/addon.ts', - 'src/main/a.ts' - ], - 'js-plugins': [ - '.github/scripts/check.mjs', - 'mobile/src/c.tsx', - 'native/addon.ts', - 'src/main/a.ts' - ], - 'anti-slop': ['mobile/src/c.tsx', 'src/main/a.ts'], - 'ast-grep': ['mobile/src/c.tsx', 'src/main/a.ts'], - oxfmt: [ - '.github/scripts/check.mjs', - 'docs/guide.md', - 'mobile/src/c.tsx', - 'native/addon.ts', - 'src/main/a.ts' - ] - }) - }) - - it('leaves every pass empty when only deleted paths changed', () => { - const plan = planLintScope(['deleted/a.ts', 'src/main/deleted-b.ts'], { exists }) - expect(plan.scope).toBe('changed') - expect(Object.values(plan.passes).every((files) => files.length === 0)).toBe(true) - }) - - it('lints all of docs/site, whose import rules read sibling modules', () => { - const plan = planLintScope(['docs/site/src/page.tsx', 'src/main/a.ts'], { exists }) - expect(plan.passes.oxlint).toEqual(['src/main/a.ts', 'docs/site']) - expect(plan.passes.oxfmt).toEqual(['docs/site/src/page.tsx', 'src/main/a.ts']) - }) -}) - -describe('preflight lint scope wiring', () => { - const steps = parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.preflight.steps - const select = steps.find((step) => step.id === 'lint-scope') - - it('scopes only pull requests, and falls back to everything when detection fails', () => { - expect(select.run).toContain('scope=full') - expect(select.run).toContain('[ "$GITHUB_EVENT_NAME" = pull_request ]') - expect(select.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD') - expect(select.run).toContain('|| scope=full') - }) - - it.each([ - ['root-lint', 'oxlint'], - ['js-plugin-lint', 'js-plugins'], - ['anti-slop', 'anti-slop'] - ])('%s reads its own changed paths and tolerates all-ignored selections', (id, pass) => { - const step = steps.find((candidate) => candidate.id === id) - expect(step.env.LINT_SCOPE).toBe('${{ steps.lint-scope.outputs.scope }}') - expect(step.run).toContain(`"$RUNNER_TEMP/lint-scope/${pass}"`) - expect(step.run).toContain('--no-error-on-unmatched-pattern "${paths[@]}"') - }) - - it('keeps whole-program passes unscoped', () => { - for (const name of [ - 'Enforce focused code-quality plugins', - 'Enforce type-aware code-quality baseline', - 'Check process-host imports', - 'Enforce dead design-system classes' - ]) { - const step = steps.find((candidate) => candidate.name === name) - expect(step.env?.LINT_SCOPE, name).toBeUndefined() - } - }) -}) diff --git a/config/scripts/main-health-alert.mjs b/config/scripts/main-health-alert.mjs deleted file mode 100644 index 6cafe5836a9..00000000000 --- a/config/scripts/main-health-alert.mjs +++ /dev/null @@ -1,213 +0,0 @@ -import process from 'node:process' -import { pathToFileURL } from 'node:url' - -// Posts to Slack only when main's PR Checks turn red or recover, so a burst of broken merges sends one alert. - -const RED = new Set(['failure', 'timed_out', 'startup_failure']) -const GREEN = new Set(['success']) -const MAX_LISTED_JOBS = 10 - -export function health(conclusion) { - if (RED.has(conclusion)) { - return 'red' - } - return GREEN.has(conclusion) ? 'green' : undefined -} - -const completedAt = (run) => Date.parse(run.updated_at ?? '') || 0 - -/** - * Decides what the completed run `current` reports, given main push runs of the same workflow. - * Main's state is the streak of same-health settled runs ending at the newest one; the streak's - * first-completed member announces it. Every handler computes the same announcer from the same - * history, so a burst of reds alerts once however their handlers interleave, and a slower older - * run outside the newest streak cannot reopen a state a newer run already reported. - * A re-run compares with its own earlier attempt, so a flaky red that passes on retry still recovers. - */ -export function decideTransition(current, runs, previousAttempt = undefined) { - const now = health(current.conclusion) - if (!now) { - return { action: 'none', reason: `Run concluded ${current.conclusion}; health is unchanged.` } - } - const settled = [ - ...runs.filter( - (run) => run.id !== current.id && run.status === 'completed' && health(run.conclusion) - ), - current - ].sort((left, right) => right.run_number - left.run_number) - const streakEnd = settled.findIndex( - (run) => health(run.conclusion) !== health(settled[0].conclusion) - ) - const streak = streakEnd === -1 ? settled : settled.slice(0, streakEnd) - const previous = streakEnd === -1 ? undefined : settled[streakEnd] - if (!streak.includes(current)) { - return { - action: 'none', - reason: `Newer run #${settled[0].run_number} already settled main ${health(settled[0].conclusion)}.` - } - } - const retried = health(previousAttempt?.conclusion) - if (retried === now) { - return { action: 'none', reason: `The re-run kept main ${now}.` } - } - if (retried && current === settled[0]) { - return { action: now === 'red' ? 'broken' : 'recovered', previous: previousAttempt } - } - const announcer = streak.reduce((first, run) => - completedAt(run) < completedAt(first) || - (completedAt(run) === completedAt(first) && run.run_number < first.run_number) - ? run - : first - ) - if (announcer !== current) { - return { action: 'none', reason: `Run #${announcer.run_number} announces this ${now} streak.` } - } - // With no history, assume main was green so the first failure is still announced. - if ((previous ? health(previous.conclusion) : 'green') === now) { - return { action: 'none', reason: `Main stays ${now}.` } - } - return { action: now === 'red' ? 'broken' : 'recovered', previous } -} - -// A malformed mapping only loses Slack mentions; the alert itself must still go out. -export function parseSlackUserIds(json, warn = console.warn) { - if (!json?.trim()) { - return {} - } - try { - const parsed = JSON.parse(json) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed - } - } catch { - // Reported below with the shape problem. - } - warn( - 'SLACK_USER_IDS is not a JSON object of GitHub login to Slack member ID; mentions use GitHub links.' - ) - return {} -} - -// Without a Slack member ID, fall back to the GitHub profile link the release pings use. -export function mention(login, slackUserIds) { - const id = slackUserIds[login] ?? slackUserIds[login.toLowerCase()] - return /^[UW][A-Z0-9]+$/.test(id ?? '') ? `<@${id}>` : `` -} - -function escapeMrkdwn(text) { - return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') -} - -export function buildMessage({ action, run, pullRequest, failedJobs, slackUserIds, repoUrl }) { - const sha = run.head_sha - const commit = `<${repoUrl}/commit/${sha}|\`${sha.slice(0, 12)}\`>` - const title = escapeMrkdwn(run.head_commit?.message?.split('\n')[0] ?? '') - const change = pullRequest - ? `<${pullRequest.html_url}|#${pullRequest.number} ${escapeMrkdwn(pullRequest.title)}>` - : title - const runLink = `<${run.html_url}|Open the run>` - if (action === 'recovered') { - return `:white_check_mark: main is green again at ${commit} (${change}). ${runLink}` - } - const logins = [ - ...new Set( - [pullRequest?.merged_by?.login, pullRequest?.user?.login, run.actor?.login].filter(Boolean) - ) - ].slice(0, 2) - const people = logins.map((login) => mention(login, slackUserIds)).join(' ') - const jobs = failedJobs.slice(0, MAX_LISTED_JOBS).map((name) => `• ${escapeMrkdwn(name)}`) - if (failedJobs.length > MAX_LISTED_JOBS) { - jobs.push(`• …and ${failedJobs.length - MAX_LISTED_JOBS} more`) - } - return [ - `:rotating_light: main is red after ${commit} (${change})${people ? ` ${people}` : ''}`, - jobs.length > 0 ? `Failed jobs:\n${jobs.join('\n')}` : 'No failed job was reported.', - runLink - ].join('\n') -} - -export function slackPayload(message) { - return { text: message, blocks: [{ type: 'section', text: { type: 'mrkdwn', text: message } }] } -} - -function githubClient(env, request) { - const api = env.GITHUB_API_URL ?? 'https://api.github.com' - return async (route) => { - const response = await request(`${api}${route}`, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${env.GH_TOKEN}`, - 'User-Agent': 'orca-main-health-alert', - 'X-GitHub-Api-Version': '2022-11-28' - }, - signal: AbortSignal.timeout(15_000) - }) - if (!response.ok) { - throw new Error(`GitHub ${route} returned HTTP ${response.status}`) - } - return response.json() - } -} - -export async function main(env = process.env, { request = fetch, log = console.log } = {}) { - const repository = env.GITHUB_REPOSITORY - if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || !/^\d+$/.test(env.RUN_ID ?? '')) { - throw new Error('GITHUB_REPOSITORY and RUN_ID are required.') - } - const github = githubClient(env, request) - const run = await github(`/repos/${repository}/actions/runs/${env.RUN_ID}`) - if (run.event !== 'push' || run.head_branch !== 'main') { - log(`Run ${run.id} is not a main push; nothing to report.`) - return undefined - } - const { workflow_runs: runs } = await github( - `/repos/${repository}/actions/workflows/${run.workflow_id}/runs?branch=main&event=push&status=completed&per_page=100` - ) - const previousAttempt = - run.run_attempt > 1 - ? await github(`/repos/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt - 1}`) - : undefined - const decision = decideTransition(run, runs, previousAttempt) - if (decision.action === 'none') { - log(decision.reason) - return undefined - } - const pulls = await github(`/repos/${repository}/commits/${run.head_sha}/pulls`) - const merged = pulls.find((pull) => pull.merged_at) ?? pulls[0] - const pullRequest = merged - ? await github(`/repos/${repository}/pulls/${merged.number}`) - : undefined - const { jobs } = await github( - `/repos/${repository}/actions/runs/${run.id}/jobs?filter=latest&per_page=100` - ) - const failedJobs = jobs.filter((job) => RED.has(job.conclusion)).map((job) => job.name) - const message = buildMessage({ - action: decision.action, - run, - pullRequest, - failedJobs, - slackUserIds: parseSlackUserIds(env.SLACK_USER_IDS), - repoUrl: `${env.GITHUB_SERVER_URL ?? 'https://github.com'}/${repository}` - }) - const payload = slackPayload(message) - if (env.DRY_RUN === '1' || !env.SLACK_WEBHOOK_URL) { - log(`Dry run (${env.SLACK_WEBHOOK_URL ? 'DRY_RUN=1' : 'no SLACK_WEBHOOK_URL'}); payload:`) - log(JSON.stringify(payload, null, 2)) - return payload - } - const response = await request(env.SLACK_WEBHOOK_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(15_000) - }) - if (!response.ok) { - throw new Error(`Slack webhook returned HTTP ${response.status}`) - } - log(`Posted the main ${decision.action} alert.`) - return payload -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main() -} diff --git a/config/scripts/main-health-alert.test.mjs b/config/scripts/main-health-alert.test.mjs deleted file mode 100644 index 501965b71d3..00000000000 --- a/config/scripts/main-health-alert.test.mjs +++ /dev/null @@ -1,233 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it, vi } from 'vitest' -import { parse } from 'yaml' -import { - buildMessage, - decideTransition, - main, - mention, - parseSlackUserIds -} from './main-health-alert.mjs' - -const run = (run_number, conclusion, extra = {}) => ({ - id: 1000 + run_number, - run_number, - status: 'completed', - conclusion, - ...extra -}) - -describe('main health transitions', () => { - it.each([ - ['green to red alerts', 'success', 'failure', 'broken'], - ['red to green recovers', 'failure', 'success', 'recovered'], - ['red stays red', 'failure', 'failure', 'none'], - ['green stays green', 'success', 'success', 'none'], - ['a timeout counts as red', 'success', 'timed_out', 'broken'] - ])('%s', (_name, before, now, action) => { - expect(decideTransition(run(2, now), [run(1, before), run(2, now)]).action).toBe(action) - }) - - it('never reports a cancelled or skipped run', () => { - for (const conclusion of ['cancelled', 'skipped', 'neutral']) { - expect(decideTransition(run(2, conclusion), [run(1, 'success')]).action).toBe('none') - } - }) - - it('looks past cancelled runs to the last settled state', () => { - const runs = [run(1, 'failure'), run(2, 'cancelled'), run(3, 'success')] - expect(decideTransition(run(3, 'success'), runs).action).toBe('recovered') - }) - - it('lets only the newest settled run speak when runs finish out of order', () => { - const runs = [run(1, 'success'), run(2, 'failure'), run(3, 'success')] - expect(decideTransition(run(2, 'failure'), runs).action).toBe('none') - }) - - it('announces the first failure when there is no earlier main run', () => { - expect(decideTransition(run(1, 'failure'), [run(1, 'failure')]).action).toBe('broken') - }) - - it('recovers when a red run passes on re-run, and stays quiet when a re-run stays red', () => { - const runs = [run(1, 'success'), run(2, 'success')] - expect(decideTransition(run(2, 'success'), runs, { conclusion: 'failure' }).action).toBe( - 'recovered' - ) - const red = [run(1, 'success'), run(2, 'failure')] - expect(decideTransition(run(2, 'failure'), red, { conclusion: 'failure' }).action).toBe('none') - }) - - it.each([ - ['#6 settles first', 100, 90], - ['#5 settles first', 90, 100] - ])( - 'sends exactly one alert for a burst of reds, whichever handler queries when: %s', - (_name, fifthDone, sixthDone) => { - const green = run(4, 'success', { updated_at: new Date(10_000).toISOString() }) - const fifth = run(5, 'failure', { updated_at: new Date(fifthDone * 1000).toISOString() }) - const sixth = run(6, 'failure', { updated_at: new Date(sixthDone * 1000).toISOString() }) - const unsettled = (pending) => ({ ...pending, status: 'in_progress', conclusion: null }) - // Each handler sees the runs that settled no later than itself, or every run if it queried late. - const views = (current) => { - const done = (other) => Date.parse(other.updated_at) <= Date.parse(current.updated_at) - const early = [green, fifth, sixth].map((other) => (done(other) ? other : unsettled(other))) - return [early, [green, fifth, sixth]] - } - for (const fifthView of views(fifth)) { - for (const sixthView of views(sixth)) { - const actions = [ - decideTransition(fifth, fifthView).action, - decideTransition(sixth, sixthView).action - ] - expect(actions.filter((action) => action === 'broken')).toHaveLength(1) - } - } - } - ) - - it('keeps a slow older red from reopening a state a newer green settled', () => { - const runs = [ - run(4, 'success', { updated_at: '2026-01-01T00:00:00Z' }), - run(6, 'success', { updated_at: '2026-01-01T00:05:00Z' }), - run(5, 'failure', { updated_at: '2026-01-01T00:09:00Z' }) - ] - expect(decideTransition(runs[2], runs).action).toBe('none') - }) -}) - -describe('Slack mentions', () => { - it('uses a configured Slack member ID and falls back to the GitHub profile link', () => { - const ids = parseSlackUserIds('{"octocat":"U0123ABC"}') - expect(mention('octocat', ids)).toBe('<@U0123ABC>') - expect(mention('someone', ids)).toBe('') - expect(parseSlackUserIds('')).toEqual({}) - const warn = vi.fn() - for (const malformed of ['[]', '{not json', '"U1"']) { - expect(parseSlackUserIds(malformed, warn)).toEqual({}) - } - expect(warn).toHaveBeenCalledTimes(3) - }) - - it('names the merger and author, the failed jobs and the run', () => { - const message = buildMessage({ - action: 'broken', - run: { - head_sha: 'a'.repeat(40), - html_url: 'https://github.com/o/r/actions/runs/9', - head_commit: { message: 'Fix \n\nbody' } - }, - pullRequest: { - number: 7, - title: 'Fix ', - html_url: 'https://github.com/o/r/pull/7', - user: { login: 'author' }, - merged_by: { login: 'merger' } - }, - failedJobs: Array.from({ length: 12 }, (_, i) => `job ${i}`), - slackUserIds: { merger: 'U1' }, - repoUrl: 'https://github.com/o/r' - }) - expect(message).toContain( - '' - ) - expect(message).toContain('') - expect(message).toContain('<@U1> ') - expect(message).toContain('• job 9\n• …and 2 more') - expect(message).toContain('') - }) -}) - -describe('alert run', () => { - const runs = { - 9: { - ...run(9, 'failure'), - event: 'push', - head_branch: 'main', - workflow_id: 5, - head_sha: 'b'.repeat(40), - html_url: 'https://github.com/o/r/actions/runs/1009', - run_attempt: 1 - } - } - const routes = { - '/repos/o/r/actions/runs/1009': runs[9], - '/repos/o/r/actions/workflows/5/runs?branch=main&event=push&status=completed&per_page=100': { - workflow_runs: [run(8, 'success'), runs[9]] - }, - [`/repos/o/r/commits/${'b'.repeat(40)}/pulls`]: [{ number: 3, merged_at: 'x' }], - '/repos/o/r/pulls/3': { - number: 3, - title: 'Change', - html_url: 'https://github.com/o/r/pull/3', - user: { login: 'dev' } - }, - '/repos/o/r/actions/runs/1009/jobs?filter=latest&per_page=100': { - jobs: [ - { name: 'static analysis and typecheck', conclusion: 'failure' }, - { name: 'package', conclusion: 'success' } - ] - } - } - const request = vi.fn(async (url) => { - const route = url.replace('https://api.github.com', '') - if (!(route in routes)) { - throw new Error(`unexpected ${url}`) - } - return { ok: true, json: async () => routes[route] } - }) - - it('prints the payload instead of posting without a webhook', async () => { - const log = vi.fn() - const payload = await main( - { GITHUB_REPOSITORY: 'o/r', RUN_ID: '1009', GH_TOKEN: 't' }, - { request, log } - ) - expect(payload.text).toContain('main is red') - expect(payload.text).toContain('• static analysis and typecheck') - expect(payload.text).not.toContain('• package') - expect(request.mock.calls.every(([url]) => url.startsWith('https://api.github.com'))).toBe(true) - expect(log.mock.calls[0][0]).toContain('Dry run') - }) - - it('posts once to the webhook when one is configured', async () => { - request.mockClear() - const post = vi.fn(async () => ({ ok: true })) - await main( - { - GITHUB_REPOSITORY: 'o/r', - RUN_ID: '1009', - GH_TOKEN: 't', - SLACK_WEBHOOK_URL: 'https://hooks.slack.test/x' - }, - { - request: (url, options) => - url.startsWith('https://hooks') ? post(url, options) : request(url, options), - log: vi.fn() - } - ) - expect(post).toHaveBeenCalledTimes(1) - expect(JSON.parse(post.mock.calls[0][1].body).blocks[0].text.text).toContain('main is red') - }) -}) - -describe('alert workflow', () => { - const workflow = parse(readFileSync('.github/workflows/main-health-alert.yml', 'utf8')) - const job = workflow.jobs.alert - - it('follows completed main PR Checks pushes and skips cancelled runs', () => { - expect(workflow.on.workflow_run).toEqual({ - workflows: ['PR Checks'], - types: ['completed'], - branches: ['main'] - }) - expect(job.if).toContain("github.event.workflow_run.event == 'push'") - expect(job.if).toContain("github.event.workflow_run.conclusion != 'cancelled'") - expect(workflow.concurrency).toBeUndefined() - }) - - it('reuses the release Slack webhook secret', () => { - const step = job.steps.find((candidate) => candidate.run?.includes('main-health-alert.mjs')) - expect(step.env.SLACK_WEBHOOK_URL).toBe('${{ secrets.SLACK_WEBHOOK_URL }}') - expect(step.env.RUN_ID).toBe('${{ github.event.workflow_run.id }}') - }) -}) diff --git a/config/scripts/pr-main-push.test.mjs b/config/scripts/pr-main-push.test.mjs deleted file mode 100644 index 9bb2f93fe2d..00000000000 --- a/config/scripts/pr-main-push.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { parse } from 'yaml' -import { compileAggregateExpression } from './pr-aggregate-gate-context.mjs' - -const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const evaluate = (expression, github) => - compileAggregateExpression(expression, ['github'])({ github }).coerceString() - -describe('main push runs of PR Checks', () => { - it('run every main commit to completion without cancelling one another', () => { - expect(workflow.on.push).toEqual({ branches: ['main'] }) - const push = { event_name: 'push', sha: 'abc', event: {} } - const pr = { event_name: 'pull_request', sha: 'def', event: { pull_request: { number: 7 } } } - expect(evaluate(workflow.concurrency.group, push)).toBe('main-checks-abc') - expect(evaluate(workflow.concurrency['cancel-in-progress'], push)).toBe('false') - expect(evaluate(workflow.concurrency.group, pr)).toBe('pr-checks-7') - expect(evaluate(workflow.concurrency['cancel-in-progress'], pr)).toBe('true') - }) - - it('select every job, and run every input-scoped step in full', () => { - const classify = workflow.jobs.code_paths.steps.find((step) => step.id === 'filter') - expect(classify.run).toContain(`if [ "$GITHUB_EVENT_NAME" = push ]; then CHANGED=''`) - for (const name of [ - 'Verify localization extraction', - 'Check VM runtime rollback compatibility', - 'Decide whether the orcad terminal smoke runs', - 'Select per-file lint scope' - ]) { - const step = workflow.jobs.preflight.steps.find((candidate) => candidate.name === name) - expect(step.run, name).toContain('[ "$GITHUB_EVENT_NAME" = pull_request ]') - } - }) - - it('report a result for main pushes through the aggregate', () => { - const verify = compileAggregateExpression(workflow.jobs.verify.if, ['github']) - expect(verify({ github: { event_name: 'push', event: {} } }).coerceString()).toBe('true') - }) -}) diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index aa117cc20bc..0e34329a562 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -126,9 +126,8 @@ describe('ready-for-review required check reuse', () => { }) it('keeps required skips conditional on proof and leaves advisory routing eligible', () => { - // Main pushes title their runs 'main', which never matches a PR identity. expect(workflow['run-name']).toBe( - "${{ github.event_name == 'push' && 'main' || format('PR {0}', github.event.pull_request.number) }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" + "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}" ) expect(workflow.on.pull_request.types).toContain('ready_for_review') const detector = workflow.jobs.code_paths @@ -150,7 +149,7 @@ describe('ready-for-review required check reuse', () => { expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) } expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe( - "github.event_name == 'pull_request' && github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" + "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.needs).toEqual([ 'code_paths',