#!/usr/bin/env bash # The only CI this repo has. Everything a branch must pass before it can # squash-merge, in one script, so what the runner executes and what you # run before pushing are one definition rather than two that drift. set -euo pipefail # Run from the repo root whatever the caller's working directory is: # .amadan/ci execs this file, an amadan runner with step support runs it # directly, and you will run it by hand from wherever you happen to be. cd "$(dirname "$0")/../.." # rustup installs to ~/.cargo/bin, which is on neither a systemd user # unit's PATH nor a login shell's when rustup ran with --no-modify-path. # Source it here rather than trusting whoever set the runner up to have # got it right — a gate that only works on one machine is not a gate. [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" for tool in cargo rustc rustfmt go node npm; do command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; } done rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; } # The stack is cgo-free by design, here as in scripts/run-dev.sh and the # Makefile. A build that quietly used cgo would not be the binary that # ships to an arm64 instance. export CGO_ENABLED=0 export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git" # Two of the steps below regenerate a committed artifact and then require # the tree to be unchanged. Both have the same shape, so they share one # definition: # # git diff alone is not enough. It sees a tracked file that changed or # was deleted, and never the NEW file a changed content hash writes # beside the old one — so a rebuild whose bytes moved leaves both a # deletion and an untracked file, and checking only one of them lets # half of that through. # # On failure the TRACKED files are put back and the untracked ones # are left where they are, listed. The check mutated a committed # directory as a side effect of reading it, and leaving a modified # tracked file behind would make a second run of this same script # pass with nobody having committed anything — the worst possible # outcome for a freshness check. But an untracked file here is # exactly the one the developer has to commit next (a vector for a # new data file, the bundle under its new hash), and deleting it — # as this once did — threw away a freshly generated artifact before # anybody could `git add` it. So it stays, and the message names it. require_committed() { local path=$1 fix=$2 ok=1 # --name-status, not a content diff: both directories hold minified # or binary artifacts, and the useful answer is which file moved, not # 371 KB of bundle in the runner's log. git diff --exit-code --name-status -- "$path" || ok=0 if [ -n "$(git status --porcelain -- "$path")" ]; then git status --porcelain -- "$path" >&2 ok=0 fi if [ "$ok" -eq 0 ]; then echo "gate: $fix" >&2 echo "gate: stale or uncommitted under $path (M = restored to the committed version, ?? = left for you to commit):" >&2 git status --porcelain -- "$path" >&2 git checkout -- "$path" exit 1 fi } echo "=== rust: fmt" # --manifest-path, because there is no workspace at the repo root: the # crate lives in core/ and a bare `cargo fmt` here finds no Cargo.toml. cargo fmt --manifest-path core/Cargo.toml --check echo "=== rust: clippy" # clippy.toml's disallowed-methods is the real enforcement of the # Doc-construction rule that core/src/lib.rs documents: Doc::new() and # Doc::with_client_id() both draw entropy this build has no safe source # for, and a doc comment alone has been got around before. # # scripts/build-wasm.sh below runs this same invocation, flag for flag, # before it builds. That is deliberate: it fails here in a few seconds # rather than forty seconds into the wasm build, and because the two # spellings are identical there is no looser check to sail through — # the drift Docs' gate hit when its two clippy runs disagreed. cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods echo "=== wasm: the artifact the host embeds" # Before cargo test, not after. core/tests/no_host_imports.rs asserts the # module imports nothing from the host by reading the wasm32 artifact off # disk, and that file only exists once this line has run. Run the tests # first and the assertion's own presence becomes a race it reports as a # skip. ./scripts/build-wasm.sh echo "=== the embedded wasm must match a fresh build" if ! cmp -s core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm; then echo "gate: internal/slides/core/testdata/slides_core.wasm does not match a fresh build — rebuild and commit the wasm:" >&2 echo " ./scripts/build-wasm.sh && cp core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm" >&2 exit 1 fi echo "=== rust: test" # SLIDES_REQUIRE_WASM=1 turns no_host_imports.rs's "artifact missing" # branch from a skip into a failure. Safe to set unconditionally here # because the build above just produced it; left unset, a bare local # `cargo test` on a fresh clone still skips rather than forcing a wasm # build first. SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/Cargo.toml # The PDF module (core/pdf, hosted by internal/slides/pdfcore) is a # second crate with the same shape and the same four steps, in the same # order and for the same reasons as the CRDT core's above: fmt, clippy # (the exact invocation scripts/build-pdf-wasm.sh runs, so nothing # looser sails through), build the artifact BEFORE its own # no_host_imports test reads it, and the byte comparison against the # committed copy go:embed actually ships. A stale slides_pdf.wasm is # the same trap as a stale slides_core.wasm: the Go tests pass against # whatever is committed, and only this cmp knows the difference. echo "=== rust: fmt (pdf)" cargo fmt --manifest-path core/pdf/Cargo.toml --check echo "=== rust: clippy (pdf)" cargo clippy --manifest-path core/pdf/Cargo.toml --all-targets -- -D warnings echo "=== wasm: the pdf artifact the host embeds" ./scripts/build-pdf-wasm.sh echo "=== the embedded pdf wasm must match a fresh build" if ! cmp -s core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm internal/slides/pdfcore/testdata/slides_pdf.wasm; then echo "gate: internal/slides/pdfcore/testdata/slides_pdf.wasm does not match a fresh build — rebuild and commit the wasm:" >&2 echo " ./scripts/build-pdf-wasm.sh && cp core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm internal/slides/pdfcore/testdata/slides_pdf.wasm" >&2 exit 1 fi echo "=== rust: test (pdf)" SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/pdf/Cargo.toml echo "=== fixtures: node dependencies" # The versions are printed rather than pinned. What Node is only matters # as a floor — 20+, for `node --test` and the ESM loading this tree # assumes — because yjs and the rest come from the lockfile and esbuild's # actual work happens inside a pinned native binary. node --version npm --version # The runner clones a fresh worktree per branch, so fixtures/node_modules # (gitignored, like any node_modules) never carries over from a previous # run. npm ci reads fixtures/package-lock.json rather than resolving from # package.json, so the installed yjs is exactly the pinned 13.6.27 and not # whatever a bare `npm install` would pick today. # # NPM_CACHE exists because the cache directory is the one part of this # that is not the same everywhere. The runner's HOME is writable and # $HOME/.npm is right there; a sandboxed local run may have neither, and # npm then fails with an EROFS on its cache rather than with anything to # do with the install. Point NPM_CACHE somewhere writable in that case. echo "=== fixtures: npm ci (needs the registry — see .amadan/README.md)" (cd fixtures && npm ci --cache "${NPM_CACHE:-$HOME/.npm}") echo "=== fixtures: the JS side's own tests" # From fixtures/ with an explicit glob, never `node --test fixtures/`: # Node 24 takes the directory argument as a test file, runs nothing and # exits 0, which is a green-looking way to run no tests at all. (cd fixtures && node --test *.test.mjs) echo "=== the vectors must match a fresh generation" # fixtures/vectors is the contract between the two implementations of the # op vocabulary (spec §2.8): generate.mjs builds each deck through # ops.mjs and writes the Y update plus the JS side's view of it, and # internal/slides/core/vectors_test.go — run by `make ci` below — loads # the same bytes through the Rust core and requires the same view. # # A vector nobody regenerated is the failure this catches, and it is a # silent one: the Go test still passes, because it is comparing the core # against a JS implementation that no longer exists. Both sides change or # neither does. (cd fixtures && node generate.mjs) require_committed fixtures/vectors \ "vectors are stale: run node fixtures/generate.mjs and commit" echo "=== the editor bundle must match a fresh build" # build-bundles.mjs is byte-reproducible: no sourcemap, no banner, no # timestamp, no absolute path in the output, and esbuild's work happens # in the pinned 0.28.2 native binary rather than in interpreted JS, so # the Node running it does not enter into the result either. # # This is the same check as the wasm cmp above and it is here for the # same reason: nothing builds the bundle at run time, the artifact is # committed, and a server handed a stale one serves it perfectly happily. # assets_embed.go's exactly-one rule makes a leftover hashed file a panic # at boot rather than a wrong editor, which is why the untracked half of # require_committed matters here too. node fixtures/build-bundles.mjs require_committed internal/slides/static \ "the editor bundle is stale: run node fixtures/build-bundles.mjs and commit internal/slides/static" echo "=== go: vet, gofmt, test, migration check" # The Makefile's own ci target, so the Go half of this gate and the # `make ci` you run between edits cannot come apart. make ci echo "gate: ok"