#!/usr/bin/env bash # The only CI this repo has. Everything a branch must pass before it can # squash-merge, in one script so CI and what you run locally are one # definition rather than two that drift. set -euo pipefail # rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd # user unit and is not on the PATH of a login shell when rustup was run # with --no-modify-path. Source it here rather than depending on whoever # configured the runner having 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; } export CGO_ENABLED=0 export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git" # 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 # fixtures/package.json, so the installed yjs is exactly the pinned 13.6.27 # — no silent drift from whatever a bare `npm install` would pick. echo "=== fixtures: node dependencies" (cd fixtures && npm ci) # save-queue.mjs is the editor's retry/drop logic, factored out so it # runs under plain Node with no DOM/browser needed (fixtures/ # save-queue.mjs's own doc comment). This is fix round 1's regression # test for F1 — a permanently-rejected batch used to retry forever and # absorb every later edit, losing all of it silently — and it belongs # in the gate, not just something run by hand once, or a future # regression here goes uncaught the same way F1 itself did. echo "=== fixtures: the save queue's retry/drop logic (F1 regression)" (cd fixtures && node --test save-queue.test.mjs) # fix round 2's blocking finding: a resync that failed once used to # give up forever, silently recreating F1's own data loss for the # rest of the session (resync-controller.mjs's own doc comment has # the full mechanism). Same reasoning as the F1 test above — this # belongs in the gate, not just something run by hand once. echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regression)" (cd fixtures && node --test resync-controller.test.mjs) # live-updates.mjs owns the cross-tab cursor and the stream's # connection policy, and two of its tests are regressions for defects # caught in review before the code existed: a cursor that advances # over a gap skips rows permanently, and an idle-close whose only # reopen triggers are visibility/focus/edit leaves a tab that is # merely being READ permanently stale. Both fail silently in a # browser — the document still saves, it just stops learning — so # they belong in the gate rather than in a manual check nobody # repeats. echo "=== fixtures: the live-update cursor and connection policy" (cd fixtures && node --test live-updates.test.mjs) # fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no # banners, no timestamps, no absolute paths baked into the output, and # esbuild's actual work happens in a pinned native binary (0.28.2), # not interpreted JS, so the Node version running it does not enter # into the result either. Five consecutive runs on this one runner # produce identical sha256, which is all a freshness check ever # needs — a weaker bar than cross-machine reproducibility, and # already met. Rebuilding here and diffing what's committed mirrors # "the embedded wasm must match a fresh build" below, for the same # reason that check is safe rather than randomly red. # align.mjs is the editor's half of paragraph alignment — the schema # attribute, the command and the active-state reader — factored out of # editor-entry.js and toolbar.mjs so it runs under plain Node with no # DOM, exactly like the three modules above. It belongs in the gate for # the reason its own tests spell out: the class names and attribute # values in it are the same vocabulary as core/src/fragment.rs's # ALIGNMENTS and internal/export/pdf.go's alignOf, which are separate # binaries that cannot import it, and both of the mistakes it guards # (an alignment nobody chose written into the document, a command that # edits when it was only being asked a question) fail silently in a # browser. echo "=== fixtures: paragraph alignment in the editor" (cd fixtures && node --test align.test.mjs) # fonts.mjs is the font vocabulary — the three faces, their marks, and # which of them an unmarked run is in. That last one is the reason this # is in the gate: the default face is stated in four places that cannot # import each other (this module, views/show.html's [docs-text] rule, # internal/export/pdf.go's styleFor and internal/fonts' Default), and a # toolbar that disagrees with the other three fails completely silently # — the control names a face the text is not in, and choosing that face # does nothing because it is already what the run has no mark for. # fonts.mjs also runs a load-time guard that only executes because this # test imports it, the same arrangement align.test.mjs has with # toolbar.mjs's. echo "=== fixtures: the font vocabulary and its default" (cd fixtures && node --test fonts.test.mjs) # paste.mjs decides what a blank line pasted out of Google Docs means. # It is in the gate because the mistake it is one edit away from is # invisible until someone pastes: turning EVERY hard_break into a # paragraph break would destroy Shift+Enter, which editor-entry.js binds # and core/src/fragment.rs renders, and it would do so only on the paste # path — which had no test at all before this file. Like the four above # it is DOM-free: transformPasted takes a Slice and returns a Slice. echo "=== fixtures: what a pasted blank line means" (cd fixtures && node --test paste.test.mjs) # indent.mjs is the second block attribute's vocabulary — the value is a # NUMBER where alignment's is a string, so where align.test.mjs above # checks that a closed table is consulted, this checks a clamp. The # cases that matter are the ones no editor produces and # POST /d/{sqid}/updates accepts anyway: a level past the maximum, a # zero, a value that is not a number. core/src/fragment.rs holds the # same three in Rust, independently, so a document cannot indent one # distance on screen and another on paper. echo "=== fixtures: the indent vocabulary and its clamp" (cd fixtures && node --test indent.test.mjs) # sizes.mjs is the point ladder, and importing it here is what makes its # own module-load guard run in the gate — the same arrangement align and # fonts already have, for the reason their comments give: a guard that # only executes when a browser mounts the editor is dead code in CI, so # a drifted vocabulary would pass everything and then throw for every # user. The legacy four-step marks are the part most worth holding: they # are no longer offered and must go on being understood forever, because # documents in the wild carry them. echo "=== fixtures: the point ladder, and the four-step scale it replaced" (cd fixtures && node --test sizes.test.mjs) # autoformat.mjs turns "- " into a bullet, and the Backspace that takes # it back is the whole reason the feature is tolerable rather than # infuriating. Both halves are in the gate because both are invisible # until someone types: a rule that fires where it should not, and a # revert that does not give back the characters, each look like a broken # editor and neither shows up in any Go test. DOM-free — ProseMirror's # state, model and inputrules packages are all headless, which matters # because Chromium will not start in this project's sandbox, so a rule # tested only through the browser drive is tested nowhere most of the # time. echo "=== fixtures: the autoformat rules, and the Backspace that undoes one" (cd fixtures && node --test autoformat.test.mjs) # A word count is a number people quote to each other, so being quietly # wrong is worse here than in most places. The cases that matter are the # ones whitespace-splitting gets wrong — punctuation, and the languages # that put no space between words at all, where splitting on whitespace # reports 1 for a whole sentence. echo "=== fixtures: counting words, including where there are no spaces" (cd fixtures && node --test wordcount.test.mjs) # menu.mjs's enabling logic, against a forty-line fake DOM. # # In the gate because its absence already shipped a bug: sync read the # module constant where it should have read the merged table, so every # command the caller passed in — paged, infinite, word count, the # shortcuts sheet — was disabled on every update and could never be # clicked. Three deliverables unreachable, and nothing saw it: the Go # tests check the model and the markup, and the gate cannot open a # browser. The first test in this file fails against that exact edit. echo "=== fixtures: which menu items the editor enables" (cd fixtures && node --test menu.test.mjs) # Both bars of the editor's chrome, against the same kind of recording # fake DOM. One plugin drives them in the browser (the toolbar's, with # the menu on its onSync) and this drives them the same way. # # In the gate because its absence shipped a visible flash and would ship # it again. Both bars sync on every ProseMirror state update, and both # used to rewrite every control's state unconditionally; setAttribute # with the value already there still invalidates style and still records # a mutation, and so does setting a reflected boolean property to the # value it already holds. So the chrome repainted once per keystroke, # and a tab getting its frames back paid for the whole backlog in one # visible frame — 23 mutations measured on reveal, 21 of them redundant # attribute writes. # # Held against the source as well as against the controls that exist # today, because the failure mode is a control added later: the count # went from 12 to 23 across the toolbar redesign, and then menu.mjs, # written afterwards by a different session, reproduced the whole defect # from scratch. Five of the 23 were its. echo "=== fixtures: the editor's chrome writes only what changed" (cd fixtures && node --test toolbar.test.mjs) echo "=== the editor bundle must match a fresh build" (cd fixtures && node build-bundles.mjs) if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2 # The rebuild above just mutated a committed directory as a side # effect of checking it. Restore it to what's actually committed — # tracked changes via checkout, any new hashed file the rebuild # wrote via clean — so the only trace of this failure is the # message and exit code above, not a stray dirty file a later # command (or a re-run of this same script) trips over, or a # rebuilt-to-fresh tree that would silently pass a second run # without anyone having committed anything. git checkout -- internal/docs/static/ git clean -fd -- internal/docs/static/ >/dev/null exit 1 fi echo "=== rust: fmt" cargo fmt --manifest-path core/Cargo.toml --check # build-wasm.sh is the ONLY clippy invocation in this gate (it runs # --all-targets -D warnings -D clippy::disallowed_methods) and it runs # BEFORE cargo test, for two reasons that used to be two separate bugs: # # 1. A second, looser clippy invocation here previously let a banned # method planted in a test file sail through this fast check and get # caught only ~40s later, inside build-wasm.sh's own clippy run -- # defeating the point of failing fast. One invocation, no drift. # 2. core/tests/no_host_imports.rs asserts zero wasm imports by reading # the wasm32 artifact off disk; it only exists after this line. Running # cargo test first made the assertion's own presence into a race — # it silently reported PASS-via-SKIP unless some other test happened # to build the artifact as a side effect first. Building here removes # the race instead of hoping to win it. echo "=== wasm: clippy, then the artifact the host embeds" ./scripts/build-wasm.sh echo "=== rust: build, test" # DOCS_REQUIRE_WASM=1 turns core/tests/no_host_imports.rs's "artifact # missing" branch into a failure instead of a skip. It is always set here # because the wasm artifact was just built above; unset, a bare local # `cargo test` still skips gracefully on a fresh clone. export DOCS_REQUIRE_WASM=1 cargo build --release --manifest-path core/Cargo.toml cargo test --manifest-path core/Cargo.toml echo "=== the embedded wasm must match a fresh build" if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2 exit 1 fi echo "=== wire compatibility, both directions" ./scripts/wire-gate.sh echo "=== go: vet, test, gofmt" go vet ./... # The browser checks (internal/docs/*_browser_test.go) need a Chromium, so # the gate does not run them; it vets them, so a test behind a tag that # nothing compiles cannot rot silently. go vet -tags browser ./internal/docs/ go test ./... -count=1 test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; } echo "=== go: build ./... (the app, not just the packages tests import)" # go vet/go test above already type-check every package they touch, # but neither one proves cmd/docs itself links into a runnable binary # under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) — # a plain build is the only step that actually does. CGO_ENABLED is # already exported above; stated again here so this line reads # correctly on its own. CGO_ENABLED=0 go build ./... # There used to be a further step here: build cmd/docs with -tags # release and assert the result refuses to start with -dev-auth on # its command line, proving a release binary had no code path that # could ever turn the development-only stand-in identity on # (cmd/docs/devauth_flag_release.go). Fix round 1 on Task 1 deleted # that identity outright — internal/docs/devauth.go had zero readers # once rastrillo/auth's RequireSession started guarding every # document route unconditionally, with no flag anywhere capable of # disabling it — so the `release` build tag it was the only user of # is gone too, and there is no longer a release/non-release # distinction for this gate to hold apart. The property that check # guarded (an unauthenticated binary reachable at a real URL) is now # covered a different way: go test above runs # TestNewRefusesMissingInstanceKeyOutsideDevelopment and # TestNewRefusesMissingMailOutsideDevelopment (internal/docs/auth_test.go), # proving Env other than "development" still fails closed on its own # fail-closed configuration, and TestSignInRequiredForEverything # proves every document route — GET and POST alike — refuses a # signed-out request. A stray "-dev-auth" on any build's command line # today reaches rastrillo.Resolve's own flag.FlagSet undefined, and is # refused exactly like any other unrecognized flag. echo "gate: ok"