Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions .github/workflows/testbox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# (scripts/goport/testbox.sh). `blacksmith testbox warmup` dispatches this workflow, `testbox run` syncs
# the working tree to it and runs one command. The setup matches the Linux test job of ci.yml, with
# zbook's toolchains. Sticky disks keep target/ and the cargo registry warm between testboxes.
# A pull request that changes this file runs it in validation mode (no testbox_id).
# A pull request that changes this file, testbox-data.sh or testbox-data.txt runs it in validation mode (no testbox_id).
name: Blacksmith Testbox

on:
Expand All @@ -15,6 +15,8 @@ on:
pull_request:
paths:
- ".github/workflows/testbox.yml"
- "scripts/goport/testbox-data.sh"
- "scripts/goport/testbox-data.txt"

permissions:
contents: read
Expand Down Expand Up @@ -42,7 +44,10 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
# After checkout: its clean step would empty a disk mounted in the workspace.
# After checkout: its clean step would empty a disk mounted in the workspace. A sticky disk commits only
# when the job ends with no failed or cancelled step: the idle timeout end requests a commit (testbox.sh
# release; not every request lands, see scripts/goport/README.md), `blacksmith testbox stop` cancels the
# job and does not (testbox.sh stop).
- uses: useblacksmith/stickydisk@v1
with:
key: ts-rust-testbox-target
Expand All @@ -51,6 +56,10 @@ jobs:
with:
key: ts-rust-testbox-cargo
path: ~/.cargo/registry
# np-suite needs Node 22 or newer; npm ci below needs node.
- uses: actions/setup-node@v5
with:
node-version: 24
# main's Go pin is the saved state's batch.upstreamPin.to (AGENTS.md, "Pin"). The Go checkout sits
# outside the workspace, so a sync of the local tree does not touch it.
- name: Resolve the Go pin
Expand All @@ -71,19 +80,17 @@ jobs:
key: ts-rust-testbox-pin-${{ steps.pin.outputs.key }}
path: ~/goport-data/pin
commit: on-change
# The whole repo, not only the Go subdir: np-suite reads packages/typescript and the root node_modules.
- name: Check out the pinned Go source
uses: actions/checkout@v5
with:
repository: ${{ steps.pin.outputs.repo }}
ref: ${{ steps.pin.outputs.commit }}
path: go-pin
sparse-checkout: ${{ steps.pin.outputs.subdir }}
persist-credentials: false
- name: Install the Go repo's typescript package
- name: Install the Go repo's node_modules
run: |
url=$(jq -r '.packages["node_modules/typescript"].resolved' go-pin/package-lock.json)
mkdir -p go-pin/node_modules/typescript
curl -sSfL "$url" | tar xz -C go-pin/node_modules/typescript --strip-components=1
(cd go-pin && npm ci --ignore-scripts --no-audit --no-fund --loglevel=error)
mv go-pin "$HOME/go-pin"
- uses: actions/setup-go@v6
with:
Expand All @@ -109,7 +116,7 @@ jobs:
SUBDIR: ${{ steps.pin.outputs.subdir }}
run: |
cat > "$HOME/testbox.env" <<EOF
export PATH="$(dirname "$(command -v go)"):$HOME/.cargo/bin:\$PATH"
export PATH="$(dirname "$(command -v go)"):$(dirname "$(command -v node)"):$HOME/.cargo/bin:\$PATH"
export CARGO_TERM_COLOR=always
export TS_GO_REPO="$HOME/go-pin${SUBDIR:+/$SUBDIR}"
export TSCTEST_TMP="$RUNNER_TEMP/tsctest"
Expand Down
69 changes: 69 additions & 0 deletions scripts/goport/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,72 @@ revision evidence (`remote.sh help`, "Reserved"): timing that needs zbook-class
- mini-abf9 is Theo's own machine again since 2026-10-04. `remote.sh` refuses every command on it.
- dbook-lan and mini-743d: `sudo -n`, perf_event_paranoid 4 (use `sudo perf`), THP madvise, and
perf, bpftrace and strace installed.

## Blacksmith testboxes

`testbox.sh` runs builds, the protected suites, the gate and the oracle batteries on a warm Blacksmith
runner (32 vCPU, 124 GB RAM), not on zbook or the SSH hosts. `.github/workflows/testbox.yml` sets up the
testbox, `testbox-data.sh` makes zbook's paths there, and `testbox-remote.sh` holds the steps of `tests`,
`np-suite` and `pin`. `testbox.sh help` gives the usage.

```sh
scripts/goport/testbox.sh warmup # about 1 min; TESTBOX_REF=<branch> for a workflow change
scripts/goport/testbox.sh tests <out-dir> <base results.json> # build-goport-tests.sh, goport-tests.sh, compare-tests.py
scripts/goport/testbox.sh np-suite <label> [<base label>] # np-suite.sh run, then np-suite.sh diff
scripts/goport/testbox.sh push <bins dir> # then run gate.sh, lsp_oracle.py or api_oracle.py as on zbook,
# from the zbook path, so the manifest names the same scripts:
# run bash -c 'cd /home/theo/Code/sandbox/ts-rust && GOPORT_PIN=<pin> bash scripts/goport/gate.sh ...'
scripts/goport/testbox.sh get <remote> [<local>] # rsync a result back
scripts/goport/testbox.sh stop # or release (see "Sticky disks")
```

- Warmup: bubblewrap, psutil, Node 24, Go, Rust 1.93.0 and the nightly, and zbook's paths
(`testbox-data.sh mount`):
- The workspace is bound on `/home/theo/Code/sandbox/ts-rust`, and the testbox env sets
`HOME=/home/theo`.
- The whole checkout of the batch pin, with `npm ci` node_modules, is bound on its zbook path.
- Each path of `testbox-data.txt` is bound from its sticky disk.
- `pin.py exec` binds the pin over the default oracle and goCheckout paths. bwrap makes those bind targets.
- Data: `testbox-data.txt` lists the zbook files that the gate and the LSP and API oracles read and git does
not hold. Disk `ts-rust-testbox-inputs` holds the files that are the same for every pin: the project inputs,
the runner trees and the corpus. Disk `ts-rust-testbox-pin-<key>` holds the pin cache, the LSP and API
goldens and the pin oracle. `testbox.sh seed [inputs|pin]` copies them from zbook (30 to 35 minutes; a
rerun copies only what is missing). Then `release`.
- Oracles: `pin.py` checks the sha256 of the pin oracle, and a build on the runner has another sha256 (Go
puts absolute paths in the binary). So the testbox uses zbook's recorded binary from the pin disk.
`tests` compares the testbox's copy with UPSTREAM.json and pushes zbook's binary when they differ.
- Git: the sync sends files, not git state. `tests` needs HEAD on origin: the testbox fetches it and resets
its index to it, so `build-goport-tests.sh` sees a clean tree at HEAD. Before a gate, fetch the commit
that the bins name (`run git fetch --depth=1 origin <sha>`).
- Other pins: `testbox.sh pin KEY` checks out a pin of this worktree's UPSTREAM.json (microsoft/TypeScript
pins only), for example bump D fed0bf24149f from a bump D worktree. Then
`testbox.sh tests --pin KEY <out-dir> <base results.json>` or `testbox.sh np-suite --pin KEY <label>`.
The pin disk holds only the batch pin's files. `tests --pin` pushes the other pin's oracle to that
testbox only.
- Output: `tests` writes `<out-dir>/tests-run` (results.json, logs, tsv, track), `<out-dir>/testbin` (the
metadata, no binaries) and `<out-dir>/compare.json`. After a failed build it writes the cargo logs to
`<out-dir>/testbin.new/logs` and prints the compiler errors. `np-suite` writes the label dir in the main
checkout's np-suite dir. Neither replaces an existing dir.
- Activity: the idle timer reads the marker `/home/runner/.testbox-last-activity`. The CLI touches it at
the start and the end of each `run`. `testbox.sh` runs the command in a subshell, so the
`HOME=/home/theo` of the testbox env does not move the end touch. The timer does not see the SSH route
of `push`, `get` and the `tests` poll. So `push` and `seed` touch the marker every minute, and the
`tests` and `np-suite` jobs every 30 s.
- Sticky disks: `target/` and the cargo registry, and the inputs and pin disks. The inputs and pin disks
commit only when a file changed (`commit: on-change`), so a testbox that only reads them does not commit an
older copy. A sticky disk commits only when the job ends with no failed or cancelled step. `stop` cancels the
job, so its writes are lost. `release` forgets the testbox, which ends at its idle timeout
(`TESTBOX_IDLE`, default 15 minutes) and requests a commit.
- Not every requested commit lands. On 2026-10-10, five testboxes that lived 2 to 13 minutes committed,
up to 2.1 GiB (a warm target after `tests` and `np-suite`). One that lived 27 minutes (10 to 15 idle
minutes at the end, no `stop`) requested a 2 GiB commit that never landed. The cause is not known: a
39-minute testbox of another session probably did commit.
- `TESTBOX_IDLE=1` with a release right after the last command gave commits that landed. Only `tests`,
`np-suite`, `push` and `seed` keep a testbox alive: with a short idle timeout, a `run` or `pin` command
longer than about 6 minutes is cut off (the VM waits at most 290 s for SSH sessions, then shuts down).
`TESTBOX_ID=<id> testbox.sh status` shows the end.
- The last commit wins, across branches too: every branch uses the same disk keys.
- Times (2026-10-10, cold target): `tests` 502 s (test bins 3.6 min, suite 4.7 min), `np-suite` 112 s. With
the R189 bins: the full gate 8.4 min, the LSP batteries 3 min, the API batteries 1 min.
- Not on testboxes yet: `candidate.sh side` and `perf.sh`. Revision evidence stays on dbook-lan and timing on
mini-743d.
2 changes: 1 addition & 1 deletion scripts/goport/testbox-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ case "$1" in
else mkdir -p "$(dirname "$src")" "$(dirname "$dest")"; touch "$src" "$dest"; fi
sudo mount --bind "$src" "$dest"
done < <(paths)
# The workflow checks out only the pin's Go subdir, at ~/go-pin.
# The workflow checks out the whole Go repo at ~/go-pin (np-suite needs packages/ and node_modules).
go=$(jq -r '(.subdir // "") as $s | .goCheckout | if $s == "" then . else rtrimstr("/" + $s) end' <<<"$pin")
mkdir -p "$go" "$REPO"
sudo mount --bind "$HOME/go-pin" "$go"
Expand Down
153 changes: 153 additions & 0 deletions scripts/goport/testbox-remote.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# The testbox side of testbox.sh. Each command runs on a Blacksmith testbox in the synced repo root, through
# `blacksmith testbox run`. The workflow has set up zbook's paths (testbox-data.sh mount): the workspace is
# bound on /home/theo/Code/sandbox/ts-rust, the batch pin's Go checkout (with node_modules) on its zbook path,
# the pin oracle and the other files of testbox-data.txt on theirs, and the testbox env sets HOME=/home/theo.
# usage: testbox-remote.sh pin [KEY...] the Go checkout of each pin at its zbook path, at the pin commit,
# with npm ci node_modules (default: the batch pin, which the
# workflow checks out)
# testbox-remote.sh start RUN STEP ARGS... run `testbox-remote.sh STEP ARGS...` detached from the SSH session:
# output in RUN.log, then its exit code in RUN.rc. The job counts as
# testbox activity (the idle timer does not end the testbox under it)
# testbox-remote.sh tests KEY COMMIT RUN build-goport-tests.sh at COMMIT, goport-tests.sh at pin KEY,
# pack the results and logs in RUN/out.tar.gz (after a failed build,
# its cargo logs in testbin.new/logs)
# testbox-remote.sh np-suite KEY LABEL RUN a stable release tsgo, np-suite.sh run LABEL at pin KEY, pack
# the label dir in RUN/out.tar.gz, then remove the label dir
# (after a failed build, only its build.log)
# pin.py exec binds a pin's oracle and Go checkout over the default ones; bwrap makes those bind targets.
# Only microsoft/TypeScript pins (layout typescript) are set up: the older layout needs the TypeScript submodule.
set -euo pipefail
case "${1:-help}" in help | -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d'; exit 0 ;; esac

# zbook's main checkout: the root of the absolute paths in UPSTREAM.json (pinRoot) and np-suite.sh.
ZBOOK_REPO=/home/theo/Code/sandbox/ts-rust
die() { echo "testbox-remote.sh: $*" >&2; exit 1; }
cd "$(git -C "$(dirname -- "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
pin() { python3 scripts/upstream/pin.py "$@"; }
batch_pin() { jq -r '.batch.upstreamPin.to // empty' docs/typechecker-state/current.json; }

# on_testbox: the workspace must be bound on $ZBOOK_REPO (testbox-data.sh mount). On zbook $ZBOOK_REPO is the
# main checkout itself, and `tests` must not move its HEAD (at_commit).
on_testbox() {
[[ $PWD != "$ZBOOK_REPO" && $ZBOOK_REPO -ef $PWD ]] ||
die "not on a testbox: $ZBOOK_REPO is not a bind of $PWD (testbox-data.sh mount)"
}

# checkout KEY: the whole pin checkout at its zbook path, at the pin commit, with the root node_modules
# (astnav tests and np-suite run node on them).
checkout() {
local rec repo commit go dir
rec=$(pin show "$1") || die "unknown pin $1"
[[ $(jq -r .layout <<< "$rec") == typescript ]] || die "pin $1: layout $(jq -r .layout <<< "$rec"), not typescript"
repo=$(jq -r .repo <<< "$rec") commit=$(jq -r .commit <<< "$rec") go=$(jq -r .goCheckout <<< "$rec")
dir=${go%/"$(jq -r .subdir <<< "$rec")"}
[[ $dir == /home/theo/.explore/repos/?* ]] || die "pin $1: checkout $dir is not under /home/theo/.explore/repos"
if [[ $(git -C "$dir" rev-parse HEAD 2> /dev/null) == "$commit" && -f $dir/node_modules/typescript/package.json ]]; then
echo "pin $1: $dir ready"
return
fi
# The workflow binds its checkout of the batch pin here: never empty it.
! mountpoint -q "$dir" || die "pin $1: the workflow's checkout at $dir is not at ${commit:0:12} with node_modules"
rm -rf "$dir"
git init -q "$dir"
git -C "$dir" fetch -q --depth=1 "https://github.com/$repo" "$commit"
git -C "$dir" -c advice.detachedHead=false checkout -q FETCH_HEAD
(cd "$dir" && npm ci --ignore-scripts --no-audit --no-fund --loglevel=error)
[[ -d $go/testdata/baselines/reference ]] || die "pin $1: no $go/testdata/baselines/reference"
echo "pin $1: $dir at ${commit:0:12}, $(ls "$dir/node_modules" | wc -l) node_modules"
}

# at_commit COMMIT: HEAD and the index at COMMIT, the synced files kept (the sync sends files, not git state).
at_commit() {
[[ $(git rev-parse HEAD) != "$1" ]] || return 0
git cat-file -e "$1^{commit}" 2> /dev/null || git fetch -q --depth=1 origin "$1" ||
die "commit $1 is not on origin: push it first"
git reset -q "$1"
}

cmd=$1
shift
case $cmd in
start)
(($# >= 2)) || die "usage: start RUN STEP ARGS..."
[[ ! -e $1.log ]] || die "$1.log exists"
mkdir -p "$(dirname "$1")"
# A dropped SSH session must not stop a long job (one dropped after 170 s on 2026-10-10).
setsid nohup "$0" job "$@" > /dev/null 2>&1 < /dev/null &
echo "started $1"
;;
job)
run=$1
shift
# run-testbox resets its idle timer when this file is newer than the last activity. It is in the login
# home (/home/runner), not in ~: the testbox env sets HOME=/home/theo. The keepalive ends with this
# shell, so a killed job lets the testbox end at its idle timeout and the local poll stop.
marker=$(getent passwd "$(id -u)" | cut -d: -f6)/.testbox-last-activity
(while sleep 30 && kill -0 "$$" 2> /dev/null; do touch "$marker"; done) &
keep=$!
# RUN.rc is written on every exit. It stays 1 when the job shell itself is killed.
rc=1
trap 'kill "$keep" 2> /dev/null || true; echo "$rc" > "$run.rc"' EXIT
if "$0" "$@" > "$run.log" 2>&1 < /dev/null; then rc=0; else rc=$?; fi
;;
pin)
on_testbox
(($#)) || { k=$(batch_pin); [[ -z $k ]] || set -- "$k"; }
(($#)) || echo "no batch pin in docs/typechecker-state/current.json: no pin checkout (testbox.sh pin KEY sets one up)"
for k in "$@"; do checkout "$k"; done
;;
tests)
(($# == 3)) || die "usage: tests KEY COMMIT RUN"
key=$1 commit=$2 run=$3
[[ ! -e $run ]] || die "$run exists"
# First: it refuses to run on zbook before at_commit moves HEAD.
on_testbox
at_commit "$commit"
checkout "$key"
mkdir -p "$run"
rc=0
echo "build-goport-tests.sh at ${commit:0:12} ($(date -u +%T))"
scripts/goport/build-goport-tests.sh "$PWD" "$run/testbin" > "$run/build.log" 2>&1 || rc=$?
# Not the bare DONE line: a driver log that ends in DONE or FAIL must not match it early.
tail -2 "$run/build.log" | grep -vx DONE || true
if ((rc == 0)); then
echo "goport-tests.sh at pin $key ($(date -u +%T))"
GOPORT_PIN=$key scripts/goport/goport-tests.sh "$run/testbin" "$run/tests-run" > "$run/tests.log" 2>&1 || rc=$?
grep -E '^[a-z_0-9-]+: [0-9]+ names|^incomplete:' "$run/tests.log" || true
tail -1 "$run/tests.log"
fi
# Not the test binaries, the release bins, the crates archive (tests-run/src) or the runner temp. A failed
# build leaves its cargo logs (the compiler errors) in testbin.new/logs.
(cd "$run" && for f in build.log tests.log testbin/* testbin.new/logs tests-run/*; do
case $f in testbin/relbin | tests-run/src | tests-run/tmp) continue ;; esac
if [[ -d $f || (-f $f && ! -x $f) ]]; then echo "$f"; fi
done) > "$run/pack.list"
tar -C "$run" -czf "$run/out.tar.gz" -T "$run/pack.list"
echo "packed $run/out.tar.gz ($(du -h "$run/out.tar.gz" | cut -f1)); rc $rc ($(date -u +%T))"
exit "$rc"
;;
np-suite)
(($# == 3)) || die "usage: np-suite KEY LABEL RUN"
key=$1 label=$2 run=$3
[[ ! -e $run ]] || die "$run exists"
on_testbox
checkout "$key"
mkdir -p "$run"
# Stable and not incremental, as candidate.sh builds release bins.
TS_CARGO_NIGHTLY=0 TS_CARGO_INCREMENTAL=0 scripts/run-cargo-capped.sh build --release --locked -p ts_goport --bin tsgo \
> "$run/build.log" 2>&1 || {
tail -20 "$run/build.log"
tar -czf "$run/out.tar.gz" -C "$run" build.log
die "tsgo build failed"
}
np=target/continuation-r97-goport/compat-backlog/np-suite
rc=0
NP_GO_DIR=$(pin path goCheckout "$key") scripts/goport/np-suite.sh run "$label" "$PWD/target/release/tsgo" || rc=$?
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
tar -czf "$run/out.tar.gz" -C "$run" build.log -C "$PWD/$np" "$label"
# The local copy is the record: the sticky target/ keeps no label dirs.
rm -rf "${np:?}/${label:?}"
exit "$rc"
;;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*) die "unknown command $cmd (testbox-remote.sh help)" ;;
esac
Loading
Loading