diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87eccc7cf3f..ab43fa93915 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,20 @@ on: type: choice default: all options: [all, release-gates, macos-control] + # Nightly safety net. The Windows shards run on a pull request only when the + # changes job judges it Windows-sensitive, so anything that slips past that + # filter must still be caught within a day: a scheduled run requests the full + # lane=all matrix (nine Windows shards and the macOS control included). + # + # GitHub runs schedules from the DEFAULT branch, which is main, so this + # trigger activates only once this file reaches main by promotion, and the + # workflow definition is main's. Every test checkout reads + # needs.changes.outputs.checkout_ref, which the changes job resolves to ONE + # dev commit on a schedule, so the run tests dev and every job tests the same + # SHA. Its check runs attach to main's head commit; release.yml accepts only a + # push-event run, so a scheduled run can never qualify a release. + schedule: + - cron: "17 18 * * *" permissions: contents: read @@ -88,8 +102,12 @@ concurrency: # `github.run_id` is unique per run, so each dispatch is a group of one: it # cancels nothing and nothing cancels it, including a second dispatch of the # same ref. - group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} - cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + # + # A scheduled run is keyed the same way. Keyed on the ref it would share + # refs/heads/main with a promotion push and, with cancel-in-progress, cancel + # the very run release.yml waits for. + group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'schedule' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' }} jobs: # Which Windows runner this run is allowed to use. @@ -118,6 +136,8 @@ jobs: # # `push` on main/preview requires the push permission, and # `workflow_dispatch` requires write access, so both carry a trusted author. + # A schedule runs main's workflow against a dev commit that already merged + # through review, the same trust as a push. # A trusted author is not audited code: merging a contributor PR into `dev` # fires `push`, and its dependencies and postinstall hooks then run here. select-windows-runner: @@ -141,7 +161,7 @@ jobs: set -euo pipefail trusted=no case "$EVENT_NAME" in - push|workflow_dispatch) trusted=yes ;; + push|workflow_dispatch|schedule) trusted=yes ;; esac # Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch, @@ -188,15 +208,26 @@ jobs: # shape-checked by the same validation step as `native`. keyring_matrix: ${{ steps.matrices.outputs.keyring_matrix }} npm_global_matrix: ${{ steps.matrices.outputs.npm_global_matrix }} - gui: ${{ steps.filter.outputs.gui }} - packaging: ${{ steps.filter.outputs.packaging }} - docs: ${{ steps.filter.outputs.docs }} - structure: ${{ steps.filter.outputs.structure }} + # A scheduled run starts from main, so its path filter diffs main and would + # scope jobs by the wrong commit. The nightly safety net selects every scope + # instead, here and in the validation steps below, so it is the full matrix. + gui: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.gui }} + packaging: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.packaging }} + docs: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docs }} + structure: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }} # Narrow scopes for two paths the ci filter leaves out on purpose. Both are re-emitted by the # validation step below, so a malformed filter output fails this job instead of silently # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} + # Whether this pull request needs the nine Windows shards: the windows path + # filter below OR an added src/tests line naming a Windows marker. Computed + # by scripts/ci/windows-sensitive-diff.sh, which validates its input and + # treats the diff as data. + windows: ${{ steps.windows.outputs.windows }} + # The commit every test job checks out. Empty (the event's own commit) + # except on a schedule, where it is the one dev SHA resolved below. + checkout_ref: ${{ steps.nightly.outputs.checkout_ref }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -205,6 +236,9 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + # The pull request merge commit plus its first parent, the base tip, + # so the Windows marker scan can diff exactly this pull request. + fetch-depth: 2 - name: Detect changed areas id: filter @@ -341,6 +375,32 @@ jobs: # real only on macOS and Windows, so its job lints and tests the crate on all three. remote_helper: - 'native/remote-workspace-helper/**' + # Paths whose change selects the nine Windows suite shards on a pull + # request. These carry the Windows service, update, shim, intercept, + # ACL and process code, and the harness every Windows shard runs. + # scripts/ci/windows-sensitive-diff.sh adds the content markers. + windows: + - 'src/lib/windows-*' + - 'src/lib/*acl*' + - 'src/service/**' + - 'src/update/**' + - 'src/claude/intercept/**' + - 'src/codex/shim*' + - 'src/codex/app-server-processes.ts' + - 'src/codex/desktop-app/**' + - 'src/config/process-state.ts' + - 'src/cli/update-restart*' + - 'src/server/local-management-read-client.ts' + - 'src/server/startup-health-cache.ts' + - 'src/tray/**' + - 'scripts/test.ts' + - 'scripts/test-*.ts' + - 'scripts/ci/**' + - 'tests/preload.ts' + - 'tests/helpers/**' + - '.github/workflows/ci.yml' + - 'package.json' + - 'bun.lock' # Everything that ends up inside `npm pack`, or that decides what # does. `src/**` belongs here because package.json ships `src` and # bin/ocx.mjs executes it: without that entry an ordinary source PR @@ -366,8 +426,8 @@ jobs: id: scope shell: bash env: - CI_SCOPE: ${{ steps.filter.outputs.ci }} - DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} + CI_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -393,7 +453,7 @@ jobs: id: matrices shell: bash env: - NATIVE_SELECTED: ${{ steps.filter.outputs.native }} + NATIVE_SELECTED: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.native }} run: | set -euo pipefail case "$NATIVE_SELECTED" in @@ -441,8 +501,8 @@ jobs: id: narrow shell: bash env: - SETUP_ACTION: ${{ steps.filter.outputs.setup_action }} - REMOTE_HELPER: ${{ steps.filter.outputs.remote_helper }} + SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} + REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} run: | set -euo pipefail for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do @@ -457,6 +517,38 @@ jobs: esac done + - name: Decide whether the Windows shards run + id: windows + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PATH_SELECTED: ${{ steps.filter.outputs.windows }} + run: bash scripts/ci/windows-sensitive-diff.sh + + # A scheduled run starts from main's head. Resolve dev ONCE so the nine + # Windows shards, the macOS control and every other job test one commit, + # however long the queue between them. contents: read already covers it. + - name: Resolve the commit a scheduled run tests + id: nightly + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "schedule" ]; then + echo 'checkout_ref=' >> "$GITHUB_OUTPUT" + exit 0 + fi + sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/dev" --jq .sha)" + if ! printf '%s' "$sha" | grep -Eqx '[0-9a-f]{40}'; then + printf '::error::could not resolve dev to a commit SHA\n' + exit 1 + fi + printf 'checkout_ref=%s\n' "$sha" >> "$GITHUB_OUTPUT" + printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" + printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" + # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file @@ -496,6 +588,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -552,6 +645,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -591,6 +685,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -626,6 +721,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -713,6 +809,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -764,8 +861,9 @@ jobs: needs: changes # Diagnostic-only lanes stay off the ordinary release gates: list every lane # that should run this job, so a new lane defaults to skipping the diagnostic. + # The nightly schedule is the full lane=all matrix, so it runs this job too. if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') runs-on: macos-latest # Unsharded full-membership control, one worker in sequential fresh batches. # Long-lived Bun isolate pools repeatedly wedged while synchronously reaping @@ -780,6 +878,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # tests/ci-workflows/release-version-line.test.ts compares package.json against the # newest release tag. actions/checkout fetches no tags by default, so # without this the check reads an empty tag set and passes on anything - @@ -826,7 +925,9 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help - # Windows runs only when a maintainer asks for it by hand. + # Windows runs on a Windows-sensitive pull request, on the nightly schedule, + # and on a lane=all (or default) dispatch. It is never part of the push run + # release.yml gates on. # # It left the PR lane first (16m23s against a 6-minute Linux critical path; # the ceiling was raised twice rather than the gap closed — #711 vs #653, @@ -842,11 +943,18 @@ jobs: # release.yml gates on a successful push-event run of this workflow, which # now means Linux + macOS + the gates; Windows re-enters the gate when the # tracked failures are fixed, not before. + # + # Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with + # green pull-request CI while breaking Windows, and the breakage surfaced days + # later on a manual dispatch. So a pull request whose changes job reports + # windows=true (a Windows path, or an added src/tests line naming a Windows + # marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards, + # and the daily schedule catches whatever that filter misses. platform-windows: name: windows ${{ matrix.shard }}/9 - needs: select-windows-runner + needs: [changes, select-windows-runner] if: >- - github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') + github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') || github.event_name == 'pull_request' && needs.changes.outputs.windows == 'true' runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }} # Sharded like the Linux legs. The single-leg run reached 30 minutes on a # green suite and was killed in cleanup; four shards put each leg inside the @@ -919,6 +1027,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Same reason as the Linux shards and the macOS control: this leg runs the # whole suite, and tests/ci-workflows/release-version-line.test.ts reads release tags. # Without tags the check sees an empty set and cannot fail. @@ -998,6 +1107,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1051,6 +1161,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1080,6 +1191,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1116,6 +1228,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1144,6 +1257,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun id: bun @@ -1181,6 +1295,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1214,6 +1329,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -1257,6 +1373,7 @@ jobs: # between jobs, so leaving a usable token in .git/config is avoidable # residue. Matches the convention already used by the other workflows. persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} # Deliberately NO setup-bun: prove `npm install -g` works without a # separately-installed Bun. The launcher uses the bundled `bun` dependency. @@ -1313,6 +1430,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -1390,6 +1508,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + ref: ${{ needs.changes.outputs.checkout_ref }} - name: Install Tauri Linux dependencies run: | @@ -1534,6 +1653,7 @@ jobs: CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} + CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1597,7 +1717,11 @@ jobs: fi dispatch=not-requested windows=not-requested - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$EVENT_NAME" = "schedule" ]; then + # The nightly safety net is the full lane=all matrix. + dispatch=requested + windows=requested + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then # Mirror the diagnostic job allowlist; release-gates requests neither suite. if [ -z "$LANE" ] || [ "$LANE" = "all" ] || [ "$LANE" = "macos-control" ]; then dispatch=requested @@ -1606,6 +1730,11 @@ jobs: if [ -z "$LANE" ] || [ "$LANE" = "all" ]; then windows=requested fi + elif [ "$EVENT_NAME" = "pull_request" ] && [ "${CHANGES_WINDOWS:-}" = "true" ]; then + # A Windows-sensitive pull request (path filter or added-line marker) runs the + # nine shards and requires them, exactly as dispatch and the schedule do. A + # shard that only fails on a pull request is a regression in that pull request. + windows=requested fi # One line per job, mirroring that job's own `if:`. Adding a job to this workflow diff --git a/scripts/ci/windows-sensitive-diff.sh b/scripts/ci/windows-sensitive-diff.sh new file mode 100644 index 00000000000..05bcaaba27e --- /dev/null +++ b/scripts/ci/windows-sensitive-diff.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Decide whether a pull request needs the nine Windows suite shards. +# +# The Windows leg costs nine runners for up to thirty minutes, so ordinary pull +# requests skip it. Before this selector it ran only on a manual lane=all +# dispatch, and #6639, #6556, #6605 and #6606 each landed green while breaking +# Windows; the breakage surfaced days later on a dispatch. A pull request is now +# Windows-sensitive when either: +# - the changes job's 'windows' path filter matched (PATH_SELECTED=true), or +# - an ADDED line under src/ or tests/ names a Windows marker (see markers below). +# +# The diff is pull-request content and is treated strictly as data: it goes to a +# file, awk keeps added lines, and grep -F matches fixed strings. Nothing from the +# diff is interpolated into a command, evaluated, or echoed. +# +# Inputs (environment): EVENT_NAME, PATH_SELECTED (true|false), GITHUB_OUTPUT. +# Output: windows=true|false appended to GITHUB_OUTPUT. +# Failure contract: an invalid PATH_SELECTED, a failing git diff, or a grep error +# fails the job. A missing base parent cannot be scanned, so it selects Windows. +set -euo pipefail + +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +event="${EVENT_NAME:-}" +selected="${PATH_SELECTED:-}" + +emit() { + printf 'windows=%s\n' "$1" >> "$GITHUB_OUTPUT" + printf 'windows-sensitive: %s (%s)\n' "$1" "$2" +} + +case "$selected" in + true|false) ;; + *) + printf '::error::windows path filter output was %q, expected true or false\n' "$selected" + exit 1 + ;; +esac + +if [ "$event" != "pull_request" ]; then + # Schedule and lane=all dispatch request Windows through the job condition; + # push never does. The value is informational outside pull requests. + emit "$selected" "path filter, ${event:-unknown} event" + exit 0 +fi + +if [ "$selected" = "true" ]; then + emit true "path filter" + exit 0 +fi + +# actions/checkout puts the pull request's merge commit at HEAD; with +# fetch-depth: 2 its first parent is the base tip, so HEAD^1..HEAD is exactly +# what this pull request changes on top of its base. +if ! git rev-parse --verify --quiet 'HEAD^1^{commit}' >/dev/null; then + printf '::warning::cannot read the base parent of the merge commit; selecting the Windows leg\n' + emit true "base parent unavailable" + exit 0 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +if ! git -c core.quotepath=off diff --no-ext-diff --no-textconv --no-color --unified=0 \ + 'HEAD^1' HEAD -- src tests > "$work/diff"; then + printf '::error::git diff of the pull request failed\n' + exit 1 +fi +awk 'substr($0, 1, 1) == "+" && substr($0, 1, 3) != "+++" { print substr($0, 2) }' \ + "$work/diff" > "$work/added" + +# Fixed strings, matched case-insensitively against added lines only. +cat > "$work/markers" <<'MARKERS' +win32 +powershell +pwsh +icacls +schtasks +get-acl +get-ciminstance +process.platform +localappdata +userprofile +MARKERS + +status=0 +grep -F -i -q -f "$work/markers" "$work/added" || status=$? +case "$status" in + 0) emit true "added line names a Windows marker" ;; + 1) emit false "no Windows path or marker" ;; + *) + printf '::error::marker scan failed with status %s\n' "$status" + exit 1 + ;; +esac diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 097529a9154..203c20aa7a0 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -11,9 +11,16 @@ request verifies Linux and TypeScript: Linux runs the suite in four shards with `changes` job's native path filter selects the change. `dev` pushes start nothing — dev integration is covered by the pull-request run — while `main` and `preview` must remain push triggers because `release.yml` requires a successful push-event run for the exact release SHA -and does not accept a pull-request run. Windows runs the full suite in nine shards only on manual -`workflow_dispatch` with `lane=all` (or an empty lane), and an aggregate green `ci` check -legitimately includes deliberate skips for every job the event did not request. +and does not accept a pull-request run. Windows runs the full suite in nine shards on a +Windows-sensitive pull request, on the nightly schedule, and on `workflow_dispatch` with +`lane=all` (or an empty lane); it never joins the push run `release.yml` gates on. A pull request +is Windows-sensitive when the `changes` job's `windows` path filter matches or an added +`src/`/`tests/` line names a Windows marker; `scripts/ci/windows-sensitive-diff.sh` makes that call, +treating the diff as data and selecting Windows when it cannot read the base parent. Wherever they run, `ci` requires them. The daily +`schedule` runs main's workflow (GitHub schedules only the default branch) against one dev SHA the +`changes` job resolves and every checkout reads, and requests the macOS control as well. An +aggregate green `ci` check legitimately includes deliberate skips for every job the event did not +request. This scoping accepts a real coverage loss: a green pull request no longer proves the macOS suite, the Rust toolchain, or the app bundle. Those regressions are caught at the promotion push to @@ -58,10 +65,15 @@ batch runner still sweeps a crashed or timed-out batch one file per process, but failure the shard has already taken. The aggregate `ci` gate derives, from the event and the `changes` outputs, which jobs this run actually requested, then requires `success` from every one of them and `skipped` from every job the event did not request — so a job that was requested and never started can no -longer report as a deliberate skip. On a `lane=all` dispatch the gate additionally reads the +longer report as a deliberate skip. Whenever Windows is requested the gate additionally reads the run's own job list through the Actions API and requires nine concrete successful `windows N/9` results, because a matrix rollup can report `success` when one matrix leg is skipped. A release that requires Windows proof still dispatches it for the exact publish SHA. +Test sandboxes keep the runner's `LOCALAPPDATA`, so Windows PowerShell 5.1 children reuse the +image's warm module-analysis cache. Replacing it with a freshly built seed made every child +re-analyze modules and timed out seven shards on the first run of #6670. +Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` +(`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: ```bash diff --git a/tests/ci-workflows/ci-concurrency-groups.test.ts b/tests/ci-workflows/ci-concurrency-groups.test.ts index 420e2b2e8bd..a8d233e6b33 100644 --- a/tests/ci-workflows/ci-concurrency-groups.test.ts +++ b/tests/ci-workflows/ci-concurrency-groups.test.ts @@ -99,11 +99,19 @@ test("push and pull_request still supersede an older head on their own ref", () }); test("every trigger this workflow declares is one the cases above cover", () => { - // A fourth trigger would arrive with no decision recorded about whether it + // A new trigger would arrive with no decision recorded about whether it // supersedes anything, and would inherit the push answer by accident. - expect(Object.keys(workflow.on ?? {}).sort()).toEqual(["pull_request", "push", "workflow_dispatch"]); + expect(Object.keys(workflow.on ?? {}).sort()).toEqual(["pull_request", "push", "schedule", "workflow_dispatch"]); }); test("the evaluator refuses an expression it does not model", () => { expect(() => render("${{ startsWith(github.ref, 'refs/tags/') }}", push("1"))).toThrow(/unsupported expression term/); }); + +test("scheduled runs on main never share the push group or cancel earlier runs", () => { + const scheduled = (run_id: string): GithubContext => ({ event_name: "schedule", ref: "refs/heads/main", run_id }); + const mainPush: GithubContext = { event_name: "push", ref: "refs/heads/main", run_id: "3" }; + expect(concurrency(scheduled("1")).group).not.toBe(concurrency(mainPush).group); + expect(concurrency(scheduled("1")).group).not.toBe(concurrency(scheduled("2")).group); + expect(concurrency(scheduled("1")).cancels).toBe("false"); +}); diff --git a/tests/ci-workflows/ci-review-lanes.test.ts b/tests/ci-workflows/ci-review-lanes.test.ts index b06de5b7300..b19a8d80a56 100644 --- a/tests/ci-workflows/ci-review-lanes.test.ts +++ b/tests/ci-workflows/ci-review-lanes.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { repoPath } from "../helpers/repo-root"; // Review-lane contracts for ci.yml and enforce-pr-target.yml. They live beside @@ -148,7 +150,7 @@ describe("CI review lanes", () => { expect(changesJob?.outputs?.ci).toBe("${{ steps.scope.outputs.ci }}"); expect(scopeStep?.id).toBe("scope"); expect(scopeStep?.shell).toBe("bash"); - expect(scopeStep?.env?.CI_SCOPE).toBe("${{ steps.filter.outputs.ci }}"); + expect(scopeStep?.env?.CI_SCOPE).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }}"); expect(scopeStep?.run).not.toContain("${{"); expect(scopeStep?.run).toContain('case "$CI_SCOPE" in'); expect(scopeStep?.run).toContain("true|false)"); @@ -169,7 +171,7 @@ describe("CI review lanes", () => { } const macosControlIf = ci.jobs?.["macos-control"] as { needs?: string; if?: string } | undefined; expect(macosControlIf?.needs).toBe("changes"); - expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); + expect(macosControlIf?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); }); test("manual release-gates keeps ordinary jobs and skips only diagnostic suites", async () => { @@ -178,12 +180,12 @@ describe("CI review lanes", () => { }; // These job conditions use boolean operators and lowercase string comparisons. // Evaluate the checked-in expressions, rather than a second implementation of them. - const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true") => { + const enabled = (job: string, event: string, lane: string, scope = "true", packaging = "true", native = "true", windows = "false") => { const condition = ci.jobs[job]!.if ?? "true"; const evaluate = new Function("github", "needs", `return (${condition});`); return evaluate( { event_name: event, event: { inputs: { lane } } }, - { changes: { outputs: { ci: scope, packaging, native } } }, + { changes: { outputs: { ci: scope, packaging, native, windows } } }, ); }; for (const [event, lane, windows, control] of [ @@ -217,8 +219,13 @@ describe("CI review lanes", () => { expect(enabled("npm-global-smoke", event, lane, "true", "true")).toBe(true); expect(enabled("npm-global-smoke", event, lane, "true", "false")).toBe(false); } + for (const windows of ["true", "false", ""]) { + expect(enabled("platform-windows", "pull_request", "", "true", "true", "true", windows)).toBe(windows === "true"); + } + expect(enabled("platform-windows", "schedule", "", "false", "false", "false")).toBe(true); + expect(enabled("macos-control", "schedule", "", "false", "false", "false")).toBe(true); expect(ci.jobs["select-windows-runner"]!.if).toBeUndefined(); - expect(ci.jobs["platform-windows"]!.needs).toBe("select-windows-runner"); + expect(ci.jobs["platform-windows"]!.needs).toEqual(["changes", "select-windows-runner"]); expect(ci.jobs.ci!.if).toBe("always()"); expect(ci.jobs.ci!.needs).toEqual(expect.arrayContaining([ "changes", "select-windows-runner", @@ -294,3 +301,148 @@ describe("CI review lanes", () => { } }, 30_000); }); + +describe("Windows and nightly CI contracts", () => { + test("publishes validated Windows selection and one nightly checkout ref", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + on: { schedule: { cron: string }[] }; + jobs: Record; steps?: Array<{ + id?: string; uses?: string; shell?: string; run?: string; + env?: Record; with?: Record; + }> }>; + }; + expect(ci.on.schedule).toEqual([{ cron: "17 18 * * *" }]); + const changes = ci.jobs.changes!; + expect(changes.outputs?.windows).toBe("${{ steps.windows.outputs.windows }}"); + expect(changes.outputs?.checkout_ref).toBe("${{ steps.nightly.outputs.checkout_ref }}"); + const checkout = changes.steps?.find(step => step.uses?.startsWith("actions/checkout@")); + expect(checkout?.with?.["fetch-depth"]).toBe(2); + const filter = changes.steps?.find(step => step.id === "filter"); + const filters = Bun.YAML.parse(String(filter?.with?.filters ?? "")) as Record; + expect(filters.windows).toEqual([ + "src/lib/windows-*", "src/lib/*acl*", "src/service/**", "src/update/**", + "src/claude/intercept/**", "src/codex/shim*", "src/codex/app-server-processes.ts", + "src/codex/desktop-app/**", "src/config/process-state.ts", "src/cli/update-restart*", + "src/server/local-management-read-client.ts", "src/server/startup-health-cache.ts", + "src/tray/**", "scripts/test.ts", "scripts/test-*.ts", "scripts/ci/**", + "tests/preload.ts", "tests/helpers/**", ".github/workflows/ci.yml", "package.json", "bun.lock", + ]); + const windows = changes.steps?.find(step => step.id === "windows"); + expect(windows?.shell).toBe("bash"); + expect(windows?.env).toEqual({ EVENT_NAME: "${{ github.event_name }}", PATH_SELECTED: "${{ steps.filter.outputs.windows }}" }); + expect(windows?.run).toBe("bash scripts/ci/windows-sensitive-diff.sh"); + expect(windows?.run).not.toContain("${{"); + let checkouts = 0; + for (const [name, job] of Object.entries(ci.jobs)) { + if (name === "changes") continue; + for (const step of job.steps ?? []) { + if (!step.uses?.startsWith("actions/checkout@")) continue; + expect(step.with?.ref, `${name} checkout`).toBe("${{ needs.changes.outputs.checkout_ref }}"); + checkouts += 1; + } + } + expect(checkouts).toBe(17); + }); + + test.skipIf(process.platform === "win32" || !Bun.which("jq"))("aggregate requests Windows only for selected PRs and requests both diagnostic jobs nightly", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs: Record; run?: string }> }>; + }; + const aggregate = ci.jobs.ci!; + const step = aggregate.steps?.find(candidate => candidate.name === "Assert every job this event requested succeeded"); + expect(step?.shell).toBe("bash"); + expect(step?.env?.CHANGES_WINDOWS).toBe("${{ needs.changes.outputs.windows }}"); + // Execute only the expectation block (as ci-scope-reduction.test.ts does): the + // leg count after it calls the Actions API, which a test must not reach. + const script = step!.run!; + const start = script.indexOf("scoped=requested"); + const first = script.indexOf("RESULTS_EOF"); + const last = script.indexOf("RESULTS_EOF", first + "RESULTS_EOF".length); + expect(start).toBeGreaterThan(-1); + expect(last).toBeGreaterThan(first); + const slice = script.slice(start, last + "RESULTS_EOF".length); + for (const [event, lane, windows, expectWindows, expectControl] of [ + ["schedule", "", undefined, "requested", "requested"], + ["pull_request", "", "true", "requested", "not-requested"], + ["pull_request", "", "false", "not-requested", "not-requested"], + ["pull_request", "", undefined, "not-requested", "not-requested"], + ["push", "", "true", "not-requested", "not-requested"], + ["workflow_dispatch", "all", "false", "requested", "requested"], + ["workflow_dispatch", "release-gates", "true", "not-requested", "not-requested"], + ] as const) { + const env: Record = { + PATH: process.env.PATH ?? "/usr/bin:/bin", EVENT_NAME: event, LANE: lane, RESULTS: "{}", + CHANGES_CI: "true", CHANGES_NATIVE: "true", CHANGES_PACKAGING: "true", + CHANGES_DESKTOP: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", + CHANGES_SETUP_ACTION: "false", CHANGES_REMOTE_HELPER: "false", + ...(windows === undefined ? {} : { CHANGES_WINDOWS: windows }), + }; + const result = spawnSync("bash", ["-c", `set -u\n${slice}\necho "WIN=$(expected_for platform-windows)"\necho "CTL=$(expected_for macos-control)"`], { + encoding: "utf8", timeout: 5_000, env, + }); + expect(result.error).toBeUndefined(); + expect(`${event}/${lane}/${windows}:${result.stdout.match(/^WIN=(.*)$/m)?.[1]}`).toBe(`${event}/${lane}/${windows}:${expectWindows}`); + expect(`${event}/${lane}/${windows}:${result.stdout.match(/^CTL=(.*)$/m)?.[1]}`).toBe(`${event}/${lane}/${windows}:${expectControl}`); + } + }); + + test.skipIf(process.platform === "win32" || !Bun.which("git"))("Windows diff scan considers only added src/tests markers and fails closed without a parent", () => { + const scenarios = [ + { path: "src/example.ts", before: "plain\n", after: "WIN32\n", expected: "true" }, + { path: "tests/example.ts", before: "plain\n", after: "ordinary\n", expected: "false" }, + { path: "src/example.ts", before: "powershell\nplain\n", after: "plain\n", expected: "false" }, + { path: "docs/example.md", before: "plain\n", after: "pwsh\n", expected: "false" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", selected: "true", expected: "true" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", selected: "invalid", status: 1 }, + { path: "src/example.ts", before: "plain\n", after: "win32\n", event: "push", expected: "false" }, + { path: "src/example.ts", before: "plain\n", after: "plain\n", event: "schedule", selected: "true", expected: "true" }, + { path: "src/example.ts", before: "plain\n", orphan: true, expected: "true" }, + ]; + for (const scenario of scenarios) { + const directory = mkdtempSync(join(tmpdir(), "ocx-windows-diff-")); + try { + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null" } }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + }; + git("init", "--quiet"); + const path = join(directory, scenario.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, scenario.before); + const commit = () => { + git("add", "."); + git("-c", "user.name=CI Fixture", "-c", "user.email=test@example.test", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "--allow-empty", "-m", "fixture"); + }; + commit(); + if (!scenario.orphan) { writeFileSync(path, scenario.after ?? scenario.before); commit(); } + const output = join(directory, "output"); + const result = spawnSync("bash", [repoPath("scripts", "ci", "windows-sensitive-diff.sh")], { + cwd: directory, encoding: "utf8", timeout: 5_000, + env: { ...process.env, EVENT_NAME: scenario.event ?? "pull_request", PATH_SELECTED: scenario.selected ?? "false", GITHUB_OUTPUT: output }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(scenario.status ?? 0); + if (scenario.expected) expect(readFileSync(output, "utf8")).toBe(`windows=${scenario.expected}\n`); + } finally { rmSync(directory, { recursive: true, force: true }); } + } + }, 30_000); + + test("startup ACL reads avoid module-autoloaded Get-Acl and Set-Acl across src", () => { + const violations: string[] = []; + const walk = (directory: string) => { + for (const entry of readdirSync(repoPath(...directory.split("/")), { withFileTypes: true })) { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) { walk(path); continue; } + if (!entry.isFile() || !/\.(ts|tsx|mjs|cjs|js)$/.test(entry.name)) continue; + readFileSync(repoPath(...path.split("/")), "utf8").split("\n").forEach((line, index) => { + if (/^(\/\/|\*|\/\*)/.test(line.trimStart())) return; + if (/\b(Get|Set)-Acl\b/i.test(line)) violations.push(`${path}:${index + 1}`); + }); + } + }; + walk("src"); + expect(violations, `${violations.join("\n")}\nRead ACLs through .NET as in src/lib/windows-owner-acl.ts; module autoload rebuilds the analysis cache for 20-27 s on a fresh profile.`).toEqual([]); + }); +}); diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index 345b715c73a..a64fb261b0c 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -75,8 +75,8 @@ describe("the narrow checks stay narrow", () => { const narrow = (changes?.steps ?? []).find(step => step.id === "narrow"); expect(changes?.outputs?.setup_action).toBe("${{ steps.narrow.outputs.setup_action }}"); expect(changes?.outputs?.remote_helper).toBe("${{ steps.narrow.outputs.remote_helper }}"); - expect(narrow?.env?.SETUP_ACTION).toBe("${{ steps.filter.outputs.setup_action }}"); - expect(narrow?.env?.REMOTE_HELPER).toBe("${{ steps.filter.outputs.remote_helper }}"); + expect(narrow?.env?.SETUP_ACTION).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }}"); + expect(narrow?.env?.REMOTE_HELPER).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }}"); expect(narrow?.run).toContain("exit 1"); }); }); diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index ff8c09c1814..bf069cba33f 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -140,7 +140,8 @@ function emitterStep(outputName: string) { } function renderFilterOutputs(text: string, inputs: { ci: string; native: string }): string { - const rendered = text.replace(/\$\{\{\s*steps\.filter\.outputs\.([a-z-]+)\s*\}\}/g, (_match, name: string) => { + // A pull_request emitter sees the plain filter value; the nightly override only matters on schedule. + const rendered = text.replace(/\$\{\{\s*(?:github\.event_name == 'schedule' && 'true' \|\| )?steps\.filter\.outputs\.([a-z_-]+)\s*\}\}/g, (_match, name: string) => { if (name === "ci") return inputs.ci; if (name === "native") return inputs.native; throw new Error(`unsupported filter output in emitter: ${name}`); diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index 5457497770b..21d5f36fab0 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -35,7 +35,7 @@ test("a change under structure/ selects a job that runs the structure gate", () // The whole defect was that nothing satisfied this. Read the condition off the // job rather than naming the job, so renaming it does not quietly pass. expect(filters.structure).toContain("structure/**"); - expect(changes?.outputs?.structure).toBe("${{ steps.filter.outputs.structure }}"); + expect(changes?.outputs?.structure).toBe("${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }}"); const selected = Object.entries(workflow.jobs ?? {}) .filter(([, job]) => job.if?.includes("needs.changes.outputs.structure == 'true'")) diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index e3562a4db50..c8b1d8b54e4 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -295,7 +295,7 @@ describe("GitHub Actions hardening", () => { } | undefined; expect(macosControlJob?.name).toBe("macos control"); expect(macosControlJob?.needs).toBe("changes"); - expect(macosControlJob?.if).toBe("github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); + expect(macosControlJob?.if).toBe("github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control')"); expect(macosControlJob?.["runs-on"]).toBe("macos-latest"); expect(macosControlJob?.strategy).toBeUndefined(); const macosControlSteps = macosControlJob?.steps ?? []; @@ -309,20 +309,15 @@ describe("GitHub Actions hardening", () => { expect(macosControlSteps.some(step => step.run?.includes("--shard"))).toBe(false); expect(macosControlSteps.some(step => step.run?.includes("coreutils") && step.run.includes("GITHUB_PATH"))).toBe(true); - // Windows is dispatch-only: it gates nothing, not even the shipping - // boundary. The sharded promotion run surfaced ~207 Windows-only failures - // that pre-date every released version, so the leg became a measurement - // tool a maintainer runs by hand, not a gate. Assert the positive - // condition and the absence of every automatic trigger — a stray - // `|| github.ref == ...` would restore a red leg to the release path. + // Windows-sensitive PRs and the daily schedule request Windows; release + // pushes still do not. Keep branch-ref shortcuts off this condition. const windowsIf = String((ci.jobs?.["platform-windows"] as { if?: string })?.if ?? ""); expect(windowsIf).toBe( - "github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')", + "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all') || github.event_name == 'pull_request' && needs.changes.outputs.windows == 'true'", ); expect(windowsIf).not.toContain("refs/heads/main"); expect(windowsIf).not.toContain("refs/heads/preview"); expect(windowsIf).not.toContain("refs/heads/dev"); - expect(windowsIf).not.toContain("pull_request"); // A lane=macos-control dispatch must skip Windows so a red Windows burn-down // cannot fail the unsharded macOS control run. A plain dispatch still runs diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 355bcca97f8..9603173247e 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -10,6 +10,7 @@ import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "../../src/claude/intercept/picker-ca"; import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { memoryPickerCaStore } from "../helpers/picker-ca-store"; import { saveConfig } from "../../src/config"; import type { OcxConfig } from "../../src/types"; @@ -34,6 +35,12 @@ function setup() { return { root, config }; } +// Each restart child cold-imports the intercept runtime and the first one mints the picker +// CA. A neighbouring case takes 8.5 s on a Windows shard, and the first child here passed a +// flat 10 s bound in three dev/PR runs (37360604391, 37443819560, 37449564955). spawnSync +// blocks the loop, so each multi-child test's own bound covers all of its children. +const PICKER_CHILD_TIMEOUT_MS = watchdogMs(10_000); + test("Windows and Linux skip all picker effects while still discarding legacy key", async () => { for (const platform of ["linux", "win32"] as const) { const { root, config } = setup(); @@ -134,7 +141,7 @@ test("fresh production restarts retain fingerprint and issue no trust mutations, await handle.stop(); process.stdout.write(JSON.stringify({ fingerprint, mutations, effective: status.effective, reason: status.reason })); `], env: { ...process.env, HOME: root, OPENCODEX_HOME: root, CLAUDE_CONFIG_DIR: join(root, "claude"), - OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(root, "desktop") }, stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(root, "desktop") }, stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(child.exitCode, child.stderr.toString()).toBe(0); return JSON.parse(child.stdout.toString()) as { fingerprint: string; mutations: string[]; effective: boolean; reason: string }; }; @@ -143,7 +150,7 @@ test("fresh production restarts retain fingerprint and issue no trust mutations, expect(second).toEqual(first); expect(lost).toMatchObject({ fingerprint: first.fingerprint, mutations: [], effective: false, reason: "trust_pending" }); expect(first.mutations).toEqual([]); -}); +}, PICKER_CHILD_TIMEOUT_MS * 3 + 5_000); test("late first-party enable migrates a dead ephemeral predecessor before persistent activation", () => { const { root } = setup(); @@ -153,7 +160,7 @@ test("late first-party enable migrates a dead ephemeral predecessor before persi const prior = Bun.spawnSync({ cmd: [process.execPath, "-e", ` import { ensurePickerCa } from ${JSON.stringify(caUrl)}; process.stdout.write(ensurePickerCa(${JSON.stringify(root)}).fingerprint); - `], stdout: "pipe", stderr: "pipe" }); + `], stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(prior.exitCode, prior.stderr.toString()).toBe(0); const child = Bun.spawnSync({ cmd: [process.execPath, "-e", ` import { readFileSync } from "node:fs"; @@ -181,14 +188,14 @@ test("late first-party enable migrates a dead ephemeral predecessor before persi await controller.enable({ persist: false, context: "server", allowTrustPrompt: false }); process.stdout.write(JSON.stringify({ removed, oldSha, writes: fake.writes, pending: readPendingPickerCaUntrust(root), fingerprint: pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256 })); - `], stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + `], stdout: "pipe", stderr: "pipe", timeout: PICKER_CHILD_TIMEOUT_MS }); expect(child.exitCode, child.stderr.toString()).toBe(0); const result = JSON.parse(child.stdout.toString()); expect(result.removed).toEqual([result.oldSha]); expect(result.writes).toBe(1); expect(result.pending).toBeNull(); expect(result.fingerprint).not.toBe(prior.stdout.toString()); -}); +}, PICKER_CHILD_TIMEOUT_MS * 2 + 5_000); test("default, off, and disabled integration remain credential-free through status, refresh, and off", async () => { for (const claudeCode of [undefined, { desktopMode: "first-party", intercept: { picker: false } }, { desktopMode: "first-party" }]) { diff --git a/tests/codex-integration/native-main-owner-lifetime.test.ts b/tests/codex-integration/native-main-owner-lifetime.test.ts index 96fe8fb4249..018afa750ad 100644 --- a/tests/codex-integration/native-main-owner-lifetime.test.ts +++ b/tests/codex-integration/native-main-owner-lifetime.test.ts @@ -159,12 +159,19 @@ function successorConfig(f: Fixture, configName: string, includePool: boolean): // contention. `watchdogMs` is the repository's existing answer to exactly this. const OWNER_EVENT_WAIT_MS = watchdogMs(10_000); +// Reaching "listening" includes the child's cold import of the server graph. On a busy +// Windows shard that once took over 45 s for the first child of a batch, and the same +// child then answered "stop" within seconds (dev runs 37316384711, 37447563941): slow +// startup, not a wedge. Startup gets twice the event budget; every later wait keeps it. +const OWNER_STARTUP_WAIT_MS = OWNER_EVENT_WAIT_MS * 2; + // The lease cases perform several of those waits back to back. The multi-server // case spawns two children and walks four ownership transitions, and it was // CANCELLED at 30,172ms against a flat 30s budget -- the budget expired mid-test, // so no assertion ever reported. Derive it from the deadline so the two cannot // drift apart again. -const OWNER_LEASE_BUDGET_MS = Math.max(30_000, OWNER_EVENT_WAIT_MS * 4); +// One slow cold start is budgeted on top of the four event waits. +const OWNER_LEASE_BUDGET_MS = Math.max(30_000, OWNER_EVENT_WAIT_MS * 4 + OWNER_STARTUP_WAIT_MS); async function waitUntil(probe: () => T | null, timeoutMs = OWNER_EVENT_WAIT_MS): Promise { const deadline = Date.now() + timeoutMs; @@ -433,16 +440,16 @@ describe("native-main process owner lease", () => { let second: ChildHarness | undefined; let support: ChildHarness | undefined; try { - await first.waitFor(event => event.event === "listening"); + await first.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await first.snapshot(isHeldReady); second = new ChildHarness(secondFixture); - await second.waitFor(event => event.event === "listening"); + await second.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await second.snapshot(isContended); const supportConfig = join(firstFixture.root, "owner-support"); writeConfig(firstFixture.codexHome, supportConfig); support = new ChildHarness({ ...firstFixture, configDir: supportConfig }); - await support.waitFor(event => event.event === "listening"); + await support.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await support.snapshot(isContended); expect((await support.command("set-mode", { mode: "pool" })).ok).toBe(true); const pool = await support.command("request", { kind: "pool" }); @@ -489,7 +496,7 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f, { NATIVE_OWNER_HOLD_SWITCH_BOUNDARY: "auth-replaced" }); let successor: ChildHarness | undefined; try { - const listening = await owner.waitFor(event => event.event === "listening"); + const listening = await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const switchRequest = fetch(`http://127.0.0.1:${Number(listening.port)}/api/native-main-profiles/switch`, { method: "POST", @@ -500,7 +507,7 @@ describe("native-main process owner lease", () => { expect(probeNativeProfileRecoveryState(f.manager.context)).toBe("journal"); successor = new ChildHarness(successorFixture); - await successor.waitFor(event => event.event === "listening"); + await successor.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await successor.snapshot(isContended); const before = await successor.command("snapshot"); const blockedMain = await successor.command("request", { kind: "main" }); @@ -532,7 +539,7 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f, { NATIVE_OWNER_HOLD_AUTH_TEMP: "1" }); let successor: ChildHarness | undefined; try { - const listening = await owner.waitFor(event => event.event === "listening"); + const listening = await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const switchRequest = fetch(`http://127.0.0.1:${Number(listening.port)}/api/native-main-profiles/switch`, { method: "POST", @@ -553,7 +560,7 @@ describe("native-main process owner lease", () => { expect(probeNativeProfileRecoveryState(f.manager.context)).toBe("journal"); successor = new ChildHarness(successorConfig(f, "temp-crash-successor", false), { NATIVE_OWNER_HOLD_RECOVERY: "1" }); - await successor.waitFor(event => event.event === "listening"); + await successor.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await successor.snapshot(isContended); await owner.hardKill(); await switchRequest; @@ -586,7 +593,7 @@ describe("native-main process owner lease", () => { linkSync(target, residue); const child = new ChildHarness(f); try { - await child.waitFor(event => event.event === "listening"); + await child.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); const blocked = await child.snapshot(event => { const owner = event.owner as { status?: string } | undefined; const gate = event.gate as { status?: string; reason?: string } | undefined; @@ -610,13 +617,13 @@ describe("native-main process owner lease", () => { const owner = new ChildHarness(f); let contender: ChildHarness | undefined; try { - await owner.waitFor(event => event.event === "listening"); + await owner.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await owner.snapshot(isHeldReady); const alias = resolve(f.codexHome, ".", "..", "codex", "."); const extra = await owner.command("start-extra-server", { codexHomeAlias: alias }); expect(extra.ok).toBe(true); contender = new ChildHarness(contenderFixture); - await contender.waitFor(event => event.event === "listening"); + await contender.waitFor(event => event.event === "listening", OWNER_STARTUP_WAIT_MS); await contender.snapshot(isContended); expect((await owner.command("stop-server", { index: 0 })).ok).toBe(true); diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index 297189fffe6..b7248eed3a0 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; +import { watchdogMs } from "../helpers/ci-watchdog"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; @@ -11,6 +12,12 @@ import { repoRoot } from "../helpers/repo-root"; // binds CODEX_CONFIG_PATH to CODEX_HOME at module load, so each case runs in a child // whose env is fixed before the module graph loads. The child timeout stays below // the test timeout so a wedged child fails as a timeout, not a hanging test. +// Each child cold-imports the management API graph: 2-5 s on a Windows CI shard, +// and over 10 s for the first child of a busy batch (run 37447563941), so a flat +// 10 s bound reported contention rather than a wedge. +const ISOLATED_CHILD_TIMEOUT_MS = watchdogMs(10_000); +// spawnSync blocks the loop, so the per-test bound has to outlast the child it waits on. +const ISOLATED_TEST_TIMEOUT_MS = ISOLATED_CHILD_TIMEOUT_MS + 5_000; const ISOLATED_PROVIDER_CONFIG = { port: 10100, defaultProvider: "openai", @@ -48,7 +55,7 @@ function runIsolatedSettingsRequest(options: { OCX_TEST_ROUTE_CONFIG: JSON.stringify(options.routeConfig), }, encoding: "utf8", - timeout: 10_000, + timeout: ISOLATED_CHILD_TIMEOUT_MS, }); if (child.status !== 0) { // A timeout or spawn failure leaves no output; surface status/signal/error @@ -79,7 +86,7 @@ function runIsolatedCodexScript(options: { OPENCODEX_HOME: join(options.root, "opencodex"), }, encoding: "utf8", - timeout: 10_000, + timeout: ISOLATED_CHILD_TIMEOUT_MS, }); if (child.status !== 0) { const cause = child.error ? ` (${child.error.name}: ${child.error.message})` : ""; @@ -203,7 +210,7 @@ test("GET /api/settings reports external Codex ownership without an apply attemp } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("GET /api/settings survives an unreadable config.toml during ownership detection", () => { // existsSync passes but readFileSync throws: config.toml as a directory is a @@ -242,7 +249,7 @@ test("GET /api/settings survives an unreadable config.toml during ownership dete } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test.skipIf(process.platform === "win32")( "GET /api/settings refuses a config.toml FIFO without blocking", @@ -326,7 +333,7 @@ test("PUT /api/settings keeps the undetermined-ownership explanation on a locked } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("readBoundedCodexConfig returns null only for a config absent at lookup", () => { const root = mkdtempSync(join(tmpdir(), "ocx-bounded-reader-")); @@ -454,7 +461,7 @@ test("native restore tolerates a config.toml over the observation bound", () => } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("PUT /api/settings reports external Codex ownership when the integration is disabled", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, so @@ -498,7 +505,7 @@ test("PUT /api/settings reports external Codex ownership when the integration is } finally { removeTreeWithRetry(root); } -}, 15_000); +}, ISOLATED_TEST_TIMEOUT_MS); test("post-gate injector read failure retains undetermined ownership and null effective state", () => { const root = mkdtempSync(join(tmpdir(), "ocx-settings-post-gate-")); @@ -529,4 +536,4 @@ test("post-gate injector read failure retains undetermined ownership and null ef authSource: { presentsCodexAccount: null }, } }); } finally { removeTreeWithRetry(root); } -}, 15000); +}, ISOLATED_TEST_TIMEOUT_MS);