From edb5607e28fb587be44079eb4f77efe90b3841ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:48:24 -0700 Subject: [PATCH] ci: block new root-level entries (#11903) * ci: guard repository root additions * fix: clear existing type-aware lint warnings --- .../scripts/check-root-directory-entries.sh | 37 +++++++ .github/workflows/pr.yml | 20 ++++ .../check-root-directory-entries.test.mjs | 99 +++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../terminal-cold-park-resource-bench.mjs | 9 +- .../terminal-cold-park-reveal-bench.mjs | 9 +- tests/tools/win-update-e2e/app-driver.mjs | 4 +- .../tools/win-update-e2e/installer-steps.mjs | 2 +- 8 files changed, 172 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/check-root-directory-entries.sh create mode 100644 config/scripts/check-root-directory-entries.test.mjs diff --git a/.github/scripts/check-root-directory-entries.sh b/.github/scripts/check-root-directory-entries.sh new file mode 100755 index 000000000..c07d7d401 --- /dev/null +++ b/.github/scripts/check-root-directory-entries.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +base_sha=$1 +head_sha=$2 +git rev-parse --verify "${base_sha}^{tree}" >/dev/null +git rev-parse --verify "${head_sha}^{tree}" >/dev/null + +declare -A base_entries=() +while IFS= read -r -d '' entry; do + base_entries["$entry"]=1 +done < <(git ls-tree -z --name-only "$base_sha") + +blocked_entries=() +while IFS= read -r -d '' entry; do + if [[ -z "${base_entries[$entry]+present}" ]]; then + blocked_entries+=("$entry") + fi +done < <(git ls-tree -z --name-only "$head_sha") + +if (( ${#blocked_entries[@]} == 0 )); then + echo "Root directory guard passed: no new root-level files or folders." + exit 0 +fi + +echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page." +echo "Root directory guard failed." +echo "New root-level files or folders are not allowed because they bloat the GitHub landing page." +echo "Move each new entry under an existing top-level directory." +printf 'Blocked entries:\n' +printf ' %s\n' "${blocked_entries[@]}" +exit 1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9ae0aa2a6..c2c311945 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -93,6 +93,23 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements + root_directory_guard: + name: root directory guard + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA" + typecheck: runs-on: ubuntu-latest @@ -377,6 +394,7 @@ jobs: if: always() needs: - static_analysis + - root_directory_guard - typecheck - git_compatibility - shell_contracts @@ -397,6 +415,7 @@ jobs: - name: Require successful checks env: STATIC_ANALYSIS: ${{ needs.static_analysis.result }} + ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} @@ -406,6 +425,7 @@ jobs: run: | for result in \ "$STATIC_ANALYSIS" \ + "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs new file mode 100644 index 000000000..e8d3bb9bc --- /dev/null +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh') +const tempDirs = [] + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function makeFixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-')) + tempDirs.push(root) + git(root, ['init', '--quiet']) + git(root, ['config', 'user.email', 'root-directory-guard-test@example.com']) + git(root, ['config', 'user.name', 'Root Directory Guard Test']) + mkdirSync(join(root, 'config'), { recursive: true }) + writeFileSync(join(root, 'config', 'base.txt'), 'base\n') + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'base']) + return { root, base: git(root, ['rev-parse', 'HEAD']) } +} + +function commitFiles(root, files) { + for (const [relativePath, contents] of files) { + const target = join(root, relativePath) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'head']) + return git(root, ['rev-parse', 'HEAD']) +} + +function runGuard({ root, base, head }) { + return spawnSync('bash', [guardScript, base, head], { + cwd: root, + encoding: 'utf8' + }) +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('root directory guard', () => { + it('allows additions inside an existing top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']]) + + const result = runGuard({ ...fixture, head }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('no new root-level files or folders') + }) + + it('rejects a new root-level file with the landing-page message', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('bloat the GitHub landing page') + expect(output).toContain('new-root.md') + }) + + it('rejects a new top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('new-folder') + }) + + it('is wired into the PR verify gate', () => { + const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const guardJob = workflow.jobs.root_directory_guard + const guardStep = guardJob.steps.find( + (step) => step.name === 'Reject new root-level files and folders' + ) + + expect(guardJob.name).toBe('root directory guard') + expect(guardJob.steps[0].with['fetch-depth']).toBe(0) + expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh') + expect(workflow.jobs.verify.needs).toContain('root_directory_guard') + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5afa11074..d1e90fad1 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -173,6 +173,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'static_analysis', + 'root_directory_guard', 'typecheck', 'git_compatibility', 'shell_contracts', diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index b70fa88a2..63b7222a9 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -28,14 +28,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const PARK_DELAY_MS = 1_500 @@ -454,7 +456,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-res-${args.label}-${stamp}.json` diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index b28591535..a01cbf2cc 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -34,14 +34,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' import { pollUntil, rendererActionTimeoutMs, runWithTimeout, setupTimeoutMs } from '../../config/scripts/windows-apphang-repro/repro-timing.mjs' -import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const scenarioTimeoutMs = 300_000 @@ -582,7 +584,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-${args.label}-${stamp}.json` diff --git a/tests/tools/win-update-e2e/app-driver.mjs b/tests/tools/win-update-e2e/app-driver.mjs index b8d155c57..72603fba9 100644 --- a/tests/tools/win-update-e2e/app-driver.mjs +++ b/tests/tools/win-update-e2e/app-driver.mjs @@ -217,7 +217,7 @@ export async function captureFailureDiagnostics(page, dir, label) { * An expected tab id prevents post-restore probes from accepting another tab. */ export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTabId = null) { const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() await surface.waitFor({ state: 'visible', timeout: timeoutMs }) @@ -399,7 +399,7 @@ export async function focusActiveTerminal(page, terminalTabId = null) { // focusing so typed commands actually reach the shell. await dismissKnownOverlays(page) const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() const click = surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }) diff --git a/tests/tools/win-update-e2e/installer-steps.mjs b/tests/tools/win-update-e2e/installer-steps.mjs index 2ae7af4b6..92ab9f9e1 100644 --- a/tests/tools/win-update-e2e/installer-steps.mjs +++ b/tests/tools/win-update-e2e/installer-steps.mjs @@ -85,7 +85,7 @@ export function silentInstall(setupExe, { timeoutMs = 180_000, installDir = null // no other flags for a per-user install. /D, when present, MUST be last. const args = ['/S'] if (installDir) { - args.push(`/D=${installDir}`) + args.push(`/D=${String(installDir)}`) } const proc = spawnSync(setupExe, args, { encoding: 'utf8' }) if (proc.error) {