Files
improvise/scripts/build-web-release.sh
Edward Langley cb6866b2c6 fix(scripts): one shell and one environment for every checker (improvise-f9qf)
`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
2026-09-22 15:50:14 -07:00

96 lines
4.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Capture the assembled site as a release artifact (improvise-oujb).
#
# `dist` runs this from the repo root as an `[[dist.extra-artifacts]]` build
# command, inside release.yml's `build-global-artifacts` job, and then uploads
# the file named here to the GitHub Release. Nothing about that job is edited
# by hand: see the comment in dist-workspace.toml.
#
# WHAT THIS IS FOR, and it is narrower than it looks. GitHub Pages keeps
# tracking `main` (user ruling, 2026-09-22: "I like Pages showing the latest
# main for now"), so this tarball is NOT what a visitor to the published site
# is running. It is a self-hostable copy of the site as of the tag, and a
# rollback target. A reader who assumes it is the deployed site will be wrong.
#
# IT BUILDS NOTHING OF ITS OWN. web/build.sh and web/assemble.sh are the same
# two scripts a developer runs and the same two ci.yml's `build` job runs; a
# release that assembled the bundle its own way would reintroduce exactly the
# drift that job's comment exists to prevent (improvise-vvl3). Everything
# below the toolchain block is those two calls and a `tar`.
#
# Usage: scripts/build-web-release.sh [OUT_TARBALL]
#
# The cache-bust token is the tag (`GITHUB_REF_NAME`), where the Pages build
# passes the commit SHA and a bare local run defaults to `dev`. A release is
# the one case where a human-readable name is available and is what a bug
# report will quote, and web/assemble.sh stamps it into the page's
# #build-version element as well as into the two bundle URLs.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
out=${1:-"$root/improvise-web.tar.gz"}
# A tag may legally contain `/` and a release.yml PR run puts `NN/merge` here,
# so the token is reduced to what is safe in a URL query and a filename.
raw=${IMPROVISE_WEB_TOKEN:-${GITHUB_REF_NAME:-dev}}
token=$(printf '%s' "$raw" | sed 's|[^A-Za-z0-9._-]|-|g')
# One home for the two pinned versions, read here and fed to $GITHUB_ENV by
# ci.yml's `build` job. Pinning is what makes a local measurement evidence
# about a CI run, and two copies of a pin is one of them going stale.
. "$root/scripts/web-toolchain-versions.env"
# The global build job carries a Rust toolchain and dist, and nothing else --
# `github-build-setup` injects only into build-local-artifacts, which is the
# wrong job for a global artifact, so the provisioning has to live here.
# Installs are Linux-only and on-demand: a developer running this on a Mac
# gets told what is missing rather than handed a linux-x64 binary.
os=$(uname -s)
need_linux() {
echo "build-web-release: $1 — install it, or run this on Linux/CI" >&2
exit 1
}
if command -v rustup > /dev/null 2>&1; then
rustup target add wasm32-unknown-unknown
fi
if ! wasm-pack --version 2>/dev/null | grep -qF "${WASM_PACK_VERSION#v}"; then
[ "$os" = Linux ] || need_linux "wasm-pack ${WASM_PACK_VERSION} not on PATH"
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
fi
wasm-pack --version
# `latest-v<major>.x` is the same resolution actions/setup-node performs for a
# bare major, so the pin means here what it means in ci.yml. SHASUMS256.txt is
# read for the filename because the directory has no stable one.
node_major=$(node --version 2>/dev/null | sed -n 's|^v\([0-9][0-9]*\).*|\1|p' || true)
if [ "${node_major:-0}" != "$NODE_VERSION" ]; then
[ "$os" = Linux ] || need_linux "node ${NODE_VERSION}.x not on PATH (found ${node_major:-none})"
base="https://nodejs.org/dist/latest-v${NODE_VERSION}.x"
file=$(curl -sSfL "$base/SHASUMS256.txt" \
| sed -n 's|.* \(node-v[0-9.]*-linux-x64\.tar\.xz\)$|\1|p')
test -n "$file" || { echo "build-web-release: no linux-x64 node in $base" >&2; exit 1; }
mkdir -p "$root/target/node"
curl -sSfL "$base/$file" | tar -xJ --strip-components=1 -C "$root/target/node"
PATH="$root/target/node/bin:$PATH"
export PATH
fi
node --version
"$root/web/build.sh"
# The site is assembled into a directory named for what it is, so extracting
# the tarball leaves one self-describing folder rather than loose files.
stage="$root/target/web-release"
rm -rf "$stage"
mkdir -p "$stage"
"$root/web/assemble.sh" "$stage/improvise-web" "$token"
rm -f "$out"
tar -czf "$out" -C "$stage" improvise-web
echo "build-web-release: wrote $out (token $token)"