feat: attach the assembled site to every release, and let the page name its build

improvise-oujb. A tagged release carried four native tarballs and nothing for
the web; the site shipped only by ci.yml's pages-deploy, on a push to main.

Pages keeps tracking main (user, 2026-09-22: "I like Pages showing the latest
main for now"), so this is a CAPTURE, not a second deployment: the tarball is a
self-hostable copy of the site as of the tag and a rollback target, not what a
visitor to the published site is running. pages-deploy is untouched.

Mechanism is dist's own [[dist.extra-artifacts]], so it survives `dist init`:
`dist plan` under the pinned 0.30.4 lists improvise-web.tar.gz beside the four
platform tarballs and `dist generate` leaves release.yml byte-identical. The
build command is scripts/build-web-release.sh, which calls web/build.sh and
web/assemble.sh -- the same two scripts ci.yml and a developer run, so there is
no second way to build the bundle. Its toolchain pins come from
scripts/web-toolchain-versions.env, which ci.yml's build job now reads too.

`github-build-setup` was measured and does NOT help: it injects into
build-local-artifacts only, which is the wrong job for a global artifact.

The page now names the build it was assembled from in #build-version, stamped
by web/assemble.sh with the same token as the bundles and verified by the same
scripts/assemble-stamp.sh gate. An unassembled tree honestly reads "unstamped".

Verified by running `dist build --artifacts=global` locally: the script ran,
the site assembled, and dist placed improvise-web.tar.gz in target/distrib.
Not verified: a real tag push, which would require cutting a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 22:37:00 -07:00
parent d17ad0feb1
commit 90ccfb0154
8 changed files with 160 additions and 4 deletions
+8 -3
View File
@@ -56,6 +56,13 @@ jobs:
workspaces: |
. -> target
harness/opfs-contract -> target
# One home for the two pins, so the release capture
# (scripts/build-web-release.sh, improvise-oujb) provisions the same
# versions rather than a second copy free to go stale. The grep keeps a
# comment or a blank line out of $GITHUB_ENV, which takes NAME=VALUE only.
- name: Pinned web toolchain versions
run: grep -E '^[A-Z_]+=' scripts/web-toolchain-versions.env >> "$GITHUB_ENV"
# Pinned release binary rather than `cargo install` (a three-minute
# source build) or a third-party action. wasm-pack runs its own bundled
# `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names
@@ -65,8 +72,6 @@ jobs:
# measured against it locally (346 KB, 120 KB gzipped). Pinning the same
# version is what makes that local run evidence about this one.
- name: Install wasm-pack
env:
WASM_PACK_VERSION: v0.15.0
run: |
set -eu
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
@@ -78,7 +83,7 @@ jobs:
# image ships one, but pinning it is what makes a local run evidence.
- uses: actions/setup-node@v4
with:
node-version: "24"
node-version: ${{ env.NODE_VERSION }}
# The same script a developer runs locally, so there is one answer to
# "how is the bundle built" rather than two that can drift. It builds all
+4
View File
@@ -60,3 +60,7 @@ GSYMS
# Python bytecode from scripts/ (interpreter-version-specific, regenerated)
__pycache__/
*.pyc
# The release web capture, written to the repo root by
# scripts/build-web-release.sh (improvise-oujb).
/improvise-web.tar.gz
+13
View File
@@ -85,6 +85,19 @@ carries prebuilt binaries for Linux (x86_64 and aarch64) and macOS (Intel and
Apple Silicon), built by `cargo dist`. The current release is the `v0.1.0-rc2`
prerelease; there is no `v0.1.0` tag yet (improvise-3tj).
Each release also carries `improvise-web.tar.gz`: the assembled browser app and
landing page, ready to serve from any static host.
### The published site and the released binaries are not the same code
Deliberately, and it is worth knowing before you file a bug. The site deploys
from `main` on every merge, so it is the latest code; a release tarball is
whatever was tagged, which may be months older. **They are different lineages.**
The web page names the build it was assembled from in its header, so quote that
rather than a release number when reporting something you saw in a browser.
`improvise-web.tar.gz` is a self-hostable copy of the site as of the tag and a
rollback target — it is not what the published site is running.
## The data model
Every cell lives at the intersection of named categories and items —
+22
View File
@@ -15,3 +15,25 @@ targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-da
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = false
# The assembled web site, attached to every release (improvise-oujb).
#
# THIS IS DIST'S OWN MECHANISM AND MUST STAY THAT WAY. `.github/workflows/
# release.yml` is autogenerated and says so on line 1; a hand edit is silently
# clobbered by the next `dist init`. Verified against dist 0.30.4, the version
# pinned above: `dist plan` lists improvise-web.tar.gz beside the four platform
# tarballs, and `dist generate` leaves release.yml byte-identical -- so there is
# nothing in the workflow for a regeneration to lose.
#
# NEXT `dist init`: keep this block. It is not generated, and `init` will not
# put it back if you drop it.
#
# The build command runs in `build-global-artifacts`, which carries a Rust
# toolchain and nothing else. `github-build-setup` does NOT help here -- it was
# measured, and it injects into `build-local-artifacts` only, which is the wrong
# job for a global artifact -- so the script provisions its own pinned wasm-pack
# and node. It builds nothing of its own: it calls web/build.sh and
# web/assemble.sh, the same two scripts ci.yml and a developer run.
[[dist.extra-artifacts]]
artifacts = ["improvise-web.tar.gz"]
build = ["sh", "scripts/build-web-release.sh"]
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
# 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
sh "$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"
sh "$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)"
+2
View File
@@ -0,0 +1,2 @@
WASM_PACK_VERSION=v0.15.0
NODE_VERSION=24
+10 -1
View File
@@ -62,6 +62,13 @@ 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}'|" \
@@ -74,8 +81,10 @@ sedi "s|'improvise_worker_server_bg\.wasm'|'improvise_worker_server_bg.wasm?v=${
# `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, four times.
# (improvise-w8ih). Its argument is FILE PATTERN, five times -- the page's own
# #build-version among them (improvise-oujb).
sh "$root/scripts/assemble-stamp.sh" \
"$app/index.html" "id=\"build-version\" title=\"Build this page was assembled from\">${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}" \
+6
View File
@@ -39,6 +39,12 @@
<span id="mode"></span>
<span id="hint"></span>
<span id="status" role="status"></span>
<!--
What this page IS: web/assemble.sh rewrites the text below to the tag
or commit it assembled from (improvise-oujb). A page served straight
out of web/ was never assembled and honestly says so.
-->
<span id="build-version" title="Build this page was assembled from">unstamped</span>
</header>
<!--