oficina / docs Public

Merge commit '40acb2db542afbb20164ff40794796127e9378a5' into native/testflight

Paul Campbell pushed by codex agent, operated by paul@keymail.dev bc1286ea3621623abfb300ef0b35fb21577e59fe parent 85859d9parent 40acb2d
11 files changed, +224 −107
  • .amadan/README.md +8 −0
  • .gitignore +1 −0
  • Makefile +23 −0
  • cmd/docs/release_test.go +20 −4
  • core/tests/wasm_trap_containment.rs +7 −35
  • internal/core/core_internal_test.go +57 −59
  • internal/core/testdata/docs_core.wasm binary
  • internal/docs/app.go +12 −1
  • internal/docs/native_stream_test.go +27 −4
  • scripts/build-release.sh +22 −2
  • scripts/build-wasm.sh +47 −2

CI passed — run details

diff --git a/.amadan/README.md b/.amadan/README.md
index 4e8ba82..fc429c4 100644
--- a/.amadan/README.md
+++ b/.amadan/README.md
@@ -73,3 +73,11 @@ fail-closed properties that matter now
development`, a fixed port, and a scratch database — one command to see
the app in a browser, signed in for real via a magic link logged to the
terminal instead of mailed.
+
+The embedded WASM is built on Linux amd64 using fixed `/work`,
+`/usr/local/cargo` and `/usr/local/rustup` paths. Rust/Cargo's crate identities
+otherwise change code ordering across hosts, even with remapped source paths.
+Linux amd64 uses unprivileged bubblewrap when available; other hosts use the
+pinned Rust Docker image. Install Docker on development Macs. Both the artifact
+builder and containment tests use this path; the freshness comparison remains
+byte-for-byte. Docker caches and writable targets are separate from source.
diff --git a/.gitignore b/.gitignore
index 0d54ff6..a5d87be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,4 @@ Thumbs.db
# Python native review/build helpers.
__pycache__/
*.pyc
+/releases/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2e12f11
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,23 @@
+# docs is script-driven, not make-driven: the gate is .amadan/ci.d/10-gate
+# and the release build is scripts/build-release.sh, which builds from a
+# `git archive` of the revision rather than from the working tree — so a
+# release can never pick up an uncommitted edit, and there is no
+# dirty-tree case to refuse.
+#
+# This Makefile exists so that `make release` means the same thing in
+# docs as it does in calendar, home, meet, memoria, sheets and slides.
+# It delegates rather than restating the build: two definitions of how
+# docs ships would drift, and the one in the script is the one CI uses.
+
+APP := docs
+RELEASE_GOOS ?= linux
+RELEASE_GOARCH ?= arm64
+RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH)
+
+.PHONY: release
+
+release:
+ @mkdir -p releases
+ GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) ./scripts/build-release.sh $(RELEASE_BIN) $(REVISION)
+ @echo "publish it with:"
+ @echo " amadan release create -repo oficina/$(APP) -notes-file NOTES.md <tag> $(RELEASE_BIN)"
diff --git a/cmd/docs/release_test.go b/cmd/docs/release_test.go
index f3d594f..a4e079d 100644
--- a/cmd/docs/release_test.go
+++ b/cmd/docs/release_test.go
@@ -15,15 +15,31 @@ import (
"time"
)
-func TestReleaseBinaryReportsItsSourceCommit(t *testing.T) {
+// TestReleaseBinaryReportsItsVersion builds the real release artifact and
+// asks the running process what it is. The failure this guards against is
+// a release that answers "dev": rastrillo's BuildVersion default, which
+// the platform's deploy check compares against the release it believes it
+// adopted — so an unstamped binary makes that check structurally unable to
+// fail while still reporting green.
+//
+// The expected value is whatever scripts/build-release.sh stamps, computed
+// the same way it computes it: the tag when this revision carries one, and
+// the abbreviated sha when it does not. Asserting the full sha instead
+// would pass only for untagged builds, which is the case that matters
+// least.
+func TestReleaseBinaryReportsItsVersion(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
- commit, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
+ described, err := exec.Command("git", "-C", root, "describe", "--tags", "--always", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
+ want := strings.TrimSpace(string(described))
+ if want == "" || want == "dev" {
+ t.Fatalf("git describe gave %q, which is not a version this test can check for", want)
+ }
dir := t.TempDir()
binary := filepath.Join(dir, "docs")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
@@ -64,8 +80,8 @@ func TestReleaseBinaryReportsItsSourceCommit(t *testing.T) {
if readErr != nil {
t.Fatal(readErr)
}
- if response.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != strings.TrimSpace(string(commit)) {
- t.Fatalf("version response %d %q; want commit %q", response.StatusCode, body, strings.TrimSpace(string(commit)))
+ if response.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != want {
+ t.Fatalf("version response %d %q; want %q", response.StatusCode, body, want)
}
return
}
diff --git a/core/tests/wasm_trap_containment.rs b/core/tests/wasm_trap_containment.rs
index 87e70aa..6732034 100644
--- a/core/tests/wasm_trap_containment.rs
+++ b/core/tests/wasm_trap_containment.rs
@@ -263,42 +263,14 @@ fn build_wasm() -> Vec<u8> {
let workspace_root = core_dir
.parent()
.expect("core/ always has a parent (the repo root)");
- // Must match scripts/build-wasm.sh's RUSTFLAGS exactly: this test
- // writes to the same `target/wasm32-unknown-unknown/release/
- // docs_core.wasm` path the gate's `cmp` checks against the committed
- // testdata copy. A cargo build here with different flags (even just
- // "none", the default before this fix) would fingerprint-mismatch
- // against build-wasm.sh's build and silently overwrite that path with
- // differently-flagged bytes -- reintroducing embedded absolute paths
- // (and failing `cmp`) any time `cargo test` runs after `build-wasm.sh`.
- let cargo_home = std::env::var("CARGO_HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| {
- PathBuf::from(std::env::var("HOME").expect("HOME must be set")).join(".cargo")
- });
- let registry_root = cargo_home.join("registry").join("src");
- let rustflags = format!(
- "--cfg getrandom_backend=\"custom\" --remap-path-prefix={}=/cargo-registry-src --remap-path-prefix={}=/docs-core",
- registry_root.display(),
- workspace_root.display(),
- );
- let status = Command::new("cargo")
- .args([
- "build",
- "--release",
- "--target",
- "wasm32-unknown-unknown",
- "--locked",
- "--lib",
- ])
- .env("RUSTFLAGS", rustflags)
- .current_dir(&core_dir)
+ // Use the same canonical host and flags as the committed artifact. Rebuild
+ // current source, without recursively linting the test that invoked us.
+ let status = Command::new("bash")
+ .args(["scripts/build-wasm.sh", "--build-only"])
+ .current_dir(workspace_root)
.status()
- .expect("failed to run cargo build for wasm32-unknown-unknown");
- assert!(
- status.success(),
- "cargo build --target wasm32-unknown-unknown failed"
- );
+ .expect("failed to run canonical wasm builder");
+ assert!(status.success(), "canonical wasm build failed");
let artifact = core_dir.join("target/wasm32-unknown-unknown/release/docs_core.wasm");
std::fs::read(&artifact).unwrap_or_else(|e| panic!("reading {artifact:?}: {e}"))
}
diff --git a/internal/core/core_internal_test.go b/internal/core/core_internal_test.go
index dfe7251..a765d70 100644
--- a/internal/core/core_internal_test.go
+++ b/internal/core/core_internal_test.go
@@ -54,6 +54,8 @@ import (
"path/filepath"
"testing"
"time"
+
+ "github.com/tetratelabs/wazero/api"
)
// rng and mutate below are a byte-for-byte port of
@@ -305,54 +307,60 @@ func TestSlowUpdateIsCutOffByGuestCallTimeout(t *testing.T) {
t.Fatalf("expected the default callTimeout (%v), got %v", defaultGuestCallTimeout, rt.callTimeout)
}
- mod, err := rt.instantiate(ctx)
- if err != nil {
- t.Fatalf("instantiate: %v", err)
- }
+ warm := func() (api.Module, uint64, []byte) {
+ t.Helper()
+ mod, err := rt.instantiate(ctx)
+ if err != nil {
+ t.Fatalf("instantiate: %v", err)
+ }
- fixtureNames := []string{"hello", "unicode", "formatted", "concurrent"}
- fixtures := make([][]byte, len(fixtureNames))
- for i, name := range fixtureNames {
- fixtures[i] = loadFixtureBytes(t, name)
- }
+ fixtureNames := []string{"hello", "unicode", "formatted", "concurrent"}
+ fixtures := make([][]byte, len(fixtureNames))
+ for i, name := range fixtureNames {
+ fixtures[i] = loadFixtureBytes(t, name)
+ }
- // Warm up: replay trials 0..1132 exactly as
- // wasm_trap_containment.rs's fuzz loop does (fixture pick, mutate,
- // doc_new, apply), ignoring the outcome of each — most are clean
- // oks or clean rejections, and this loop must not go through
- // Handle.Apply (which would auto-rebuild on any genuine trap here
- // and undo the accumulated memory growth this test depends on).
- r := &rng{x: 0xC0FFEE}
- for i := 0; i < 1133; i++ {
+ // Warm up: replay trials 0..1132 exactly as
+ // wasm_trap_containment.rs's fuzz loop does (fixture pick, mutate,
+ // doc_new, apply), ignoring the outcome of each — most are clean
+ // oks or clean rejections, and this loop must not go through
+ // Handle.Apply (which would auto-rebuild on any genuine trap here
+ // and undo the accumulated memory growth this test depends on).
+ r := &rng{x: 0xC0FFEE}
+ for i := 0; i < 1133; i++ {
+ fi := r.nextUsize(len(fixtures))
+ mutated := mutate(r, fixtures[fi])
+ docHandle, err := createDoc(ctx, rt.callTimeout, mod, fmt.Sprintf("warmup-%d", i), uint64(2_000_000+i))
+ if err != nil {
+ t.Fatalf("warm-up trial %d: doc_new failed unexpectedly: %v", i, err)
+ }
+ _ = applyOn(ctx, rt.callTimeout, mod, docHandle, mutated) // outcome irrelevant; only the memory growth matters
+ }
+
+ // Trial 1133 itself: the exact reproducer documented in
+ // core/tests/wasm_trap_containment.rs ("a 3-byte-flipped `concurrent`
+ // fixture ... ran for 163 seconds of real CPU time"). Confirm the RNG
+ // sequence still lands on the documented bytes before relying on them.
fi := r.nextUsize(len(fixtures))
mutated := mutate(r, fixtures[fi])
- docHandle, err := createDoc(ctx, rt.callTimeout, mod, fmt.Sprintf("warmup-%d", i), uint64(2_000_000+i))
- if err != nil {
- t.Fatalf("warm-up trial %d: doc_new failed unexpectedly: %v", i, err)
+ const wantHex = "02010200040107636f6e6f656e7403424293010100040107636f6e74656e74034141418a"
+ if got := hex.EncodeToString(mutated); got != wantHex {
+ t.Fatalf("RNG/mutate drifted from core/tests/wasm_trap_containment.rs: trial 1133 produced "+
+ "fixture=%s bytes=%s, want the documented bytes=%s — this test no longer exercises the "+
+ "known-slow reproducer", fixtureNames[fi], got, wantHex)
+ }
+ if fixtureNames[fi] != "concurrent" {
+ t.Fatalf("expected trial 1133 to mutate the concurrent fixture, got %s", fixtureNames[fi])
}
- _ = applyOn(ctx, rt.callTimeout, mod, docHandle, mutated) // outcome irrelevant; only the memory growth matters
- }
- // Trial 1133 itself: the exact reproducer documented in
- // core/tests/wasm_trap_containment.rs ("a 3-byte-flipped `concurrent`
- // fixture ... ran for 163 seconds of real CPU time"). Confirm the RNG
- // sequence still lands on the documented bytes before relying on them.
- fi := r.nextUsize(len(fixtures))
- mutated := mutate(r, fixtures[fi])
- const wantHex = "02010200040107636f6e6f656e7403424293010100040107636f6e74656e74034141418a"
- if got := hex.EncodeToString(mutated); got != wantHex {
- t.Fatalf("RNG/mutate drifted from core/tests/wasm_trap_containment.rs: trial 1133 produced "+
- "fixture=%s bytes=%s, want the documented bytes=%s — this test no longer exercises the "+
- "known-slow reproducer", fixtureNames[fi], got, wantHex)
- }
- if fixtureNames[fi] != "concurrent" {
- t.Fatalf("expected trial 1133 to mutate the concurrent fixture, got %s", fixtureNames[fi])
- }
+ docHandle, err := createDoc(ctx, rt.callTimeout, mod, "trial-1133", 2_000_000+1133)
+ if err != nil {
+ t.Fatalf("creating the document under test: %v", err)
+ }
- docHandle, err := createDoc(ctx, rt.callTimeout, mod, "trial-1133", 2_000_000+1133)
- if err != nil {
- t.Fatalf("creating the document under test: %v", err)
+ return mod, docHandle, mutated
}
+ mod, docHandle, mutated := warm()
// Sanity: confirm this input is genuinely slow (not just a fast trap)
// under wazero's compiler engine at this memory-growth state, with a
@@ -375,31 +383,21 @@ func TestSlowUpdateIsCutOffByGuestCallTimeout(t *testing.T) {
"it returned in %v instead — the reproducer may have stopped reproducing, which would make "+
"the capped assertion below meaningless", sanityElapsed)
}
- t.Logf("uncapped: pathological update ran %v before failing on its own (%v)", sanityElapsed, sanityErr)
-
- // The actual assertion: a fresh document on the same (already grown)
- // instance, wired through the exported Handle so this exercises the
- // real Apply()/rebuild() path, called with a plain context carrying no
- // deadline of its own — so what cuts this call off, if anything does,
- // is exactly rt.callTimeout (the default, 1s), not a contrived
- // caller-side budget. That is the point fix-round-1 finding 2 raised:
- // the previous 5s default was close enough to this input's ~4.34s
- // natural running time that the cap wasn't reliably doing anything for
- // its own motivating case. 1s should cut it off at roughly a quarter
- // of that.
- docHandle2, err := createDoc(ctx, rt.callTimeout, mod, "trial-1133b", 2_000_000+11330)
- if err != nil {
- t.Fatalf("creating the second document under test: %v", err)
- }
- handle := &Handle{rt: rt, guid: "trial-1133b", clientID: 2_000_000 + 11330, mod: mod, handle: docHandle2}
+ t.Logf("sanity probe: pathological update failed or reached its 20s bound after %v (%v)", sanityElapsed, sanityErr)
+ _ = mod.Close(ctx)
+
+ // Repeat the exact warm-up independently: a timed-out sanity probe closes
+ // its module, and a fresh but unwarmed module would lose the reproducer.
+ mod, docHandle, mutated = warm()
+ handle := &Handle{rt: rt, guid: "trial-1133", clientID: 2_000_000 + 1133, mod: mod, handle: docHandle}
oldMod := handle.mod
start := time.Now()
applyErr := handle.Apply(ctx, mutated)
elapsed := time.Since(start)
- if applyErr == nil {
- t.Fatalf("expected the capped Apply to fail (it should have been cut off before completing), got nil error after %v", elapsed)
+ if !errors.Is(applyErr, context.DeadlineExceeded) {
+ t.Fatalf("expected the capped Apply to reach its deadline, got %v after %v", applyErr, elapsed)
}
// Generous upper bound: the call must not have been allowed to run
// anywhere near its natural ~4.34s completion time.
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm
index c03208f..53decb9 100755
Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 01e27e3..ccfb4e0 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -23,6 +23,7 @@ import (
"amadan.net/oficina/home/handoff"
nativeauth "amadan.net/rastrillo/native/server"
"github.com/carlosframework/docs/internal/docs/linkstore"
+ "github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/mail"
"github.com/carlosframework/rastrillo/migrate"
@@ -492,7 +493,17 @@ func New(cfg Config) (*App, error) {
if home == nil {
return authPlugin.RequireSession(next)
}
- return home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next))
+ current := home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // A live stream retains its opening context. Require trusts that cached
+ // session, so re-read the cookie before admitting each stream write.
+ session, ok := home.bridge.sessions.From(r)
+ if !ok {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusUnauthorized)
+ return
+ }
+ current.ServeHTTP(w, sessions.WithSession(r, session))
+ })
}
nativeSessions, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.Origin, SigninPath: "/signin", Logger: logger})
if err != nil {
diff --git a/internal/docs/native_stream_test.go b/internal/docs/native_stream_test.go
index f760928..d65e355 100644
--- a/internal/docs/native_stream_test.go
+++ b/internal/docs/native_stream_test.go
@@ -14,7 +14,20 @@ func TestNativeStreamStopsAfterSessionRevocation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- owner := app.TestPerson(t, "stream@example.com")
+ authenticated := app.TestRequest(t, "stream@example.com", "GET", "/", nil)
+ assertNativeStreamRevoked(t, app, "stream@example.com", authenticated)
+}
+
+func TestNativeHomeStreamStopsAfterSessionRevocation(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ browser := f.browser()
+ browser.localSignin(t, "alice@example.com")
+ assertNativeStreamRevoked(t, f.app, "alice@example.com", browser.request("GET", "/", ""))
+}
+
+func assertNativeStreamRevoked(t *testing.T, app *App, address string, authenticated *http.Request) {
+ t.Helper()
+ owner := app.TestPerson(t, address)
doc, err := app.Store().Create(t.Context(), owner, "Live fixture")
if err != nil {
t.Fatal(err)
@@ -22,26 +35,36 @@ func TestNativeStreamStopsAfterSessionRevocation(t *testing.T) {
streamDurations(t, app, 20*time.Millisecond, time.Second)
server := httptest.NewServer(app.Handler())
defer server.Close()
- authenticated := app.TestRequest(t, "stream@example.com", "GET", "/", nil)
req, _ := http.NewRequest("GET", server.URL+"/d/"+doc.Sqid+"/stream", nil)
+ req.Host = authenticated.Host
for _, cookie := range authenticated.Cookies() {
req.AddCookie(cookie)
}
- response, err := server.Client().Do(req)
+ client := server.Client()
+ client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+ response, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
+ if response.StatusCode != http.StatusOK || response.Header.Get("Content-Type") != "text/event-stream" {
+ t.Fatalf("stream did not open: status=%d location=%q", response.StatusCode, response.Header.Get("Location"))
+ }
reader := bufio.NewReader(response.Body)
if name, _ := nextEvent(t, reader, time.Second); name != "update" {
t.Fatalf("no initial event: %s", name)
}
signout := httptest.NewRequest("POST", "/signout", nil)
+ signout.Host = authenticated.Host
signout.Header.Set("Sec-Fetch-Site", "same-origin")
for _, cookie := range authenticated.Cookies() {
signout.AddCookie(cookie)
}
- app.Handler().ServeHTTP(httptest.NewRecorder(), signout)
+ signedOut := httptest.NewRecorder()
+ app.Handler().ServeHTTP(signedOut, signout)
+ if signedOut.Code != http.StatusSeeOther {
+ t.Fatalf("signout failed: %d", signedOut.Code)
+ }
closed := make(chan error, 1)
go func() { _, err := io.Copy(io.Discard, reader); closed <- err }()
select {
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
index 1869b7e..5ecafa1 100755
--- a/scripts/build-release.sh
+++ b/scripts/build-release.sh
@@ -4,14 +4,34 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git"
test "$#" -ge 1
test "$#" -le 2
-docs_output=$(realpath -m -- "$1")
+# The output path has to be absolute before the cd into the archive
+# below, and it has to resolve even though the file does not exist yet.
+# `realpath -m` does that on Linux, where this has always run, but BSD
+# realpath has no -m, so it fails on a Mac. dirname + pwd is the same
+# thing in POSIX, once the directory is there to cd into.
+docs_output_dir=$(dirname -- "$1")
+mkdir -p -- "$docs_output_dir"
+docs_output=$(cd -- "$docs_output_dir" && pwd)/$(basename -- "$1")
cd "$(dirname "$0")/.."
docs_revision=$(git rev-parse --verify "${2:-HEAD}^{commit}")
+# What the built binary answers GET /api/version with. rastrillo's
+# BuildVersion defaults to "dev", and the platform's deploy check
+# compares that field against the release it believes it adopted — so an
+# unstamped binary makes the check incapable of failing.
+#
+# git describe --tags --always resolves to the tag when the revision
+# carries one and to the same abbreviated sha when it does not, so a
+# published release reports the name it was published under and an
+# ordinary build still says something true about itself. Name it
+# yourself for a one-off: DOCS_VERSION=v0.1.0-wip scripts/build-release.sh out
+docs_version="${DOCS_VERSION:-$(git describe --tags --always "$docs_revision")}"
docs_source=$(mktemp -d)
trap 'rm -rf -- "$docs_source"' EXIT
git archive "$docs_revision" | tar -x -C "$docs_source"
cd "$docs_source"
CGO_ENABLED=0 GOOS="${GOOS:-linux}" GOARCH="${GOARCH:-arm64}" \
go build -mod=readonly -trimpath \
- -ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=$docs_revision" \
+ -ldflags "-s -w -X github.com/carlosframework/rastrillo.BuildVersion=$docs_version" \
-o "$docs_output" ./cmd/docs
+
+echo "built $docs_output reporting version $docs_version"
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh
index 522f045..d277b38 100755
--- a/scripts/build-wasm.sh
+++ b/scripts/build-wasm.sh
@@ -8,7 +8,50 @@ cd "$(dirname "$0")/.."
# Doc::with_client_id() both draw entropy this build has no safe source
# for. A doc comment alone has been wrong, or gotten around, more than
# once; this is the actual enforcement.
-cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods
+if [[ "${1:-}" != "--build-only" && "${1:-}" != "--canonical-build" ]]; then
+ cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods
+fi
+# Rust/Cargo crate identities affect code ordering even after path remapping.
+# Build on Linux amd64 at fixed source/toolchain paths on every host.
+if [[ "${1:-}" == "--canonical-build" ]]; then
+ [[ "$(uname -s)/$(uname -m)" == "Linux/x86_64" && "$(pwd)" == /work \
+ && "$CARGO_HOME" == /usr/local/cargo && "$RUSTUP_HOME" == /usr/local/rustup ]] \
+ || { echo "build-wasm: invalid canonical build environment" >&2; exit 1; }
+ export CARGO_TARGET_DIR=/work/core/target RUSTUP_TOOLCHAIN=1.98.0
+else
+ out="core/target/wasm32-unknown-unknown/release/docs_core.wasm"
+ mkdir -p "$(dirname "$out")"
+ temporary=$(mktemp "${out}.XXXXXX")
+ trap 'rm -f "$temporary"' EXIT
+ if [[ "$(uname -s)/$(uname -m)" == "Linux/x86_64" ]] && command -v bwrap >/dev/null; then
+ cargo_home=$(cd "${CARGO_HOME:-$HOME/.cargo}" && pwd)
+ rustup_home=$(cd "${RUSTUP_HOME:-$HOME/.rustup}" && pwd)
+ mkdir -p core/target/canonical-linux-amd64
+ bwrap --tmpfs / \
+ --ro-bind /usr /usr --tmpfs /usr/local \
+ --ro-bind /bin /bin --ro-bind /lib /lib --ro-bind /lib64 /lib64 \
+ --ro-bind /etc /etc --bind /tmp /tmp --proc /proc --dev /dev \
+ --ro-bind "$(pwd)" /work \
+ --bind "$(pwd)/core/target/canonical-linux-amd64" /work/core/target \
+ --bind "$cargo_home" /usr/local/cargo --ro-bind "$rustup_home" /usr/local/rustup \
+ --chdir /work --setenv CARGO_HOME /usr/local/cargo --setenv RUSTUP_HOME /usr/local/rustup \
+ --setenv PATH /usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin \
+ bash -ec './scripts/build-wasm.sh --canonical-build >&2; cat core/target/wasm32-unknown-unknown/release/docs_core.wasm' > "$temporary"
+ else
+ command -v docker >/dev/null || { echo "build-wasm: install Docker, or bubblewrap on Linux amd64" >&2; exit 1; }
+ target_key=$(printf %s "$(pwd)" | shasum -a 256 | cut -c1-16)
+ docker run --rm --platform linux/amd64 \
+ --mount "type=bind,source=$(pwd),target=/work,readonly" \
+ --mount "type=volume,source=oficina-docs-wasm-target-${target_key},target=/work/core/target" \
+ --mount type=volume,source=oficina-docs-wasm-cargo,target=/usr/local/cargo \
+ --mount type=volume,source=oficina-docs-wasm-rustup,target=/usr/local/rustup \
+ --workdir /work \
+ rust@sha256:8aea3a845c48caac4091a8d0e6494d213061f8f014dc2f5a5f1a88a1a8c9f301 \
+ bash -ec 'rustup target add wasm32-unknown-unknown >&2; ./scripts/build-wasm.sh --canonical-build >&2; cat core/target/wasm32-unknown-unknown/release/docs_core.wasm' > "$temporary"
+ fi
+ mv "$temporary" "$out"
+ exit 0
+fi
# --lib only: the wasm module shipped to wazero is the library crate. Native
# tooling under src/bin (e.g. emit_update, used from the host side of
# wire-gate.sh) is never part of that artifact and does not define the
@@ -36,7 +79,9 @@ cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D cli
# it has to be repeated here alongside the remap flags.
registry_root="${CARGO_HOME:-$HOME/.cargo}/registry/src"
workspace_root="$(pwd)"
-export RUSTFLAGS="--cfg getrandom_backend=\"custom\" --remap-path-prefix=${registry_root}=/cargo-registry-src --remap-path-prefix=${workspace_root}=/docs-core"
+# Cargo also puts local path dependency identities into mangled debug names.
+# Remove that optional symbol section so identical code reproduces across worktrees.
+export RUSTFLAGS="-C strip=symbols --cfg getrandom_backend=\"custom\" --remap-path-prefix=${registry_root}=/cargo-registry-src --remap-path-prefix=${workspace_root}=/docs-core"
cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown --locked --lib
out="core/target/wasm32-unknown-unknown/release/docs_core.wasm"
[ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; }