diff --git a/.github/actions/prepare-git-compatibility/action.yml b/.github/actions/prepare-git-compatibility/action.yml new file mode 100644 index 00000000000..dce54649421 --- /dev/null +++ b/.github/actions/prepare-git-compatibility/action.yml @@ -0,0 +1,30 @@ +name: Prepare baseline Git compatibility binary +description: Restore or build the pinned Linux Git binary for the compatibility contract. + +runs: + using: composite + steps: + - name: Cache baseline Git build + uses: actions/cache@v5 + with: + path: ~/.cache/orca-git-compat/git-2.25.5 + key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 + + # Finish the CPU-heavy build before any timed compatibility lanes start. + - name: Build the baseline Git binary + shell: bash + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$HOME/.cache/orca-git-compat/git-2.25.5" + if [ -x "$source/git" ]; then + exit 0 + fi + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + # Object files are no longer needed after linking the cached binary. + find "$source" -name '*.o' -delete diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml new file mode 100644 index 00000000000..9066dd266a3 --- /dev/null +++ b/.github/workflows/ci-cache-warmup.yml @@ -0,0 +1,65 @@ +name: Warm shared CI caches + +on: + schedule: + - cron: '41 * * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-git-compatibility/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'config/tsconfig*.json' + - 'config/scripts/ensure-native-runtime.mjs' + - 'config/scripts/rebuild-native-deps.mjs' + - 'config/patches/node-pty@1.1.0.patch' + - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' + - 'native/windows-registry/**' + pull_request: + paths: + - '.github/workflows/ci-cache-warmup.yml' + - '.github/actions/prepare-git-compatibility/**' + - 'config/scripts/ci-cache-warmup-workflow.test.mjs' + +permissions: + contents: read + +concurrency: + group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + warm: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Default-branch caches can be restored by every PR; PR caches cannot. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: '24' + + - uses: ./.github/actions/prepare-git-compatibility + + - name: Cache TypeScript incremental state + id: typecheck-cache + uses: actions/cache@v5 + with: + path: config/*.tsbuildinfo + key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }} + restore-keys: | + tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}- + + - name: Refresh TypeScript state for this commit + if: steps.typecheck-cache.outputs.cache-hit != 'true' + run: pnpm run typecheck diff --git a/.github/workflows/cloud-verify.yml b/.github/workflows/cloud-verify.yml index 6191552dab9..02f2d1822ec 100644 --- a/.github/workflows/cloud-verify.yml +++ b/.github/workflows/cloud-verify.yml @@ -32,7 +32,14 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + + # Scan every ancestor of HEAD without downloading unrelated branch/tag histories. + - name: Fetch complete scan history + working-directory: . + run: | + git fetch --no-tags --unshallow origin "$GITHUB_SHA" + test "$(git rev-parse --is-shallow-repository)" = false - name: Scan Cloud history reachable from HEAD with Gitleaks run: >- diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml index 1602bae956a..6b49177ed56 100644 --- a/.github/workflows/git-command-termination-runtime.yml +++ b/.github/workflows/git-command-termination-runtime.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: git-command-termination-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-exit: runs-on: windows-latest diff --git a/.github/workflows/issue-os-labeler.yaml b/.github/workflows/issue-os-labeler.yaml index c437db7f21a..cf0ee59a842 100644 --- a/.github/workflows/issue-os-labeler.yaml +++ b/.github/workflows/issue-os-labeler.yaml @@ -12,7 +12,7 @@ permissions: jobs: apply-os-label: - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Apply OS label from issue form diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 9f9df4f9b0b..592bc75bb5e 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -9,6 +9,9 @@ on: workflow_dispatch: permissions: contents: read +concurrency: + group: pi-owner-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pi-provider-runtime.yml b/.github/workflows/pi-provider-runtime.yml index 837c38baf9a..31046737499 100644 --- a/.github/workflows/pi-provider-runtime.yml +++ b/.github/workflows/pi-provider-runtime.yml @@ -7,6 +7,9 @@ on: - '.github/workflows/pi-provider-runtime.yml' permissions: contents: read +concurrency: + group: pi-provider-runtime-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: runtime: strategy: diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index a884aedc636..fb2934d1f73 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -19,7 +19,8 @@ permissions: jobs: loc: name: test vs non-test LoC - runs-on: ubuntu-latest + # API calls and a small Node script fit the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 2 steps: # Why no checkout: the Files API already has per-file additions/deletions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6ac6de78ed3..781617d0116 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -23,8 +23,10 @@ jobs: # Per-job outputs also skip git-compat/xterm/packaging/shell when those # inputs are unchanged; empty diffs fail closed and run everything. code_paths: - name: detect code-relevant changes - runs-on: ubuntu-latest + name: detect changes and check repository guards + # Reuse one lightweight checkout for detection and the always-required guards. + runs-on: ubuntu-slim + timeout-minutes: 5 outputs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} @@ -56,8 +58,27 @@ jobs: # few this job actually reads on demand. fetch-depth: 0 filter: blob:none + sparse-checkout: | + /package.json + /README.md + /docs/readme/ + /.github/scripts/check-root-directory-entries.mjs + /config/scripts/check-readme-local-links.mjs + /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-e2e-source-routing.mjs + sparse-checkout-cone-mode: false 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: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" + + # The full Git index retains link targets outside the sparse working tree. + - name: Check README local links + run: node config/scripts/check-readme-local-links.mjs + - name: Classify changed paths id: filter env: @@ -249,37 +270,11 @@ 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: - # Why blob:none: full history is needed for the merge-base diff, but historical - # file contents are not. Blobs are ~89% of this repo's pack, and Git fetches the - # few this job actually reads on demand. - fetch-depth: 0 - filter: blob:none - 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: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA" - - # Why here: the READMEs embed media owned by docs/site and resources/onboarding, - # and the classifier skips static_analysis for docs-only diffs. This job runs - # on every PR and needs no install. - - name: Check README local links - run: node config/scripts/check-readme-local-links.mjs - typecheck: needs: [code_paths] if: needs.code_paths.outputs.typecheck == 'true' - runs-on: ubuntu-latest + # Typechecking uses no native runtime, so it can use the free public ARM runner. + runs-on: ubuntu-24.04-arm steps: - name: Checkout @@ -317,39 +312,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies - # Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the - # same binary on every PR for minutes of runner time. The key carries the version - # because that is the only input; the sha256 assertion below still guards the - # tarball on the miss path that actually builds. Only this PR's own later pushes - # can restore it — GitHub scopes a cache written from a pull_request run to that - # ref — so a first push always takes the build path below. - - name: Cache baseline Git build - uses: actions/cache@v5 - with: - path: ~/.cache/orca-git-compat/git-2.25.5 - key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5 - - # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below - # spend their wall clock waiting on container starts, not on Git. Sharing a runner - # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so - # the build has to finish before anything timed starts. - - name: Build the baseline Git binary - run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$HOME/.cache/orca-git-compat/git-2.25.5" - if [ -x "$source/git" ]; then - exit 0 - fi - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j"$(nproc)" \ - NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - # Why: the linked binaries are what the next run needs; the objects that - # produced them are most of the tree and would bloat the cache entry. - find "$source" -name '*.o' -delete + - uses: ./.github/actions/prepare-git-compatibility - name: Verify Git binary compatibility matrix run: | @@ -603,8 +566,9 @@ jobs: test: needs: [code_paths, test_native_cache] + # Honor cancellation while allowing the optional native-cache primer to skip. if: >- - always() && + !cancelled() && needs.code_paths.outputs.test == 'true' && (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') uses: ./.github/workflows/unit-tests.yml @@ -1089,11 +1053,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} verify: - if: always() + if: ${{ !cancelled() }} needs: - code_paths - static_analysis - - root_directory_guard - typecheck - git_compatibility - codex_index_heal_contract @@ -1106,7 +1069,9 @@ jobs: - managed_hook_node18 - package - package_windows - runs-on: ubuntu-latest + # Evaluating job results only needs the lightweight container runner. + runs-on: ubuntu-slim + timeout-minutes: 5 steps: # Why: e2e is deliberately absent from needs. The suite is currently red on @@ -1123,7 +1088,6 @@ jobs: SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} STATIC_ANALYSIS: ${{ needs.static_analysis.result }} STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} @@ -1152,9 +1116,6 @@ jobs: if [ "$CODE_PATHS" != "success" ]; then exit 1 fi - if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then - exit 1 - fi if [ "$SHOULD_RUN" != "true" ]; then echo "Docs-only change; expensive PR checks skipped." fi diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 8014145939b..e6ab98f0d0d 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -16,7 +16,7 @@ concurrency: jobs: enforce: if: github.repository == 'stablyai/orca' - runs-on: ubuntu-latest + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Enforce release policy diff --git a/.github/workflows/track-community-prs.yaml b/.github/workflows/track-community-prs.yaml index 5a5dcb670c2..7a588663fb2 100644 --- a/.github/workflows/track-community-prs.yaml +++ b/.github/workflows/track-community-prs.yaml @@ -15,7 +15,8 @@ permissions: jobs: track-community-pr: - runs-on: ubuntu-latest + # API-only bookkeeping fits the free single-CPU container runner. + runs-on: ubuntu-slim timeout-minutes: 5 steps: - name: Generate bufo-bot token diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318..73748238f5f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -33,9 +33,6 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' - cache-dependency-path: | - pnpm-lock.yaml - cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index cd56aa98c0a..1b095416708 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { deserialize, serialize } from 'node:v8' import { describe, expect, it } from 'vitest' import { MOBILE_WEB_APP_NATIVE_PARITY_STYLE, @@ -74,6 +75,25 @@ async function withScratch(run) { } } +// Snapshots preserve Buffer methods and give each assertion its own mutable copy. +let appBundleSnapshot +let writtenBundleSnapshot + +async function readAppBundle() { + appBundleSnapshot ??= bundleMobileWebApp().then(serialize) + // Deserialized Buffers alias their snapshot, so copy it before exposing them. + return deserialize(Buffer.from(await appBundleSnapshot)) +} + +async function readWrittenBundle() { + writtenBundleSnapshot ??= withScratch(async (scratch) => { + const { outDir, ...result } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') }) + const html = await readFile(join(outDir, 'index.html'), 'utf8') + return serialize({ ...result, html }) + }) + return deserialize(await writtenBundleSnapshot) +} + describe('the CRLF pin', () => { it('exempts the same extensions in .gitattributes as the CRLF scan skips', async () => { const attributes = await readFile(join(projectDir, '.gitattributes'), 'utf8') @@ -91,8 +111,23 @@ describe('the CRLF pin', () => { }) describeBundling('the app bundle', () => { + it('isolates read-only fixture consumers from mutations in another assertion', async () => { + const first = await readAppBundle() + const original = first.script[0] + first.script[0] ^= 255 + first.chunks.length = 0 + first.routeKeys.length = 0 + const next = await readAppBundle() + expect(next.script[0]).toBe(original) + expect(next.chunks.length).toBeGreaterThan(0) + expect(next.routeKeys.length).toBeGreaterThan(0) + const written = await readWrittenBundle() + written.manifest.assets.length = 0 + expect((await readWrittenBundle()).manifest.assets.length).toBeGreaterThan(0) + }, 120_000) + it('resolves react-native to react-native-web and leaves no require.context', async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) for (const source of sources) { expect(source).not.toContain('require.context') } @@ -101,7 +136,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('cuts the routes into chunks the entry does not load', async () => { - const { script, chunks, entryStaticBytes } = await bundleMobileWebApp() + const { script, chunks, entryStaticBytes } = await readAppBundle() expect(chunks.length).toBeGreaterThan(1) // The entry's own bytes plus the chunks it imports statically, which is what the browser // parses before any route paints. Every route chunk is outside it. @@ -112,7 +147,7 @@ describeBundling('the app bundle', () => { }, 120_000) it('names the chunk each route lands in', async () => { - const { chunks, routeChunks, routeKeys } = await bundleMobileWebApp() + const { chunks, routeChunks, routeKeys } = await readAppBundle() expect(Object.keys(routeChunks).sort()).toEqual([...routeKeys].sort()) const emitted = new Set(chunks.map((chunk) => chunk.name)) for (const [key, name] of Object.entries(routeChunks)) { @@ -202,7 +237,7 @@ describeBundling('the app bundle', () => { ) it('bundles every route module', async () => { - const { routeKeys } = await bundleMobileWebApp() + const { routeKeys } = await readAppBundle() expect(routeKeys).toEqual(await collectMobileWebAppRouteKeys(appDir)) }, 120_000) @@ -274,7 +309,7 @@ describeBundling('the app bundle', () => { }, 240_000) it("names an output the same way the manifest's own asset hash does", async () => { - const { script, chunks } = await bundleMobileWebApp() + const { script, chunks } = await readAppBundle() // The name is embedded in the importer, so it cannot be recomputed later; this is what says // the name inside the bytes and the manifest's sha256 of those bytes are the same string. expect(hashedAsset(script, 'js').path).toBe(`assets/${sha256Hex(script)}.js`) @@ -330,7 +365,7 @@ describeBundling('the app bundle', () => { // once per call. The file explorer calls triggerSelection on every row tap, and C1.9 already // traced a swallowed long press on the worktree list to that stray click. `haptics.web.ts` is // what keeps the whole shim out of the bundle, so this reads the bytes rather than the import. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { // The shim's own fingerprint, not `navigator.vibrate`: react-native-web's Vibration export // calls that too, and it touches no DOM until something invokes it. expect(source).not.toContain('ariaHidden') @@ -340,7 +375,7 @@ describeBundling('the app bundle', () => { }, 120_000) it("ships react-native-web's hairline at one device pixel, whichever of its builds resolves", async () => { - const sources = allScriptSource(await bundleMobileWebApp()) + const sources = allScriptSource(await readAppBundle()) // Minified, so the assignment reads `.hairlineWidth=`; RNW's own value is the literal 1. const assignments = sources.flatMap( (source) => source.match(/\.hairlineWidth=[^;]{0,120}/g) ?? [] @@ -354,7 +389,7 @@ describeBundling('the app bundle', () => { it('embeds no absolute path from this checkout', async () => { // Every chunk, not only the entry: the route manifest names each route by absolute path, and // the chunk that import resolves to is where such a path would survive. - for (const source of allScriptSource(await bundleMobileWebApp())) { + for (const source of allScriptSource(await readAppBundle())) { expect(source).not.toContain(projectDir) } }, 120_000) @@ -370,76 +405,56 @@ describeBundling('the app bundle', () => { }, 120_000) it('loads the entry as a module, so its route imports resolve', async () => { - await withScratch(async (scratch) => { - const outDir = join(scratch, 'module-tag') - const { manifest } = await buildMobileWebAppBundle({ outDir }) - const html = await readFile(join(outDir, 'index.html'), 'utf8') - // import() in a classic script is a syntax error, so the tag and the format are one fact. - expect(html).toContain('