mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* 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:
1 parent
3d99c22497
commit
4bbad24221
15 files changed
+297
-62
No files matched your search
@@ -193,6 +193,21 @@ RUN for i in 1 2 3; do \
|
||||
&& fc-cache -f \
|
||||
&& 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
|
||||
# bun.lock changes). Copy BOTH so install is deterministic and matches local
|
||||
# resolution. Without bun.lock here, bun install resolved transitive deps
|
||||
|
||||
@@ -41,13 +41,21 @@ on:
|
||||
description: 'Runner-sizing experiments only: shard count, 1-40 (empty keeps FREE_SUITE_SHARDS)'
|
||||
type: string
|
||||
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
|
||||
# push runs never see workflow_dispatch inputs, so free-plan resolves these
|
||||
# (or a dispatch override) into outputs that free-suite reads.
|
||||
# The one source of the free-suite runner, shard count and sub-shards per
|
||||
# shard. pull_request and push runs never see workflow_dispatch inputs, so
|
||||
# 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:
|
||||
FREE_SUITE_RUNNER: ubicloud-standard-4
|
||||
FREE_SUITE_SHARDS: '20'
|
||||
FREE_SUITE_RUNNER: ubicloud-standard-8
|
||||
FREE_SUITE_SHARDS: '10'
|
||||
FREE_SUITE_JOBS: '4'
|
||||
|
||||
# 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
|
||||
@@ -120,10 +128,12 @@ jobs:
|
||||
env:
|
||||
RUNNER_OVERRIDE: ${{ inputs.free_runner }}
|
||||
SHARDS_OVERRIDE: ${{ inputs.free_shards }}
|
||||
JOBS_OVERRIDE: ${{ inputs.free_jobs }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
runner="${RUNNER_OVERRIDE:-$FREE_SUITE_RUNNER}"
|
||||
shards="${SHARDS_OVERRIDE:-$FREE_SUITE_SHARDS}"
|
||||
jobs="${JOBS_OVERRIDE:-$FREE_SUITE_JOBS}"
|
||||
case "$runner" in
|
||||
ubicloud-standard-2|ubicloud-standard-4|ubicloud-standard-8) ;;
|
||||
*) 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 ;;
|
||||
esac
|
||||
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
|
||||
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"
|
||||
case "$jobs" in
|
||||
''|*[!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
|
||||
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 "runner=$runner" >> "$GITHUB_OUTPUT"
|
||||
- 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
|
||||
# /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
|
||||
# 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:
|
||||
needs: reuse
|
||||
if: ${{ !cancelled() && (needs.reuse.result != 'success' || needs.reuse.outputs.reused != 'true') }}
|
||||
@@ -229,7 +244,7 @@ jobs:
|
||||
resolved="$(realpath "$tmp")"
|
||||
echo "temp root: $tmp -> $resolved"
|
||||
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
|
||||
bun test "${files[@]}"
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ on:
|
||||
description: Record per-file Windows durations (artifact) instead of running the lane
|
||||
type: boolean
|
||||
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.
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -49,6 +53,11 @@ on:
|
||||
type: boolean
|
||||
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:
|
||||
group: windows-free-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
@@ -105,8 +114,18 @@ jobs:
|
||||
# 4-vCPU Windows runner, so the lane lands near 4 min with setup.
|
||||
env:
|
||||
GSTACK_FREE_TEST_DURATIONS: scripts/free-test-durations-windows.json
|
||||
JOBS_OVERRIDE: ${{ inputs.windows_jobs }}
|
||||
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"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
**/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,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
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+38
-17
@@ -195,15 +195,18 @@ fallback; unknown files get 99th-percentile pessimism, and both full-suite and
|
||||
the long pole. Packed
|
||||
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
|
||||
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
|
||||
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`
|
||||
(`ubicloud-standard-4`) and `FREE_SUITE_SHARDS` (`'20'`), which `free-plan`
|
||||
resolves into the shards' runner and shard count. A `workflow_dispatch` run may
|
||||
override them with `free_runner` (`ubicloud-standard-2`, `-4` or `-8`) and
|
||||
`free_shards` (1–40) 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
|
||||
(`ubicloud-standard-8`), `FREE_SUITE_SHARDS` (`'10'`) and `FREE_SUITE_JOBS`
|
||||
(`'4'`, the [sub-shards](#sub-shards-inside-one-ci-job) each shard runs side by
|
||||
side), which `free-plan` resolves into the shards' runner, shard count and plan.
|
||||
GSTA-203 chose them by a 3-run A/B against 20 × `ubicloud-standard-4` at k = 1
|
||||
(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
|
||||
disk sample every 2 seconds, even when the suite fails. The public
|
||||
`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
|
||||
`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-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,
|
||||
opens or refreshes one seed PR whose body states the ratchet result, and
|
||||
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
|
||||
inside, with its own sandbox, process-group kill and log. Bun's worker
|
||||
`--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
|
||||
child, so no workflow changes behavior until it passes `--jobs`.
|
||||
`scripts/test-free-shards.ts`). Without `--jobs`, a shard runs at the plan's
|
||||
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
|
||||
shard's file count (an empty shard takes 1); anything else stops with
|
||||
`FREE_JOBS_INVALID`. `GSTACK_FREE_JOBS` sets the local full-suite process count
|
||||
and never changes `--ci-run`. The exclusive host-state shard has one file, so
|
||||
a workflow passes it `--jobs 1` or no `--jobs`.
|
||||
- Workflows set k in the plan, per shard: `--ci-plan <plan> --shards N
|
||||
--shard-jobs k` stores `jobs = min(k, the shard's files that may run in
|
||||
parallel)` on each shard, and only when that is above 1. The exclusive
|
||||
host-state shard holds only `TREE_MUTATING` files, so it stays one child, and
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
in `outcome.counts` (`tests`, `pass`, `fail`, `skip`, `todo`, `expects` per
|
||||
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.
|
||||
|
||||
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`),
|
||||
runs one strict serial shard per `windows-latest` job
|
||||
`windows-free-tests.yml` plans on Linux (`--windows-only --ci-plan --shards 6
|
||||
--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
|
||||
(`--windows-only --ci-verify`). Packing uses Windows-measured durations from
|
||||
`scripts/free-test-durations-windows.json`; the weekly
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -45,16 +45,39 @@ export const FREE_JOBS_INVALID = 'FREE_JOBS_INVALID';
|
||||
export const FREE_SUBSHARD_TIMEOUT = 'FREE_SUBSHARD_TIMEOUT';
|
||||
export const FREE_SUBSHARD_REJECTED = 'FREE_SUBSHARD_REJECTED';
|
||||
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. */
|
||||
export function parseJobsFlag(raw: string | undefined): number {
|
||||
/** `--jobs <k>` or `--shard-jobs <k>` at parse time: digits only, at least 1. */
|
||||
export function parseJobsFlag(raw: string | undefined, flag = '--jobs'): number {
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
throw new Error(`${FREE_JOBS_INVALID}: --jobs needs --ci-run. ${JOBS_FIX}`);
|
||||
}
|
||||
|
||||
+35
-15
@@ -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 --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-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
|
||||
* # k sub-shards side by side inside one CI job (default 1 = one child, today's behavior;
|
||||
* # scripts/lib/shard-jobs.ts). --jobs applies only to --ci-run: a positive whole number up
|
||||
* # to the shard's file count, else FREE_JOBS_INVALID. GSTACK_FREE_JOBS sets the local
|
||||
* # full-suite process count and never changes --ci-run; --jobs is its only knob.
|
||||
* # k sub-shards side by side inside one CI job (default: the plan's per-shard k, else 1 =
|
||||
* # one child, today's behavior; scripts/lib/shard-jobs.ts). --jobs applies only to
|
||||
* # --ci-run and overrides the plan: a positive whole number up to the shard's file count,
|
||||
* # 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/
|
||||
*/
|
||||
|
||||
@@ -97,8 +101,8 @@ import { attributeFreeHomeWriters, guardFreeHome, sharedFreeHome, type FreeHomeG
|
||||
import { appendStepSummary, ciHealthSummary, SEED_REFRESH_COMMAND, unseededWarning, windowsCurationLine } from './lib/free-ci-health';
|
||||
import { curateWindowsSafe, type CurationResult } from './lib/windows-curation';
|
||||
import {
|
||||
assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, readJunitReport, rejectJobsWithoutCiRun, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems,
|
||||
validateJobsForShard, type ShardTestCounts, type SubShardRecord,
|
||||
assertSoloSeedSample, ciRunContext, parseJobsFlag, parseJunitCounts, plannedShardJobs, plannedShardJobsProblem, readJunitReport, rejectJobsWithoutCiRun,
|
||||
rejectShardJobsWithoutCiPlan, runCiShard, SLOW_UNDER_LOAD, subShardRecordProblems, validateJobsForShard, type ShardTestCounts, type SubShardRecord,
|
||||
} from './lib/shard-jobs';
|
||||
export { curateWindowsSafe, detectWindowsFragility, KNOWN_WINDOWS_INCOMPATIBLE, type CurationResult } from './lib/windows-curation';
|
||||
import {
|
||||
@@ -424,19 +428,27 @@ export interface FreeCiPlan {
|
||||
version: 1;
|
||||
revision: 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 =>
|
||||
createHash('sha256').update(JSON.stringify(plan)).digest('hex');
|
||||
|
||||
/** 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 exclusive = files.filter(file => file in TREE_MUTATING).sort();
|
||||
const packed = packShardsByDuration(readers, count, durations);
|
||||
const shards = packed.shards.map((files, index) => ({ shard: index + 1, 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) });
|
||||
const planned = packed.shards.map((files, index) => ({ files, predictedMs: packed.predictedMs[index] }));
|
||||
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 };
|
||||
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');
|
||||
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');
|
||||
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();
|
||||
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');
|
||||
const subShardProblems = subShardRecordProblems(result);
|
||||
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.exitCode !== 0 || outcome.failingFiles.length || outcome.unattributedFailures || result.retry) throw new Error('Inconsistent passing CI result');
|
||||
} else {
|
||||
@@ -556,8 +571,10 @@ type CliOptions = {
|
||||
result: string | null;
|
||||
results: 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;
|
||||
/** Set only by --shard-jobs (with --ci-plan); absent means k = 1 in every shard. */
|
||||
shardJobs?: number;
|
||||
};
|
||||
|
||||
export function parseCliOptions(argv: string[]): CliOptions {
|
||||
@@ -573,6 +590,7 @@ export function parseCliOptions(argv: string[]): CliOptions {
|
||||
let quick = false;
|
||||
let attributeHome = false;
|
||||
let jobs: number | undefined;
|
||||
let shardJobs: number | undefined;
|
||||
const paths: Record<'ciPlan' | 'ciRun' | 'ciVerify' | 'result' | 'results' | 'filesFrom', string | 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'),
|
||||
'--files-from': pathFlag('--files-from', 'filesFrom'),
|
||||
'--jobs': (next) => { jobs = parseJobsFlag(next()); },
|
||||
'--shard-jobs': (next) => { shardJobs = parseJobsFlag(next(), '--shard-jobs'); },
|
||||
'--shards': (next) => {
|
||||
const value = next();
|
||||
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.ciVerify && !paths.results) throw new Error('--ci-verify requires --results');
|
||||
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');
|
||||
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)` : ''}`;
|
||||
@@ -1954,7 +1974,7 @@ async function main(): Promise<number> {
|
||||
appendStepSummary(line);
|
||||
}
|
||||
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);
|
||||
writeJson(options.ciPlan, plan);
|
||||
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];
|
||||
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);
|
||||
const durations = loadFreeTestDurations() ?? {};
|
||||
const { result, exitCode } = await runCiShard({
|
||||
@@ -1986,7 +2006,7 @@ async function main(): Promise<number> {
|
||||
runShard: (files, shardNumber, totalShards, runOptions) => runFreeShard(files, shardNumber, totalShards, runOptions),
|
||||
retry: outcomes => retryFailedFreeFiles(outcomes, plan.shards.length, options),
|
||||
pack: (files, count) => packShardsByDuration(files, count, durations).shards,
|
||||
isSerial: file => file in TREE_MUTATING || file in SLOW_UNDER_LOAD,
|
||||
isSerial: runsSerialUnderJobs,
|
||||
terminationRequested: isTerminationRequested,
|
||||
});
|
||||
writeJson(options.result!, result);
|
||||
|
||||
@@ -256,3 +256,22 @@ describe('CI Node.js installs match the image and are verified (#3354)', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -174,7 +174,8 @@ describe('free-tests workflow wiring', () => {
|
||||
expect(workflow.env.FREE_SUITE_SHARDS).toMatch(/^[1-9][0-9]?$/);
|
||||
expect(planner.outputs.runner).toBe('${{ steps.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('${{');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'free plan '));
|
||||
const bin = path.join(directory, 'bin');
|
||||
@@ -185,7 +186,7 @@ describe('free-tests workflow wiring', () => {
|
||||
const output = path.join(directory, 'output');
|
||||
fs.writeFileSync(output, '');
|
||||
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),
|
||||
GITHUB_OUTPUT: toShell(output), GITHUB_STEP_SUMMARY: toShell(path.join(directory, 'summary')) },
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
@@ -199,12 +200,13 @@ describe('free-tests workflow wiring', () => {
|
||||
const unset = resolve({});
|
||||
expect(unset.status).toBe(0);
|
||||
expect(unset.outputs.runner).toBe(workflow.env.FREE_SUITE_RUNNER);
|
||||
expect(unset.args).toContain(`--shards ${workflow.env.FREE_SUITE_SHARDS}`);
|
||||
const override = resolve({ RUNNER_OVERRIDE: 'ubicloud-standard-4', SHARDS_OVERRIDE: '16' });
|
||||
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', JOBS_OVERRIDE: '3' });
|
||||
expect(override.status).toBe(0);
|
||||
expect(override.outputs.runner).toBe('ubicloud-standard-4');
|
||||
expect(override.args).toContain('--shards 16');
|
||||
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' }];
|
||||
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' },
|
||||
{ JOBS_OVERRIDE: '0' }, { JOBS_OVERRIDE: '9' }, { JOBS_OVERRIDE: '2; true' }, { JOBS_OVERRIDE: '-1' }];
|
||||
for (const bad of refusals) {
|
||||
const refused = resolve(bad);
|
||||
expect(refused.status, JSON.stringify(bad)).not.toBe(0);
|
||||
|
||||
@@ -120,3 +120,13 @@ printf 200
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
}, 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
@@ -10,11 +10,11 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, verifyFreeCiResults,
|
||||
collectFreeTestFiles, createFreeCiPlan, eligibleFreeRetryFiles, parseCliOptions, runFreeShard, TREE_MUTATING, validateFreeCiPlan, verifyFreeCiResults,
|
||||
type FreeCiResult, type FreeShardOutcome,
|
||||
} from '../scripts/test-free-shards';
|
||||
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,
|
||||
} 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', () => {
|
||||
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);
|
||||
@@ -160,7 +206,8 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
|
||||
summary: { testsRan: 16, filesRan: 8, sawTerminalSummary: true } });
|
||||
expect(result.subShards!.map(record => record.status)).toEqual(['passed', 'passed', 'passed', 'passed']);
|
||||
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 () => {
|
||||
@@ -263,7 +310,7 @@ describe('k > 1: merge, rejection and one retry pass per CI shard', () => {
|
||||
});
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* that moved out of it.
|
||||
*/
|
||||
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 { collectFreeTestFiles, createFreeCiPlan, curateWindowsSafe, parseCliOptions, validateFreeCiPlan } from '../scripts/test-free-shards';
|
||||
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', () => {
|
||||
expect(Object.keys(windows.on).sort()).toEqual(['schedule', 'workflow_call', 'workflow_dispatch']);
|
||||
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']);
|
||||
const plan = windows.jobs['windows-plan'];
|
||||
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 ');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user