diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9c32466..66764c96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index f5ca20fe..4c96598c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index fd8bf5bc..b8ed1423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,7 +263,7 @@ filing, and before pushing docs run the audit: grep -rohE 'improvise-[a-z0-9]{3,8}\b' \ context README.md AGENTS.md CLAUDE.md crates src tests examples web harness docs \ | sort -u \ - | grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|side)$' \ + | grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|side|web)$' \ > /tmp/bd-audit-ids.txt bd show $(cat /tmp/bd-audit-ids.txt) 2>&1 \ | grep -iE 'not found|no such issue' || echo "all resolve" @@ -291,6 +291,14 @@ real defect through: exclusion list is a filter, and a filter that over-matches fails in the direction that looks like success. (Anchoring it is also what caught the example id this very paragraph originally spelled out — see the note below.) +- **A release artifact's name is ID-shaped, and `improvise-installer.sh` + escaped only by being ten characters long.** `improvise-web.tar.gz` + (improvise-oujb) is three, so it matches `{3,8}` and resolves to nothing; + `web` is therefore excluded above. The general point is that the length + bound, not any rule, is what has been keeping `dist`'s artifact names out of + this audit — so **a new artifact whose name is a short lowercase word must be + added to the exclusion list in the same commit**, or it arrives as a dead id + that no amount of `bd` archaeology explains. Note the recipe's own prose deliberately spells no live or dead id: a document that lists ids as examples makes itself a source of audit noise. diff --git a/README.md b/README.md index 75c4f69d..ef395bfe 100644 --- a/README.md +++ b/README.md @@ -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 — diff --git a/RELEASING.md b/RELEASING.md index f96814c3..68998d08 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,7 +48,19 @@ moved. Every step below exists to put that check before the irreversible one. 5. **Watch the run.** A green release takes about three minutes and publishes tarballs for all four targets with their `.sha256` files, `sha256.sum`, - `dist-manifest.json`, `source.tar.gz` and `improvise-installer.sh`. + `dist-manifest.json`, `source.tar.gz`, `improvise-installer.sh` and + `improvise-web.tar.gz` — the assembled landing page and browser app, stamped + with the tag, which the page then shows in its header (improvise-oujb). + + **`improvise-web.tar.gz` is a self-hostable copy and a rollback target, not + what the published site is serving.** GitHub Pages tracks `main` and a tag + does not deploy it, so the site and the release are deliberately different + code. Two things follow when the web half goes wrong. `release.yml` is + autogenerated, so never hand-edit it: change `dist-workspace.toml` and re-run + `dist generate`, which should leave the workflow byte-identical. And a + **pull-request** run of the Release workflow skips `build-global-artifacts`, + so a green PR says nothing about the web capture — to exercise it without + cutting a tag, run `dist build --artifacts=global` locally. The failure signature is unmistakable once you know it: **`plan` fails in about twenty seconds and the four jobs behind it skip.** That is the tag not diff --git a/dist-workspace.toml b/dist-workspace.toml index 50519790..8f15aa1d 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -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"] diff --git a/scripts/build-web-release.sh b/scripts/build-web-release.sh new file mode 100755 index 00000000..eb4c9388 --- /dev/null +++ b/scripts/build-web-release.sh @@ -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.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)" diff --git a/scripts/web-toolchain-versions.env b/scripts/web-toolchain-versions.env new file mode 100644 index 00000000..7d146661 --- /dev/null +++ b/scripts/web-toolchain-versions.env @@ -0,0 +1,2 @@ +WASM_PACK_VERSION=v0.15.0 +NODE_VERSION=24 diff --git a/web/assemble.sh b/web/assemble.sh index 94307415..6935bd80 100755 --- a/web/assemble.sh +++ b/web/assemble.sh @@ -12,7 +12,8 @@ # # Usage: web/assemble.sh [OUT_DIR] [CACHE_BUST_TOKEN] # -# The token cache-busts the two generated bundles. Pages serves everything +# 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 @@ -62,6 +63,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 +82,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\">${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}" \ diff --git a/web/index.html b/web/index.html index 2d00b2e0..5ed8239a 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,13 @@ -->

improvise

+ + unstamped diff --git a/web/style.css b/web/style.css index ce0e8b9d..e6aab1c5 100644 --- a/web/style.css +++ b/web/style.css @@ -87,6 +87,20 @@ header h1 { flex: none; } +/* Which build this is (improvise-oujb). Quietest thing on the bar -- it is + reference, never state -- but it must stay legible enough to quote. */ +#build-version { + flex: none; + /* A tag fits whole; a 40-char commit SHA truncates to a prefix long enough + to name a build, and selecting the text still copies the whole thing. */ + max-width: 14ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dim); + font-size: 0.72rem; +} + #doc-name { flex: none; /* Wide enough for the shipped document's whole title, `name (stored_as)`,