v1.92.77.0 wave(github): #3724 #3771 #3833 (#3846)

* test-free-shards: --ci-run --jobs k sub-shards inside one CI job (k=1 default), per-file JUnit counts (GSTA-203 PR 2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ci): ship ffmpeg in the eval image so the marathon design-motion case can run (#3687)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* shard-jobs: serial sub-shard gets what is left of the shard wall; register shard-jobs.ts in ship-measure touchfiles

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* shard-jobs: validate every sub-shard summary, bounded JUnit read, stricter --ci-verify records, unique count paths, no serial launch without wall

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* shard-jobs tests: non-hex plan id fixture; harness-version: shard-jobs FREE_ONLY entry is non-behavioral

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(test:free): exact root-bound JUnit path matching; serial timeout text names the wall the parallel phase left

Adversarial pass 2 (GSTA-203 PR 2): a plain relative path now matches a planned
file exactly, and an absolute or ../-prefixed path matches only when its prefix is
the child's root, so an unplanned browse/test/x.test.ts is never credited to
test/x.test.ts. The serial sub-shard's FREE_SUBSHARD_TIMEOUT text says how much of
the shard wall the parallel phase left it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(testing): real-home tripwire covers --jobs k>1 parallel sub-shards (docs audit docs-203-1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(free): per-shard sub-shard k in the CI plan (--shard-jobs), free_jobs and windows_jobs dispatch inputs (k=1 defaults)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(free-tests): 10 x ubicloud-standard-8 at 4 sub-shards per shard (GSTA-203 PR 3 A/B winner); Windows stays at k=1

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (changelog names main's 20 x standard-4 baseline and the unchanged 80 vCPUs)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(testing): sub-shard default is the plan's per-shard k (docs audit docs-pr3-2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(free): run the #3680/#3679 BSD-userland regression on macos-named-regressions

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: macos-named-regressions runs the BSD-userland ubi-runner regression (#3680)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: Windows k=1 applies to the nightly suite since #3806 (docs re-audit after the main merge)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-02 12:58:56 +00:00
1 parent 3d99c22497
commit 4bbad24221
15 files changed
+297 -62

No files matched your search

+15
View File
@@ -193,6 +193,21 @@ RUN for i in 1 2 3; do \
&& fc-cache -f \ && fc-cache -f \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ffmpeg (with ffprobe): /design-motion's checks and exports shell out to it
# (lib/motion-encode.ts), and the marathon design-motion-showcase case runs
# that flow end to end. The image had none, so every trial stopped at
# FFMPEG_MISSING (#3687). The build fails if the packaged ffmpeg ever lacks a
# filter or encoder the checks and exports use.
RUN for i in 1 2 3; do \
apt-get update && apt-get install -y --no-install-recommends ffmpeg && break || \
(echo "ffmpeg install retry $i/3"; sleep 10); \
done \
&& rm -rf /var/lib/apt/lists/* \
&& command -v ffprobe \
&& filters="$(ffmpeg -hide_banner -filters)" && encoders="$(ffmpeg -hide_banner -encoders)" \
&& for f in psnr gblur palettegen paletteuse signalstats tile; do echo "$filters" | grep -qw "$f" || { echo "ERROR: ffmpeg lacks the $f filter" >&2; exit 1; }; done \
&& for e in libx264 gif; do echo "$encoders" | grep -qw "$e" || { echo "ERROR: ffmpeg lacks the $e encoder" >&2; exit 1; }; done
# Pre-install dependencies (cached layer — only rebuilds when package.json or # Pre-install dependencies (cached layer — only rebuilds when package.json or
# bun.lock changes). Copy BOTH so install is deterministic and matches local # bun.lock changes). Copy BOTH so install is deterministic and matches local
# resolution. Without bun.lock here, bun install resolved transitive deps # resolution. Without bun.lock here, bun install resolved transitive deps
+25 -10
View File
@@ -41,13 +41,21 @@ on:
description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)' description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)'
type: string type: string
default: '' default: ''
free_jobs:
description: 'Runner-sizing experiments only: sub-shards per CI shard (--shard-jobs), 1-8 (empty keeps FREE_SUITE_JOBS)'
type: string
default: ''
# The one source of the free-suite runner and shard count. pull_request and # The one source of the free-suite runner, shard count and sub-shards per
# push runs never see workflow_dispatch inputs, so free-plan resolves these # shard. pull_request and push runs never see workflow_dispatch inputs, so
# (or a dispatch override) into outputs that free-suite reads. # free-plan resolves these (or a dispatch override) into outputs and the plan
# that free-suite reads. FREE_SUITE_JOBS is k in `--ci-plan --shard-jobs k`:
# each shard runs min(k, its parallel files) sandboxed sub-shards side by side
# (scripts/lib/shard-jobs.ts); the exclusive host-state shard stays one child.
env: env:
FREE_SUITE_RUNNER: ubicloud-standard-4 FREE_SUITE_RUNNER: ubicloud-standard-8
FREE_SUITE_SHARDS: '20' FREE_SUITE_SHARDS: '10'
FREE_SUITE_JOBS: '4'
# Keyed on the PR number, not head_ref: a bare branch name carries no fork # Keyed on the PR number, not head_ref: a bare branch name carries no fork
# prefix, so same-name branches from two forks would share one group and a # prefix, so same-name branches from two forks would share one group and a
@@ -120,10 +128,12 @@ jobs:
env: env:
RUNNER_OVERRIDE: ${{ inputs.free_runner }} RUNNER_OVERRIDE: ${{ inputs.free_runner }}
SHARDS_OVERRIDE: ${{ inputs.free_shards }} SHARDS_OVERRIDE: ${{ inputs.free_shards }}
JOBS_OVERRIDE: ${{ inputs.free_jobs }}
run: | run: |
set -euo pipefail set -euo pipefail
runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}" runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}"
shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}" shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}"
jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}"
case "$runner" in case "$runner" in
ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;; ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;;
*) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;; *) echo "::error::free_runner '$runner' is not ubicloud-standard-2, -4 or -8"; exit 1 ;;
@@ -132,10 +142,14 @@ jobs:
''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;; ''|*[!0-9]*) echo "::error::free_shards '$shards' is not a whole number"; exit 1 ;;
esac esac
if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi if [ "$shards" -lt 1 ] || [ "$shards" -gt 40 ]; then echo "::error::free_shards $shards is outside 1-40"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ]; then case "$jobs" in
echo "- Runner-sizing experiment: $shards shards on $runner (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY" ''|*[!0-9]*) echo "::error::free_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 8 ]; then echo "::error::free_jobs $jobs is outside 1-8"; exit 1; fi
if [ "$runner" != "$FREE_SUITE_RUNNER" ] || [ "$shards" != "$FREE_SUITE_SHARDS" ] || [ "$jobs" != "$FREE_SUITE_JOBS" ]; then
echo "- Runner-sizing experiment: $shards shards on $runner at $jobs sub-shards each (defaults $FREE_SUITE_SHARDS on $FREE_SUITE_RUNNER at $FREE_SUITE_JOBS); not a PR or train Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi fi
matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards") matrix=$(bun run scripts/test-free-shards.ts --ci-plan "$RUNNER_TEMP/free-plan.json" --shards "$shards" --shard-jobs "$jobs")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "runner=$runner" >> "$GITHUB_OUTPUT" echo "runner=$runner" >> "$GITHUB_OUTPUT"
- name: Seed growth ratchet (60 s ceiling, shrink-only allowlist) - name: Seed growth ratchet (60 s ceiling, shrink-only allowlist)
@@ -186,7 +200,8 @@ jobs:
# code that compares a realpath with an unresolved path passes on Linux's # code that compares a realpath with an unresolved path passes on Linux's
# /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's # /tmp and fails here (#2982). TMPDIR is deliberately left at the runner's
# default, and the job fails if that layout is not symlinked. It also sources # default, and the job fails if that layout is not symlinked. It also sources
# the Codex probe from zsh, macOS's default shell (#3024; no paid call). # the Codex probe from zsh, macOS's default shell (#3024; no paid call), and
# runs ubi-runner under macOS's bsdtar and one-true-awk (#3680, #3679).
macos-named-regressions: macos-named-regressions:
needs: reuse needs: reuse
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }} if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
@@ -229,7 +244,7 @@ jobs:
resolved="$(realpath "$tmp")" resolved="$(realpath "$tmp")"
echo "temp root: $tmp -> $resolved" echo "temp root: $tmp -> $resolved"
test "$resolved" != "${tmp%/}" test "$resolved" != "${tmp%/}"
files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts) files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts test/regression-issue3680-ubicloud-bsd-userland.test.ts)
for file in "${files[@]}"; do test -f "$file"; done for file in "${files[@]}"; do test -f "$file"; done
bun test "${files[@]}" bun test "${files[@]}"
+20 -1
View File
@@ -42,6 +42,10 @@ on:
description: Record per-file Windows durations (artifact) instead of running the lane description: Record per-file Windows durations (artifact) instead of running the lane
type: boolean type: boolean
default: false default: false
windows_jobs:
description: 'Sub-shard experiments only: sub-shards per Windows shard (--shard-jobs), 1-4 (empty keeps WINDOWS_FREE_JOBS). Never cite such a run as a PR result.'
type: string
default: ''
# free-durations-record.yml calls the recording path weekly. # free-durations-record.yml calls the recording path weekly.
workflow_call: workflow_call:
inputs: inputs:
@@ -49,6 +53,11 @@ on:
type: boolean type: boolean
default: false default: false
# k in `--ci-plan --shard-jobs k`: each Windows shard runs min(k, its parallel
# files) sandboxed sub-shards side by side (scripts/lib/shard-jobs.ts).
env:
WINDOWS_FREE_JOBS: '1'
concurrency: concurrency:
group: windows-free-${{ github.event.pull_request.number || github.run_id }} group: windows-free-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
@@ -105,8 +114,18 @@ jobs:
# 4-vCPU Windows runner, so the lane lands near 4 min with setup. # 4-vCPU Windows runner, so the lane lands near 4 min with setup.
env: env:
GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json
JOBS_OVERRIDE: ${{ inputs.windows_jobs }}
run: | run: |
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6) set -euo pipefail
jobs="${JOBS_OVERRIDE:-$WINDOWS_FREE_JOBS}"
case "$jobs" in
''|*[!0-9]*) echo "::error::windows_jobs '$jobs' is not a whole number"; exit 1 ;;
esac
if [ "$jobs" -lt 1 ] || [ "$jobs" -gt 4 ]; then echo "::error::windows_jobs $jobs is outside 1-4"; exit 1; fi
if [ "$jobs" != "$WINDOWS_FREE_JOBS" ]; then
echo "- Sub-shard experiment: $jobs sub-shards per Windows shard (default $WINDOWS_FREE_JOBS); not a PR Windows Free Tests result" >> "$GITHUB_STEP_SUMMARY"
fi
matrix=$(bun run scripts/test-free-shards.ts --windows-only --ci-plan "$RUNNER_TEMP/windows-plan.json" --shards 6 --shard-jobs "$jobs")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with: with:
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## [1.92.77.0] - 2026-11-02
**The weekly marathon evals can run /design-motion again: the CI eval image now ships ffmpeg (#3687).** The `design-motion-showcase` case joined the marathon tier on 2026-10-26, and its first scheduled run (Actions run 41200316994, main @ 088bfc5) failed 0/3. Every trial built and captured the page, then stopped at `FFMPEG_MISSING`, because the image had no ffmpeg and the skill rightly refuses to export before its checks run. The model did nothing wrong; the case could not pass on that image.
### Fixed
- **`.github/docker/Dockerfile.ci`** installs Ubuntu's `ffmpeg` (6.1.1, with `ffprobe`). The build fails if that ffmpeg lacks a filter or encoder the motion checks and exports use (`psnr`, `gblur`, `palettegen`, `paletteuse`, `signalstats`, `tile`, `libx264`, `gif`).
- **`test/ci-image-cli-pin.test.ts`** fails the free suite if the image stops installing ffmpeg or stops checking it.
The macOS `macos-named-regressions` CI job now runs `test/regression-issue3680-ubicloud-bsd-userland.test.ts`, so the ubi-runner cases for #3680 (bsdtar) and #3679 (macOS awk) run on a real Mac against `/usr/bin/tar` and `/usr/bin/awk`. Before, the file ran only on Linux CI, where bsdtar and one-true-awk aren't installed, so every case skipped and a regression in either could merge green. A new wiring test fails if the macOS job stops naming the file.
The Linux free suite now runs on 10 `ubicloud-standard-8` machines, each running its tests as 4 sandboxed sub-shards side by side, instead of main's 20 `ubicloud-standard-4` machines (set by #3710) running one file at a time. Both use 80 vCPUs per run. In a 3-run A/B at one commit against that main setup, the slowest shard dropped from 389–450 s to 224–258 s and the Ubicloud cost per run dropped from about $0.39 to $0.32 at premium prices, with the same per-file test counts and no new failures. The CI plan now stores each shard's sub-shard count (`--ci-plan --shard-jobs k`), keeps the exclusive host-state shard at one child, and `--ci-verify` rejects a shard that ran at a different count. The nightly Windows suite stays at one child per shard, because two sub-shards made Windows file-lock cleanup failures show up in two of three runs. Dispatch-only `free_jobs` and `windows_jobs` inputs exist for future experiments. See docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job.
## [1.92.76.0] - 2026-11-02 ## [1.92.76.0] - 2026-11-02
**/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749). **/retro now remembers its history across git worktrees, so a run in a linked worktree compares against last week instead of announcing "First retro recorded".** Retro snapshots used to live in `.context/retros/` inside the working tree. A worktree (Conductor, Claude Code) saw none of the main checkout's history, and its own snapshot was deleted along with the worktree (#3749).
+1 -1
View File
@@ -1 +1 @@
1.92.76.0 1.92.77.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.92.76.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.92.77.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+38 -17
View File
@@ -195,15 +195,18 @@ fallback; unknown files get 99th-percentile pessimism, and both full-suite and
the long pole. Packed the long pole. Packed
shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The
legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses
one duration-packed `--ci-plan`, 20 ordinary `--ci-run` shards plus a separate one duration-packed `--ci-plan`, 10 ordinary `--ci-run` shards plus a separate
exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on exclusive-fixture shard, and a `--ci-verify` aggregate. CI shards still run on
independent machines without ordering unrelated jobs. Two workflow-level independent machines without ordering unrelated jobs. Three workflow-level
constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER` constants in `free-tests.yml` set that size for every event: `FREE_SUITE_RUNNER`
(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan` (`ubicloud-standard-8`), `FREE_SUITE_SHARDS` (`'10'`) and `FREE_SUITE_JOBS`
resolves into the shards' runner and shard count. A `workflow_dispatch` run may (`'4'`, the [sub-shards](#sub-shards-inside-one-ci-job) each shard runs side by
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and side), which `free-plan` resolves into the shards' runner, shard count and plan.
`free_shards` (1–40) for runner-sizing experiments only; its job summary says GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1
so, and it is never cited as a PR or train Free Tests result. Each shard prints (the PR that set them has the table). A `workflow_dispatch` run may
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`),
`free_shards` (1–40) and `free_jobs` (1–8) for runner-sizing experiments only;
its job summary says so, and it is never cited as a PR or train Free Tests result. Each shard prints
`RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and `RESOURCES: peak_used_mib=… min_free_disk_mib=… samples=…` from a memory and
disk sample every 2 seconds, even when the suite fails. The public disk sample every 2 seconds, even when the suite fails. The public
`TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole `TREE_MUTATING` map now classifies exclusive host-state fixtures; its sole
@@ -1553,7 +1556,7 @@ refresh to a maintainer.
`scripts/ubicloud/setup-free-suite.sh` and the Windows seed through `scripts/ubicloud/setup-free-suite.sh` and the Windows seed through
`windows-free-tests.yml`'s `record_durations` path. The recorder stays on `windows-free-tests.yml`'s `record_durations` path. The recorder stays on
`ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER` `ubicloud-standard-8` while the free-suite shards run on `FREE_SUITE_RUNNER`
(`ubicloud-standard-4`); both are Ubicloud standard runners. When either recording adds (`ubicloud-standard-8`); both are Ubicloud standard runners. When either recording adds
or drops a test file, it force-updates the `bot/free-duration-seeds` branch, or drops a test file, it force-updates the `bot/free-duration-seeds` branch,
opens or refreshes one seed PR whose body states the ratchet result, and opens or refreshes one seed PR whose body states the ratchet result, and
dispatches Free Tests, Windows Smoke and Quality gate on it. Those dispatches Free Tests, Windows Smoke and Quality gate on it. Those
@@ -1575,14 +1578,24 @@ k sub-shards side by side on the same machine (`scripts/lib/shard-jobs.ts`). Eac
sub-shard is an ordinary free shard child: one `bun test` process, serial sub-shard is an ordinary free shard child: one `bun test` process, serial
inside, with its own sandbox, process-group kill and log. Bun's worker inside, with its own sandbox, process-group kill and log. Bun's worker
`--parallel` mode is still not used (decision V3/D6 in `--parallel` mode is still not used (decision V3/D6 in
`scripts/test-free-shards.ts`). The default is `k = 1`, which runs today's one `scripts/test-free-shards.ts`). Without `--jobs`, a shard runs at the plan's
child, so no workflow changes behavior until it passes `--jobs`. per-shard k (below); a shard with no planned k runs one child (`k = 1`).
- `--jobs` applies only to `--ci-run`. It takes a positive whole number up to the - Workflows set k in the plan, per shard: `--ci-plan <plan> --shards N
shard's file count (an empty shard takes 1); anything else stops with --shard-jobs k` stores `jobs = min(k, the shard's files that may run in
`FREE_JOBS_INVALID`. `GSTACK_FREE_JOBS` sets the local full-suite process count parallel)` on each shard, and only when that is above 1. The exclusive
and never changes `--ci-run`. The exclusive host-state shard has one file, so host-state shard holds only `TREE_MUTATING` files, so it stays one child, and
a workflow passes it `--jobs 1` or no `--jobs`. a plan made at k = 1 is byte-identical to one made without `--shard-jobs`.
`--ci-run` runs the shard at the plan's k, and `--ci-verify` rejects any
shard result that ran at a different k. `free-tests.yml` reads k from
`FREE_SUITE_JOBS` and `windows-free-tests.yml` from `WINDOWS_FREE_JOBS`; the
`free_jobs` and `windows_jobs` dispatch inputs override them for experiments
only.
- `--jobs` applies only to `--ci-run` and overrides the plan's k, for local
reproduction. It takes a positive whole number up to the shard's file count
(an empty shard takes 1); anything else stops with `FREE_JOBS_INVALID`, as
does a bad `--shard-jobs` or one without `--ci-plan`. `GSTACK_FREE_JOBS` sets
the local full-suite process count and never changes `--ci-run`.
- Files are split into disjoint sub-shards packed by the duration seed. Each - Files are split into disjoint sub-shards packed by the duration seed. Each
sub-shard has an id (`<plan id prefix>:<shard>.<n>`) bound to its file list. sub-shard has an id (`<plan id prefix>:<shard>.<n>`) bound to its file list.
Files in `TREE_MUTATING` or `SLOW_UNDER_LOAD` run after the parallel Files in `TREE_MUTATING` or `SLOW_UNDER_LOAD` run after the parallel
@@ -1606,6 +1619,13 @@ reproduce command. If the file passes serially, add it to `SLOW_UNDER_LOAD` in
`scripts/lib/shard-jobs.ts` with that run id and a reason. Never raise the `scripts/lib/shard-jobs.ts` with that run id and a reason. Never raise the
timeout. `SLOW_UNDER_LOAD` is empty today. timeout. `SLOW_UNDER_LOAD` is empty today.
The nightly Windows lane stays at `WINDOWS_FREE_JOBS: '1'`. In GSTA-203's A/B, k = 2
made the slowest Windows shard faster, but two of three runs needed the flaky
retry for file-lock cleanup errors (`EBUSY` in
`browse/test/stealth-webdriver.test.ts` and a native fixture cleanup in
`browse/test/cookie-import-native-job.test.ts`), which k = 1 never showed. Fix
those cleanups before raising it.
Every `--ci-run` result, at any k, records per-file test and `expect()` counts Every `--ci-run` result, at any k, records per-file test and `expect()` counts
in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per
file, plus shard totals). They come from bun's JUnit reporter, which the runner file, plus shard totals). They come from bun's JUnit reporter, which the runner
@@ -1624,8 +1644,9 @@ test list in its last step, where each file's reason sits beside it.
`test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast. `test/windows-native-workflows.test.ts` keeps that list Windows-safe and fast.
The curated suite below runs nightly on main and on dispatch, never on a PR. The curated suite below runs nightly on main and on dispatch, never on a PR.
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6`), `windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6
runs one strict serial shard per `windows-latest` job --shard-jobs $WINDOWS_FREE_JOBS`), runs one strict serial shard per
`windows-latest` job
(`bun run test:windows --ci-run`), and verifies every shard receipt on Linux (`bun run test:windows --ci-run`), and verifies every shard receipt on Linux
(`--windows-only --ci-verify`). Packing uses Windows-measured durations from (`--windows-only --ci-verify`). Packing uses Windows-measured durations from
`scripts/free-test-durations-windows.json`; the weekly `scripts/free-test-durations-windows.json`; the weekly
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.92.76", "version": "1.92.77",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+27 -4
View File
@@ -45,16 +45,39 @@ export const FREE_JOBS_INVALID = 'FREE_JOBS_INVALID';
export const FREE_SUBSHARD_TIMEOUT = 'FREE_SUBSHARD_TIMEOUT'; export const FREE_SUBSHARD_TIMEOUT = 'FREE_SUBSHARD_TIMEOUT';
export const FREE_SUBSHARD_REJECTED = 'FREE_SUBSHARD_REJECTED'; export const FREE_SUBSHARD_REJECTED = 'FREE_SUBSHARD_REJECTED';
const DOCS = 'docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job'; const DOCS = 'docs/TESTING_INTERNALS.md#sub-shards-inside-one-ci-job';
const JOBS_FIX = `--jobs applies only to --ci-run and defaults to 1; GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run. See ${DOCS}`; const JOBS_FIX = `--jobs applies only to --ci-run and defaults to the plan's per-shard k (1 when the plan has none); --shard-jobs applies only to --ci-plan; GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run. See ${DOCS}`;
/** `--jobs <k>` at parse time: digits only, at least 1. */ /** `--jobs <k>` or `--shard-jobs <k>` at parse time: digits only, at least 1. */
export function parseJobsFlag(raw: string | undefined): number { export function parseJobsFlag(raw: string | undefined, flag = '--jobs'): number {
if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) { if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) {
throw new Error(`${FREE_JOBS_INVALID}: --jobs needs a positive whole number; got ${JSON.stringify(raw ?? '')}. ${JOBS_FIX}`); throw new Error(`${FREE_JOBS_INVALID}: ${flag} needs a positive whole number; got ${JSON.stringify(raw ?? '')}. ${JOBS_FIX}`);
} }
return Number(raw); return Number(raw);
} }
export function rejectShardJobsWithoutCiPlan(): never {
throw new Error(`${FREE_JOBS_INVALID}: --shard-jobs needs --ci-plan. ${JOBS_FIX}`);
}
/**
* One planned shard's k under `--ci-plan --shard-jobs k`: at most one parallel
* sub-shard per file that may run in parallel, so a shard of only serial files
* (the exclusive TREE_MUTATING shard) keeps k = 1. Undefined means k = 1, and
* the plan then carries no `jobs` key, so its id matches a plan without k.
*/
export function plannedShardJobs(files: readonly string[], shardJobs: number, isSerial: (file: string) => boolean): number | undefined {
const jobs = Math.min(shardJobs, files.filter(file => !isSerial(file)).length);
return jobs > 1 ? jobs : undefined;
}
/** Why a plan's per-shard k cannot stand; null when absent or valid. */
export function plannedShardJobsProblem(shard: { shard: number; files: readonly string[]; jobs?: unknown }, isSerial: (file: string) => boolean): string | null {
if (shard.jobs === undefined) return null;
const parallel = shard.files.filter(file => !isSerial(file)).length;
return Number.isInteger(shard.jobs) && (shard.jobs as number) >= 2 && (shard.jobs as number) <= parallel ? null
: `CI shard ${shard.shard} plans --jobs ${JSON.stringify(shard.jobs)} for ${parallel} parallel file(s)`;
}
export function rejectJobsWithoutCiRun(): never { export function rejectJobsWithoutCiRun(): never {
throw new Error(`${FREE_JOBS_INVALID}: --jobs needs --ci-run. ${JOBS_FIX}`); throw new Error(`${FREE_JOBS_INVALID}: --jobs needs --ci-run. ${JOBS_FIX}`);
} }
+35 -15
View File
@@ -77,11 +77,15 @@
* bun run scripts/test-free-shards.ts --attribute-home # name real-home writers: each file alone, private HOME * bun run scripts/test-free-shards.ts --attribute-home # name real-home writers: each file alone, private HOME
* bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20 * bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20
* bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json * bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json
* bun run scripts/test-free-shards.ts --ci-plan plan.json --shards 20 --shard-jobs 2
* # the plan stores a per-shard k: min(k, the shard's files that may run in parallel),
* # kept only when above 1, so the exclusive TREE_MUTATING shard stays one child.
* bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json --jobs 4 * bun run scripts/test-free-shards.ts --ci-run plan.json --shard 1 --result result.json --jobs 4
* # k sub-shards side by side inside one CI job (default 1 = one child, today's behavior; * # k sub-shards side by side inside one CI job (default: the plan's per-shard k, else 1 =
* # scripts/lib/shard-jobs.ts). --jobs applies only to --ci-run: a positive whole number up * # one child, today's behavior; scripts/lib/shard-jobs.ts). --jobs applies only to
* # to the shard's file count, else FREE_JOBS_INVALID. GSTACK_FREE_JOBS sets the local * # --ci-run and overrides the plan: a positive whole number up to the shard's file count,
* # full-suite process count and never changes --ci-run; --jobs is its only knob. * # else FREE_JOBS_INVALID. --ci-verify requires every result to have run at the plan's k.
* # GSTACK_FREE_JOBS sets the local full-suite process count and never changes --ci-run.
* bun run scripts/test-free-shards.ts --ci-verify plan.json --results results/ * bun run scripts/test-free-shards.ts --ci-verify plan.json --results results/
*/ */
@@ -97,8 +101,8 @@ import { attributeFreeHomeWriters, guardFreeHome, sharedFreeHome, type FreeHomeG
import { appendStepSummary, ciHealthSummary, SEED_REFRESH_COMMAND, unseededWarning, windowsCurationLine } from './lib/free-ci-health'; import { appendStepSummary, ciHealthSummary, SEED_REFRESH_COMMAND, unseededWarning, windowsCurationLine } from './lib/free-ci-health';
import { curateWindowsSafe, type CurationResult } from './lib/windows-curation'; import { curateWindowsSafe, type CurationResult } from './lib/windows-curation';
import { import {
assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, readJunitReport, rejectJobsWithoutCiRun, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem, readJunitReport, rejectJobsWithoutCiRun,
validateJobsForShard, type ShardTestCounts, type SubShardRecord, rejectShardJobsWithoutCiPlan, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, validateJobsForShard, type ShardTestCounts, type SubShardRecord,
} from './lib/shard-jobs'; } from './lib/shard-jobs';
export { curateWindowsSafe, detectWindowsFragility, KNOWN_WINDOWS_INCOMPATIBLE, type CurationResult } from './lib/windows-curation'; export { curateWindowsSafe, detectWindowsFragility, KNOWN_WINDOWS_INCOMPATIBLE, type CurationResult } from './lib/windows-curation';
import { import {
@@ -424,19 +428,27 @@ export interface FreeCiPlan {
version: 1; version: 1;
revision: string; revision: string;
id: string; id: string;
shards: Array<{ shard: number; files: string[]; predictedMs: number }>; /** `jobs` is the shard's k from --shard-jobs, present only when above 1. */
shards: Array<{ shard: number; files: string[]; predictedMs: number; jobs?: number }>;
} }
/** Files that run in the trailing serial sub-shard under k > 1, never beside another child. */
const runsSerialUnderJobs = (file: string) => file in TREE_MUTATING || file in SLOW_UNDER_LOAD;
const planDigest = (plan: Omit<FreeCiPlan, 'id'>): string => const planDigest = (plan: Omit<FreeCiPlan, 'id'>): string =>
createHash('sha256').update(JSON.stringify(plan)).digest('hex'); createHash('sha256').update(JSON.stringify(plan)).digest('hex');
/** One immutable plan is shared by isolated CI machines; never repack per job. */ /** One immutable plan is shared by isolated CI machines; never repack per job. */
export function createFreeCiPlan(files: string[], count: number, durations: Record<string, number>, revision: string): FreeCiPlan { export function createFreeCiPlan(files: string[], count: number, durations: Record<string, number>, revision: string, shardJobs = 1): FreeCiPlan {
const readers = files.filter(file => !(file in TREE_MUTATING)); const readers = files.filter(file => !(file in TREE_MUTATING));
const exclusive = files.filter(file => file in TREE_MUTATING).sort(); const exclusive = files.filter(file => file in TREE_MUTATING).sort();
const packed = packShardsByDuration(readers, count, durations); const packed = packShardsByDuration(readers, count, durations);
const shards = packed.shards.map((files, index) => ({ shard: index + 1, files, predictedMs: packed.predictedMs[index] })); const planned = packed.shards.map((files, index) => ({ files, predictedMs: packed.predictedMs[index] }));
if (exclusive.length) shards.push({ shard: shards.length + 1, files: exclusive, predictedMs: exclusive.reduce((ms, file) => ms + (durations[file] ?? 0), 0) }); if (exclusive.length) planned.push({ files: exclusive, predictedMs: exclusive.reduce((ms, file) => ms + (durations[file] ?? 0), 0) });
const shards = planned.map(({ files, predictedMs }, index) => {
const jobs = plannedShardJobs(files, shardJobs, runsSerialUnderJobs);
return { shard: index + 1, files, predictedMs, ...(jobs ? { jobs } : {}) };
});
const body = { version: 1 as const, revision, shards }; const body = { version: 1 as const, revision, shards };
return { ...body, id: planDigest(body) }; return { ...body, id: planDigest(body) };
} }
@@ -447,6 +459,8 @@ export function validateFreeCiPlan(plan: FreeCiPlan, files: string[], revision:
|| id !== planDigest({ version, revision: plan.revision, shards })) throw new Error('CI plan identity or revision mismatch'); || id !== planDigest({ version, revision: plan.revision, shards })) throw new Error('CI plan identity or revision mismatch');
if (shards.some((shard, index) => shard.shard !== index + 1 || !Array.isArray(shard.files) if (shards.some((shard, index) => shard.shard !== index + 1 || !Array.isArray(shard.files)
|| !Number.isFinite(shard.predictedMs) || shard.predictedMs < 0)) throw new Error('Invalid CI shard plan'); || !Number.isFinite(shard.predictedMs) || shard.predictedMs < 0)) throw new Error('Invalid CI shard plan');
const jobsProblems = shards.map(shard => plannedShardJobsProblem(shard, runsSerialUnderJobs)).filter(Boolean);
if (jobsProblems.length) throw new Error(`Invalid CI shard plan: ${jobsProblems.join('; ')}`);
const planned = shards.flatMap(shard => shard.files).sort(); const planned = shards.flatMap(shard => shard.files).sort();
if (new Set(planned).size !== planned.length || JSON.stringify(planned) !== JSON.stringify([...files].sort())) throw new Error('CI plan must cover every free file exactly once'); if (new Set(planned).size !== planned.length || JSON.stringify(planned) !== JSON.stringify([...files].sort())) throw new Error('CI plan must cover every free file exactly once');
} }
@@ -486,6 +500,7 @@ export function verifyFreeCiResults(plan: FreeCiPlan, results: FreeCiResult[]):
if (!hasCompleteCiSummary(outcome)) throw new Error('Missing or incomplete CI execution summary'); if (!hasCompleteCiSummary(outcome)) throw new Error('Missing or incomplete CI execution summary');
const subShardProblems = subShardRecordProblems(result); const subShardProblems = subShardRecordProblems(result);
if (subShardProblems.length) throw new Error(`CI shard ${outcome.shard} sub-shards rejected: ${subShardProblems.join('; ')}`); if (subShardProblems.length) throw new Error(`CI shard ${outcome.shard} sub-shards rejected: ${subShardProblems.join('; ')}`);
if ((result.jobs ?? 1) !== (shard.jobs ?? 1)) throw new Error(`CI shard ${outcome.shard} ran at --jobs ${result.jobs ?? 1}; the plan says ${shard.jobs ?? 1}`);
if (outcome.status === 'passed') { if (outcome.status === 'passed') {
if (outcome.exitCode !== 0 || outcome.failingFiles.length || outcome.unattributedFailures || result.retry) throw new Error('Inconsistent passing CI result'); if (outcome.exitCode !== 0 || outcome.failingFiles.length || outcome.unattributedFailures || result.retry) throw new Error('Inconsistent passing CI result');
} else { } else {
@@ -556,8 +571,10 @@ type CliOptions = {
result: string | null; result: string | null;
results: string | null; results: string | null;
filesFrom: string | null; filesFrom: string | null;
/** Set only by --jobs (with --ci-run); absent means k = 1. */ /** Set only by --jobs (with --ci-run); absent means the plan's per-shard k. */
jobs?: number; jobs?: number;
/** Set only by --shard-jobs (with --ci-plan); absent means k = 1 in every shard. */
shardJobs?: number;
}; };
export function parseCliOptions(argv: string[]): CliOptions { export function parseCliOptions(argv: string[]): CliOptions {
@@ -573,6 +590,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
let quick = false; let quick = false;
let attributeHome = false; let attributeHome = false;
let jobs: number | undefined; let jobs: number | undefined;
let shardJobs: number | undefined;
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = { const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | null> = {
ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null, ciPlan: null, ciRun: null, ciVerify: null, result: null, results: null, filesFrom: null,
}; };
@@ -597,6 +615,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
'--results': pathFlag('--results', 'results'), '--results': pathFlag('--results', 'results'),
'--files-from': pathFlag('--files-from', 'filesFrom'), '--files-from': pathFlag('--files-from', 'filesFrom'),
'--jobs': (next) => { jobs = parseJobsFlag(next()); }, '--jobs': (next) => { jobs = parseJobsFlag(next()); },
'--shard-jobs': (next) => { shardJobs = parseJobsFlag(next(), '--shard-jobs'); },
'--shards': (next) => { '--shards': (next) => {
const value = next(); const value = next();
if (!value) throw new Error('Missing value for --shards'); if (!value) throw new Error('Missing value for --shards');
@@ -621,8 +640,9 @@ export function parseCliOptions(argv: string[]): CliOptions {
if (paths.ciRun && (shardIndex === null || !paths.result)) throw new Error('--ci-run requires --shard and --result'); if (paths.ciRun && (shardIndex === null || !paths.result)) throw new Error('--ci-run requires --shard and --result');
if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results'); if (paths.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
if (jobs !== undefined && !paths.ciRun) rejectJobsWithoutCiRun(); if (jobs !== undefined && !paths.ciRun) rejectJobsWithoutCiRun();
if (shardJobs !== undefined && !paths.ciPlan) rejectShardJobsWithoutCiPlan();
if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection'); if (quick && (recordDurations || windowsOnly || shardIndex !== null)) throw new Error('--quick cannot change recording, Windows or shard selection');
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }) }; return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit, quick, attributeHome, ...paths, ...(jobs === undefined ? {} : { jobs }), ...(shardJobs === undefined ? {} : { shardJobs }) };
} }
const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`; const describeLines = (lines: string[]) => `${lines.slice(0, 5).join(', ')}${lines.length > 5 ? ` (+${lines.length - 5} more)` : ''}`;
@@ -1954,7 +1974,7 @@ async function main(): Promise<number> {
appendStepSummary(line); appendStepSummary(line);
} }
warnUnseededFreeFiles(ciFiles, durations); warnUnseededFreeFiles(ciFiles, durations);
const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision); const plan = createFreeCiPlan(ciFiles, options.shardCount, durations, revision, options.shardJobs);
validateFreeCiPlan(plan, ciFiles, revision); validateFreeCiPlan(plan, ciFiles, revision);
writeJson(options.ciPlan, plan); writeJson(options.ciPlan, plan);
console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) })); console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) }));
@@ -1975,7 +1995,7 @@ async function main(): Promise<number> {
} }
const shard = plan.shards[options.shardIndex! - 1]; const shard = plan.shards[options.shardIndex! - 1];
if (!shard || shard.shard !== options.shardIndex) throw new Error('CI shard index is outside the plan'); if (!shard || shard.shard !== options.shardIndex) throw new Error('CI shard index is outside the plan');
const jobs = options.jobs ?? 1; const jobs = options.jobs ?? shard.jobs ?? 1;
validateJobsForShard(jobs, shard.files.length, shard.shard); validateJobsForShard(jobs, shard.files.length, shard.shard);
const durations = loadFreeTestDurations() ?? {}; const durations = loadFreeTestDurations() ?? {};
const { result, exitCode } = await runCiShard({ const { result, exitCode } = await runCiShard({
@@ -1986,7 +2006,7 @@ async function main(): Promise<number> {
runShard: (files, shardNumber, totalShards, runOptions) => runFreeShard(files, shardNumber, totalShards, runOptions), runShard: (files, shardNumber, totalShards, runOptions) => runFreeShard(files, shardNumber, totalShards, runOptions),
retry: outcomes => retryFailedFreeFiles(outcomes, plan.shards.length, options), retry: outcomes => retryFailedFreeFiles(outcomes, plan.shards.length, options),
pack: (files, count) => packShardsByDuration(files, count, durations).shards, pack: (files, count) => packShardsByDuration(files, count, durations).shards,
isSerial: file => file in TREE_MUTATING || file in SLOW_UNDER_LOAD, isSerial: runsSerialUnderJobs,
terminationRequested: isTerminationRequested, terminationRequested: isTerminationRequested,
}); });
writeJson(options.result!, result); writeJson(options.result!, result);
+19
View File
@@ -256,3 +256,22 @@ describe('CI Node.js installs match the image and are verified (#3354)', () => {
expect(unpack).toBeGreaterThan(verify); expect(unpack).toBeGreaterThan(verify);
}); });
}); });
// Value: protects=the eval image ships an ffmpeg that runs /design-motion's checks and exports;
// fails_when=Dockerfile.ci stops installing ffmpeg or stops failing the build on a missing filter or encoder;
// why_new=the marathon design-motion-showcase case ran 0/3 on an image without ffmpeg (#3687); seam=none;
// no_claim=reads the Dockerfile only; the image build runs the check itself
describe('ci image ffmpeg for the design-motion evals (#3687)', () => {
const lines = logicalLines(fs.readFileSync(DOCKERFILE, 'utf-8')).filter((line) => !/^\s*#/.test(line));
test('apt installs ffmpeg, which the marathon design-motion-showcase case needs', () => {
expect(lines.some((line) => /apt-get install\b[^&|;]*\sffmpeg\b/.test(line))).toBe(true);
});
test('the build fails when that ffmpeg cannot run the motion checks and exports', () => {
const verify = lines.find((line) => /\bffmpeg\b.*-filters/.test(line)) ?? '';
for (const need of ['ffprobe', 'psnr', 'gblur', 'palettegen', 'paletteuse', 'signalstats', 'tile', 'libx264', 'gif']) {
expect(verify, `Dockerfile.ci must check ffmpeg for ${need}`).toContain(need);
}
});
});
+8 -6
View File
@@ -174,7 +174,8 @@ describe('free-tests workflow wiring', () => {
expect(workflow.env.FREE_SUITE_SHARDS).toMatch(/^[1-9][0-9]?$/); expect(workflow.env.FREE_SUITE_SHARDS).toMatch(/^[1-9][0-9]?$/);
expect(planner.outputs.runner).toBe('${{ steps.plan.outputs.runner }}'); expect(planner.outputs.runner).toBe('${{ steps.plan.outputs.runner }}');
expect(workflow.jobs['free-suite']['runs-on']).toBe('${{ needs.free-plan.outputs.runner }}'); expect(workflow.jobs['free-suite']['runs-on']).toBe('${{ needs.free-plan.outputs.runner }}');
expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}' }); expect(workflow.env.FREE_SUITE_JOBS).toMatch(/^[1-8]$/);
expect(plan.env).toEqual({ RUNNER_OVERRIDE: '${{ inputs.free_runner }}', SHARDS_OVERRIDE: '${{ inputs.free_shards }}', JOBS_OVERRIDE: '${{ inputs.free_jobs }}' });
expect(plan.run).not.toContain('${{'); expect(plan.run).not.toContain('${{');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'free plan ')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'free plan '));
const bin = path.join(directory, 'bin'); const bin = path.join(directory, 'bin');
@@ -185,7 +186,7 @@ describe('free-tests workflow wiring', () => {
const output = path.join(directory, 'output'); const output = path.join(directory, 'output');
fs.writeFileSync(output, ''); fs.writeFileSync(output, '');
const result = spawnSync('bash', ['-c', plan.run], { const result = spawnSync('bash', ['-c', plan.run], {
env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', ...overrides, env: { ...process.env, ...workflow.env, RUNNER_OVERRIDE: '', SHARDS_OVERRIDE: '', JOBS_OVERRIDE: '', ...overrides,
PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory), PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory),
GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) }, GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) },
encoding: 'utf8', timeout: 5000, encoding: 'utf8', timeout: 5000,
@@ -199,12 +200,13 @@ describe('free-tests workflow wiring', () => {
const unset = resolve({}); const unset = resolve({});
expect(unset.status).toBe(0); expect(unset.status).toBe(0);
expect(unset.outputs.runner).toBe(workflow.env.FREE_SUITE_RUNNER); expect(unset.outputs.runner).toBe(workflow.env.FREE_SUITE_RUNNER);
expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS}`); expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS} --shard-jobs ${workflow.env.FREE_SUITE_JOBS}`);
const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16' }); const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16', JOBS_OVERRIDE: '3' });
expect(override.status).toBe(0); expect(override.status).toBe(0);
expect(override.outputs.runner).toBe('ubicloud-standard-4'); expect(override.outputs.runner).toBe('ubicloud-standard-4');
expect(override.args).toContain('--shards 16'); expect(override.args).toContain('--shards 16 --shard-jobs 3');
const refusals: Record<string, string>[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' }]; const refusals: Record<string, string>[] = [{ RUNNER_OVERRIDE: 'ubuntu-24.04' }, { RUNNER_OVERRIDE: 'ubicloud-standard-8 x' }, { SHARDS_OVERRIDE: '0' }, { SHARDS_OVERRIDE: '41' }, { SHARDS_OVERRIDE: '16; true' },
{ JOBS_OVERRIDE: '0' }, { JOBS_OVERRIDE: '9' }, { JOBS_OVERRIDE: '2; true' }, { JOBS_OVERRIDE: '-1' }];
for (const bad of refusals) { for (const bad of refusals) {
const refused = resolve(bad); const refused = resolve(bad);
expect(refused.status, JSON.stringify(bad)).not.toBe(0); expect(refused.status, JSON.stringify(bad)).not.toBe(0);
@@ -120,3 +120,13 @@ printf 200
} finally { rmSync(f.root, { recursive: true, force: true }); } } finally { rmSync(f.root, { recursive: true, force: true }); }
}, 60_000); }, 60_000);
}); });
describe('CI runs these cases on the BSD userland itself', () => {
// Value: protects=the cases above run where /usr/bin/tar and /usr/bin/awk are the BSD tools; fails_when=macos-named-regressions stops naming this file, so Linux CI (no bsdtar or one-true-awk installed) skips every case and a #3680 or #3679 regression ships green; why_new=the file was only in the Linux free suite; seam=.github/workflows/free-tests.yml; no_claim=the job's runner image or its tool versions
test('free-tests.yml macos-named-regressions names this file', () => {
const workflow = Bun.YAML.parse(readFileSync(join(ROOT, '.github/workflows/free-tests.yml'), 'utf8')) as any;
const mac = workflow.jobs['macos-named-regressions'];
expect(mac['runs-on']).toMatch(/^macos-/);
expect((mac.steps as any[]).map(step => String(step.run ?? '')).join('\n')).toContain('test/regression-issue3680-ubicloud-bsd-userland.test.ts');
});
});
+51 -4
View File
@@ -10,11 +10,11 @@ import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { import {
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults, collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, validateFreeCiPlan, verifyFreeCiResults,
type FreeCiResult, type FreeShardOutcome, type FreeCiResult, type FreeShardOutcome,
} from '../scripts/test-free-shards'; } from '../scripts/test-free-shards';
import { import {
assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, assertSoloSeedSample, assignSubShards, ciRunContext, FREE_JOBS_INVALID, mergeSubShardOutcomes, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem,
readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions, readJunitReport, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, subShardTimeoutText, validateJobsForShard, type CiShardDeps, type RunShardOptions,
} from '../scripts/lib/shard-jobs'; } from '../scripts/lib/shard-jobs';
@@ -89,6 +89,52 @@ describe('--jobs validation (FREE_JOBS_INVALID)', () => {
}); });
}); });
describe('per-shard k in the plan (--ci-plan --shard-jobs)', () => {
test('--shard-jobs belongs to --ci-plan only and takes the same positive whole numbers', () => {
const base = ['--ci-plan', 'plan.json', '--shards', '4'];
expect(parseCliOptions([...base, '--shard-jobs', '2']).shardJobs).toBe(2);
expect('shardJobs' in parseCliOptions(base)).toBe(false);
for (const raw of ['0', '2.5', 'x']) expect(() => parseCliOptions([...base, '--shard-jobs', raw])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs a positive whole number`);
expect(() => parseCliOptions(['--shard-jobs', '2'])).toThrow(`${FREE_JOBS_INVALID}: --shard-jobs needs --ci-plan`);
expect(() => parseCliOptions(['--ci-run', 'p.json', '--shard', '1', '--result', 'r.json', '--shard-jobs', '2'])).toThrow(FREE_JOBS_INVALID);
});
test('each shard plans min(k, its parallel files); the exclusive shard and one-file shards stay one child, and k = 1 keeps the plan id', () => {
const withExclusive = [...files, EXCLUSIVE];
const plain = createFreeCiPlan(withExclusive, 3, durations, 'revision-a');
expect(createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 1)).toEqual(plain);
expect(plain.shards.every(shard => !('jobs' in shard))).toBe(true);
const planned = createFreeCiPlan(withExclusive, 3, durations, 'revision-a', 4);
expect(planned.id).not.toBe(plain.id);
expect(planned.shards.map(shard => shard.files)).toEqual(plain.shards.map(shard => shard.files));
const exclusiveShard = planned.shards.at(-1)!;
expect(exclusiveShard.files).toEqual([EXCLUSIVE]);
expect('jobs' in exclusiveShard).toBe(false);
for (const shard of planned.shards.slice(0, -1)) expect(shard.jobs).toBe(Math.min(4, shard.files.length) > 1 ? Math.min(4, shard.files.length) : undefined);
expect(() => validateFreeCiPlan(planned, withExclusive, 'revision-a')).not.toThrow();
expect(plannedShardJobs(['a', 'b', 'c'], 2, file => file === 'a')).toBe(2);
expect(plannedShardJobs(['a', 'b'], 4, file => file === 'a')).toBeUndefined();
expect(plannedShardJobs([], 4, () => false)).toBeUndefined();
});
test('a plan whose per-shard k is out of range is invalid, and every shard result must have run at its planned k', () => {
const isSerial = (file: string) => file === EXCLUSIVE;
expect(plannedShardJobsProblem({ shard: 1, files }, isSerial)).toBeNull();
expect(plannedShardJobsProblem({ shard: 1, files, jobs: 7 }, isSerial)).toBeNull();
for (const jobs of [1, 0, 8, 2.5, '2']) expect(plannedShardJobsProblem({ shard: 1, files, jobs }, isSerial)).toContain('CI shard 1 plans --jobs');
expect(plannedShardJobsProblem({ shard: 2, files: [EXCLUSIVE, files[0]], jobs: 2 }, isSerial)).toContain('for 1 parallel file(s)');
const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2);
expect(plan.shards[0].jobs).toBe(2);
const tampered = structuredClone(plan);
tampered.shards[0].jobs = 99;
expect(() => validateFreeCiPlan(tampered, files, 'revision-a')).toThrow();
const k1: FreeCiResult = { planId: plan.id, revision: 'revision-a', outcome: passed(plan.shards[0].files), retry: null };
expect(() => verifyFreeCiResults(plan, [k1])).toThrow('CI shard 1 ran at --jobs 1; the plan says 2');
const plainPlan = createFreeCiPlan(files, 1, durations, 'revision-a');
expect(() => verifyFreeCiResults(plainPlan, [{ ...k1, planId: plainPlan.id }])).not.toThrow();
});
});
describe('k = 1 golden: PR 2 changes nothing in CI', () => { describe('k = 1 golden: PR 2 changes nothing in CI', () => {
test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => { test('one child over the whole planned shard with the same wall, one retry pass, and the same result keys', async () => {
const h = harness(files); const h = harness(files);
@@ -160,7 +206,8 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } }); summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } });
expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']); expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']);
expect(h.retries).toHaveLength(1); expect(h.retries).toHaveLength(1);
expect(() => verifyFreeCiResults(h.plan, [result])).not.toThrow(); expect(() => verifyFreeCiResults({ ...h.plan, shards: [{ ...h.shard, jobs: 3 }] }, [result])).not.toThrow();
expect(() => verifyFreeCiResults(h.plan, [result])).toThrow('CI shard 1 ran at --jobs 3; the plan says 1');
}); });
test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => { test('failures from every sub-shard are collected first, so k never multiplies the five-file retry cap', async () => {
@@ -263,7 +310,7 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
}); });
test('--ci-verify rejects tampered sub-shard records', () => { test('--ci-verify rejects tampered sub-shard records', () => {
const plan = createFreeCiPlan(files, 1, durations, 'revision-a'); const plan = createFreeCiPlan(files, 1, durations, 'revision-a', 2);
const shard = plan.shards[0]; const shard = plan.shards[0];
const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]); const assignments = assignSubShards(plan.id, shard, 2, () => false, packFiles => [packFiles.slice(0, 3), packFiles.slice(3)]);
const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10); const merged = mergeSubShardOutcomes(shard, assignments.map(assignment => ({ assignment, outcome: passed(assignment.files) })), 10);
+34 -2
View File
@@ -4,7 +4,9 @@
* that moved out of it. * that moved out of it.
*/ */
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import { existsSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards'; import { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards';
import { windowsCurationLine } from '../scripts/lib/free-ci-health'; import { windowsCurationLine } from '../scripts/lib/free-ci-health';
@@ -27,7 +29,7 @@ const WINDOWS_CURATED_FLOOR = 795;
test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => { test('the Windows lane runs nightly and by hand, plans, runs one strict shard per job and verifies every result', () => {
expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']); expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]); expect(windows.on.schedule).toEqual([{ cron: '17 9 * * *' }]);
expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations']); expect(Object.keys(windows.on.workflow_dispatch.inputs)).toEqual(['record_durations', 'windows_jobs']);
expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']); expect(Object.keys(windows.on.workflow_call.inputs)).toEqual(['record_durations']);
const plan = windows.jobs['windows-plan']; const plan = windows.jobs['windows-plan'];
expect(plan['runs-on']).toBe('ubuntu-24.04'); expect(plan['runs-on']).toBe('ubuntu-24.04');
@@ -50,6 +52,36 @@ test('the Windows lane runs nightly and by hand, plans, runs one strict shard pe
expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results '); expect(aggregate.steps.find((step: any) => step.name === 'Require exact coverage and complete strict results').run).toContain('--windows-only --ci-verify "$RUNNER_TEMP/windows-plan.json" --results ');
}); });
test.skipIf(!Bun.which('bash'))('windows-plan passes the one sub-shard constant, or a validated dispatch override, to --shard-jobs', () => {
const planStep = windows.jobs['windows-plan'].steps.find((step: any) => step.id === 'plan');
expect(windows.env.WINDOWS_FREE_JOBS).toMatch(/^[1-4]$/);
expect(planStep.env.JOBS_OVERRIDE).toBe('${{ inputs.windows_jobs }}');
expect(planStep.run).not.toContain('${{');
const directory = mkdtempSync(path.join(os.tmpdir(), 'windows plan '));
const bin = path.join(directory, 'bin');
mkdirSync(bin);
writeFileSync(path.join(bin, 'bun'), '#!/bin/sh\nprintf "%s\\n" "$*" > "$RUNNER_TEMP/bun-args"\necho \'{"shard":[1]}\'\n', { mode: 0o755 });
const toShell = (p: string) => p.split(path.sep).join('/');
const resolve = (override: string) => {
rmSync(path.join(directory, 'bun-args'), { force: true });
const result = spawnSync('bash', ['-c', planStep.run], {
env: { ...process.env, ...windows.env, JOBS_OVERRIDE: override, PATH: `${toShell(bin)}:${process.env.PATH}`, RUNNER_TEMP: toShell(directory),
GITHUB_OUTPUT: toShell(path.join(directory, 'output')), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) },
encoding: 'utf8', timeout: 5000,
});
return { status: result.status, args: existsSync(path.join(directory, 'bun-args')) ? readFileSync(path.join(directory, 'bun-args'), 'utf8') : '' };
};
try {
expect(resolve('')).toEqual({ status: 0, args: expect.stringContaining(`--shards 6 --shard-jobs ${windows.env.WINDOWS_FREE_JOBS}`) });
expect(resolve('2')).toEqual({ status: 0, args: expect.stringContaining('--shards 6 --shard-jobs 2') });
for (const bad of ['0', '5', '2; true', 'x']) {
const refused = resolve(bad);
expect(refused.status, bad).not.toBe(0);
expect(refused.args, bad).toBe('');
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => { test('a Windows CI plan binds the curated file set, so it never verifies as the full Linux suite', () => {
for (const argv of [['--windows-only', '--ci-plan', 'p.json'], ['--windows-only', '--ci-run', 'p.json', '--shard', '1', '--result', 'r.json'], ['--windows-only', '--ci-verify', 'p.json', '--results', 'd']]) { for (const argv of [['--windows-only', '--ci-plan', 'p.json'], ['--windows-only', '--ci-run', 'p.json', '--shard', '1', '--result', 'r.json'], ['--windows-only', '--ci-verify', 'p.json', '--results', 'd']]) {
expect(parseCliOptions(argv).windowsOnly).toBe(true); expect(parseCliOptions(argv).windowsOnly).toBe(true);