cb6866b2c6
`scripts/pr-reviews-test.sh` passed 19/19 locally and 2/19 in CI on the same commit, because a multi-assignment `local` is not left-to-right on bash 5 while macOS's bash 3.2 tolerates it. The obvious half of the fix is pinning the shell. The half that decides the design is not. .envrc DOES already put a nix bash 5.3.15 on PATH, which is CI's semantics -- and it is not enough. `pkgs.mkShell` exports `name`, `out`, `outputs`, `system`, `shell`, `builder`, `stdenv`, `phases` and about twenty more generically-named variables that CI does not have, so a script reading one by accident under `set -u` is SILENT in the dev shell and fatal in CI. That is exactly what happened: the broken clause read `$name`, and in the dev shell `$name` was `nix-shell-env`. Measured at 051f541^ on one machine: macOS /bin/bash 3.2 19 passed, 0 failed direnv exec . ./scripts/pr-reviews-test.sh 19 passed, 0 failed the same, with `name` unset 2 passed, 17 failed Two reasons for green, one of them the correct interpreter. A dev shell is a third environment, not a reproduction of CI. A nix shebang was measured and declined, on availability rather than speed: warm, `nix shell nixpkgs#bash --command true` is 61.8ms against an ambient 5.1ms, and `nix-shell -p bash --run true` -- the manpage form -- is 675.2ms, 132x, inside git's merge machinery for beads-merge-driver.sh. But only the `kb` job installs nix; build, test, wasm, js, harness-lint and browser have none, so a nix shebang needs an installer step on three more jobs to reach a bash those runners already ship. zsh is not a shebang change and the runner does not have it either. Both declined in context/code/tooling.md section 5. What lands instead pins interpreter and environment together, in one place: scripts/checkers.sh is the one entry point for every check_the_checker, used verbatim by CI and by the local gate. It pins a bash >= 5 (ambient first, nix only when the ambient one is older, exit 3 when neither), scrubs the environment to an allowlist, and refuses any shell shebang in scripts/ that is not `#!/usr/bin/env bash`. The pinned bash goes on the front of PATH so inner invocations get it too -- which is where the fault actually lived. The second undetected split is closed with it: the four `#!/bin/sh` and one `#!/usr/bin/env sh` scripts are now bash, and the six places that ran a script through an explicit `sh` -- overriding the callee's own shebang, which is the same disease -- now invoke it directly. Verification, injected against the real script: restoring pr-reviews' multi-assignment `local` leaves `sh scripts/pr-reviews-test.sh` at 22/22 and `direnv exec . ./scripts/pr-reviews-test.sh` at 22/22, while `./scripts/checkers.sh pr-reviews` reports 2 passed, 20 failed, exit 1 -- matching the 2/19 CI actually reported. scripts/checkers-test.sh is twelve cases, each injecting rather than asserting; the pair that carries it runs one fixture both ways, so the `set -u` case is a test of the runner and not of `set -u`. The old local gate was `for t in scripts/*-test.sh; do sh "$t"; done`, which overrides every shebang with /bin/sh -- so it ran every suite under bash 3.2 in sh-mode regardless of what the file said. It is replaced by `./scripts/checkers.sh`. Workspace suite unchanged at 1689 passing over 38 binaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
113 lines
5.6 KiB
Bash
Executable File
113 lines
5.6 KiB
Bash
Executable File
#!/bin/sh
|
|
# Assemble the static site into `site/`: the landing page from `docs/` at the
|
|
# root, and the browser app from `web/` under `app/`.
|
|
#
|
|
# `web/` is the *source* directory: it also holds build.sh, assemble.sh and
|
|
# .gitignore, which have no business being served. This copies what ships.
|
|
#
|
|
# Extracted from the CI workflow so that the Pages job and the browser harness
|
|
# assemble the same tree — a harness that runs against a different arrangement
|
|
# of files is testing something nobody deploys (improvise-vvl3). Same reason
|
|
# `web/build.sh` exists rather than two copies of the wasm-pack invocation.
|
|
#
|
|
# Usage: web/assemble.sh [OUT_DIR] [CACHE_BUST_TOKEN]
|
|
#
|
|
# The token cache-busts the two generated bundles and names the build in the
|
|
# page's own #build-version (improvise-oujb). Pages serves everything
|
|
# `max-age=600` and the wasm-pack filenames carry no content hash, so for ten
|
|
# minutes after a deploy a returning visitor runs the PREVIOUS build — which
|
|
# during improvise-9x6 debugging made a shipped fix look like it had not
|
|
# shipped, twice. CI passes the commit SHA; a local run defaults to `dev`.
|
|
#
|
|
# Both halves are stamped, because a fresh glue file does NOT pull a fresh
|
|
# .wasm: the glue locates it with `new URL('..._bg.wasm', import.meta.url)`,
|
|
# and a URL constructed against a base does not inherit the base's query
|
|
# string. Stamping only the import specifier would refresh the wrapper and
|
|
# keep the stale binary — the failure mode this is here to end.
|
|
set -eu
|
|
|
|
root=$(cd "$(dirname "$0")/.." && pwd)
|
|
out=${1:-"$root/site"}
|
|
token=${2:-dev}
|
|
|
|
test -d "$root/web/pkg" || { echo "web/pkg missing — run web/build.sh first" >&2; exit 1; }
|
|
test -d "$root/web/worker-pkg" || { echo "web/worker-pkg missing — run web/build.sh first" >&2; exit 1; }
|
|
|
|
rm -rf "$out"
|
|
mkdir -p "$out"
|
|
|
|
# The landing page is the front door and the app is what it sends you to
|
|
# (user ruling, improvise-kh8). So `docs/` lands at the ROOT and the app moves
|
|
# under `app/` whole — every path in app.js and worker.js is relative, so the
|
|
# bundles travel with it and the generated glue needs no editing.
|
|
cp -f "$root/docs/index.html" "$out/index.html"
|
|
cp -rf "$root/docs/casts" "$out/casts"
|
|
|
|
app="$out/app"
|
|
mkdir -p "$app"
|
|
# shell.js and serial.js are imported by app.js and worker.js: leaving either
|
|
# out is a page that fails to load its own module graph, not a degraded one.
|
|
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/shell.js" \
|
|
"$root/web/serial.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
|
|
# The document the page opens on a browser that has never seen one.
|
|
# web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust,
|
|
# which writes them through the VFS — so leaving it out is an empty grid, not
|
|
# a 404 the page could report.
|
|
cp -f "$root/web/demo.improv" "$app/"
|
|
|
|
cp -rf "$root/web/pkg" "$app/pkg"
|
|
cp -rf "$root/web/worker-pkg" "$app/worker-pkg"
|
|
rm -f "$app/pkg/.gitignore" "$app/pkg/package.json"
|
|
rm -f "$app/worker-pkg/.gitignore" "$app/worker-pkg/package.json"
|
|
|
|
# BSD sed (macOS) needs an argument to -i; GNU sed must not be given one.
|
|
sedi() { sed "$1" "$2" > "$2.tmp" && mv -f "$2.tmp" "$2"; }
|
|
|
|
# The page must be able to say what it is: a site that cannot name its own
|
|
# build makes every bug report about it unanswerable (improvise-oujb). This is
|
|
# the same token the bundles are stamped with, and it is verified below on the
|
|
# same terms, so an unstamped page is a refusal rather than a silent `unstamped`
|
|
# on the front door.
|
|
sedi "s|>unstamped<|>${token}<|" "$app/index.html"
|
|
|
|
sedi "s|\./pkg/improvise_wasm_client\.js|&?v=${token}|" "$app/app.js"
|
|
sedi "s|\./worker-pkg/improvise_worker_server\.js|&?v=${token}|" "$app/worker.js"
|
|
sedi "s|'improvise_wasm_client_bg\.wasm'|'improvise_wasm_client_bg.wasm?v=${token}'|" \
|
|
"$app/pkg/improvise_wasm_client.js"
|
|
sedi "s|'improvise_worker_server_bg\.wasm'|'improvise_worker_server_bg.wasm?v=${token}'|" \
|
|
"$app/worker-pkg/improvise_worker_server.js"
|
|
|
|
# One `grep` over all four files succeeds when ANY one of them matches, so
|
|
# three failed substitutions could still produce a "successful" assembly under
|
|
# `set -e` (found by Copilot on PR #24). Each stamped reference is therefore
|
|
# verified on its own, by scripts/assemble-stamp.sh -- which is a separate
|
|
# script so its refusals can be exercised without fabricating a web/pkg
|
|
# (improvise-w8ih). Its argument is FILE PATTERN, five times -- the page's own
|
|
# #build-version among them (improvise-oujb).
|
|
"$root/scripts/assemble-stamp.sh" \
|
|
"$app/index.html" "id=\"build-version\">${token}<" \
|
|
"$app/app.js" "pkg/improvise_wasm_client.js?v=${token}" \
|
|
"$app/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}" \
|
|
"$app/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}" \
|
|
"$app/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"
|
|
|
|
# Every relative import the two entry points make must have been copied. The
|
|
# cp list above is a second answer to "what ships", and a missing sibling is a
|
|
# 404 at module-load time, which the page cannot report.
|
|
for entry in app.js worker.js; do
|
|
deps=$(sed -n 's|.*from "\./\([A-Za-z0-9_.-]*\.js\)".*|\1|p' "$app/$entry")
|
|
for dep in $deps; do
|
|
test -f "$app/$dep" || {
|
|
echo "assemble: $entry imports ./$dep, which was not copied" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
done
|
|
|
|
# The prose site at `docs/`, generated from context/ by the same script the
|
|
# harness serves. It runs LAST because its link check verifies the links that
|
|
# leave its own tree (`../index.html`, `../app/`) against the filesystem, which
|
|
# means their targets must already be here. It exits non-zero on a break, so a
|
|
# dead cross-reference fails the assembly rather than shipping.
|
|
node "$root/scripts/build-doc-site.mjs" "$out/docs" "$token"
|