Skip to content
163 changes: 146 additions & 17 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions scripts/ci/windows-sensitive-diff.sh
Original file line number Diff line number Diff line change
@@ -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
20 changes: 16 additions & 4 deletions structure/ops/cross-platform-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions tests/ci-workflows/ci-concurrency-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading
Loading