oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env bash
2# The only CI this repo has. Everything a branch must pass before it can
3# squash-merge, in one script so CI and what you run locally are one
4# definition rather than two that drift.
5set -euo pipefail
6
7# rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd
8# user unit and is not on the PATH of a login shell when rustup was run
9# with --no-modify-path. Source it here rather than depending on whoever
10# configured the runner having got it right — a gate that only works on one
11# machine is not a gate.
12[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
13
14for tool in cargo rustc rustfmt go node npm; do
15 command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; }
16done
17rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \
18 || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; }
19
20export CGO_ENABLED=0
21export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git"
22
23# The runner clones a fresh worktree per branch, so fixtures/node_modules
24# (gitignored, like any node_modules) never carries over from a previous
25# run. npm ci reads fixtures/package-lock.json rather than resolving from
26# fixtures/package.json, so the installed yjs is exactly the pinned 13.6.27
27# — no silent drift from whatever a bare `npm install` would pick.
28echo "=== fixtures: node dependencies"
29(cd fixtures && npm ci)
30
31# save-queue.mjs is the editor's retry/drop logic, factored out so it
32# runs under plain Node with no DOM/browser needed (fixtures/
33# save-queue.mjs's own doc comment). This is fix round 1's regression
34# test for F1 — a permanently-rejected batch used to retry forever and
35# absorb every later edit, losing all of it silently — and it belongs
36# in the gate, not just something run by hand once, or a future
37# regression here goes uncaught the same way F1 itself did.
38echo "=== fixtures: the save queue's retry/drop logic (F1 regression)"
39(cd fixtures && node --test save-queue.test.mjs)
40
41# fix round 2's blocking finding: a resync that failed once used to
42# give up forever, silently recreating F1's own data loss for the
43# rest of the session (resync-controller.mjs's own doc comment has
44# the full mechanism). Same reasoning as the F1 test above — this
45# belongs in the gate, not just something run by hand once.
46echo "=== fixtures: the resync retry/backoff state machine (fix round 2 regression)"
47(cd fixtures && node --test resync-controller.test.mjs)
48
49# live-updates.mjs owns the cross-tab cursor and the stream's
50# connection policy, and two of its tests are regressions for defects
51# caught in review before the code existed: a cursor that advances
52# over a gap skips rows permanently, and an idle-close whose only
53# reopen triggers are visibility/focus/edit leaves a tab that is
54# merely being READ permanently stale. Both fail silently in a
55# browser — the document still saves, it just stops learning — so
56# they belong in the gate rather than in a manual check nobody
57# repeats.
58echo "=== fixtures: the live-update cursor and connection policy"
59(cd fixtures && node --test live-updates.test.mjs)
60
61# fixtures/build-bundles.mjs is byte-reproducible: no sourcemaps, no
62# banners, no timestamps, no absolute paths baked into the output, and
63# esbuild's actual work happens in a pinned native binary (0.28.2),
64# not interpreted JS, so the Node version running it does not enter
65# into the result either. Five consecutive runs on this one runner
66# produce identical sha256, which is all a freshness check ever
67# needs — a weaker bar than cross-machine reproducibility, and
68# already met. Rebuilding here and diffing what's committed mirrors
69# "the embedded wasm must match a fresh build" below, for the same
70# reason that check is safe rather than randomly red.
71# align.mjs is the editor's half of paragraph alignment — the schema
72# attribute, the command and the active-state reader — factored out of
73# editor-entry.js and toolbar.mjs so it runs under plain Node with no
74# DOM, exactly like the three modules above. It belongs in the gate for
75# the reason its own tests spell out: the class names and attribute
76# values in it are the same vocabulary as core/src/fragment.rs's
77# ALIGNMENTS and internal/export/pdf.go's alignOf, which are separate
78# binaries that cannot import it, and both of the mistakes it guards
79# (an alignment nobody chose written into the document, a command that
80# edits when it was only being asked a question) fail silently in a
81# browser.
82echo "=== fixtures: paragraph alignment in the editor"
83(cd fixtures && node --test align.test.mjs)
84
85# fonts.mjs is the font vocabulary — the three faces, their marks, and
86# which of them an unmarked run is in. That last one is the reason this
87# is in the gate: the default face is stated in four places that cannot
88# import each other (this module, views/show.html's [docs-text] rule,
89# internal/export/pdf.go's styleFor and internal/fonts' Default), and a
90# toolbar that disagrees with the other three fails completely silently
91# — the control names a face the text is not in, and choosing that face
92# does nothing because it is already what the run has no mark for.
93# fonts.mjs also runs a load-time guard that only executes because this
94# test imports it, the same arrangement align.test.mjs has with
95# toolbar.mjs's.
96echo "=== fixtures: the font vocabulary and its default"
97(cd fixtures && node --test fonts.test.mjs)
98
99# paste.mjs decides what a blank line pasted out of Google Docs means.
100# It is in the gate because the mistake it is one edit away from is
101# invisible until someone pastes: turning EVERY hard_break into a
102# paragraph break would destroy Shift+Enter, which editor-entry.js binds
103# and core/src/fragment.rs renders, and it would do so only on the paste
104# path — which had no test at all before this file. Like the four above
105# it is DOM-free: transformPasted takes a Slice and returns a Slice.
106echo "=== fixtures: what a pasted blank line means"
107(cd fixtures && node --test paste.test.mjs)
108
109# indent.mjs is the second block attribute's vocabulary — the value is a
110# NUMBER where alignment's is a string, so where align.test.mjs above
111# checks that a closed table is consulted, this checks a clamp. The
112# cases that matter are the ones no editor produces and
113# POST /d/{sqid}/updates accepts anyway: a level past the maximum, a
114# zero, a value that is not a number. core/src/fragment.rs holds the
115# same three in Rust, independently, so a document cannot indent one
116# distance on screen and another on paper.
117echo "=== fixtures: the indent vocabulary and its clamp"
118(cd fixtures && node --test indent.test.mjs)
119
120# sizes.mjs is the point ladder, and importing it here is what makes its
121# own module-load guard run in the gate — the same arrangement align and
122# fonts already have, for the reason their comments give: a guard that
123# only executes when a browser mounts the editor is dead code in CI, so
124# a drifted vocabulary would pass everything and then throw for every
125# user. The legacy four-step marks are the part most worth holding: they
126# are no longer offered and must go on being understood forever, because
127# documents in the wild carry them.
128echo "=== fixtures: the point ladder, and the four-step scale it replaced"
129(cd fixtures && node --test sizes.test.mjs)
130
131# autoformat.mjs turns "- " into a bullet, and the Backspace that takes
132# it back is the whole reason the feature is tolerable rather than
133# infuriating. Both halves are in the gate because both are invisible
134# until someone types: a rule that fires where it should not, and a
135# revert that does not give back the characters, each look like a broken
136# editor and neither shows up in any Go test. DOM-free — ProseMirror's
137# state, model and inputrules packages are all headless, which matters
138# because Chromium will not start in this project's sandbox, so a rule
139# tested only through the browser drive is tested nowhere most of the
140# time.
141echo "=== fixtures: the autoformat rules, and the Backspace that undoes one"
142(cd fixtures && node --test autoformat.test.mjs)
143
144# A word count is a number people quote to each other, so being quietly
145# wrong is worse here than in most places. The cases that matter are the
146# ones whitespace-splitting gets wrong — punctuation, and the languages
147# that put no space between words at all, where splitting on whitespace
148# reports 1 for a whole sentence.
149echo "=== fixtures: counting words, including where there are no spaces"
150(cd fixtures && node --test wordcount.test.mjs)
151
152# menu.mjs's enabling logic, against a forty-line fake DOM.
153#
154# In the gate because its absence already shipped a bug: sync read the
155# module constant where it should have read the merged table, so every
156# command the caller passed in — paged, infinite, word count, the
157# shortcuts sheet — was disabled on every update and could never be
158# clicked. Three deliverables unreachable, and nothing saw it: the Go
159# tests check the model and the markup, and the gate cannot open a
160# browser. The first test in this file fails against that exact edit.
161echo "=== fixtures: which menu items the editor enables"
162(cd fixtures && node --test menu.test.mjs)
163
164# Both bars of the editor's chrome, against the same kind of recording
165# fake DOM. One plugin drives them in the browser (the toolbar's, with
166# the menu on its onSync) and this drives them the same way.
167#
168# In the gate because its absence shipped a visible flash and would ship
169# it again. Both bars sync on every ProseMirror state update, and both
170# used to rewrite every control's state unconditionally; setAttribute
171# with the value already there still invalidates style and still records
172# a mutation, and so does setting a reflected boolean property to the
173# value it already holds. So the chrome repainted once per keystroke,
174# and a tab getting its frames back paid for the whole backlog in one
175# visible frame — 23 mutations measured on reveal, 21 of them redundant
176# attribute writes.
177#
178# Held against the source as well as against the controls that exist
179# today, because the failure mode is a control added later: the count
180# went from 12 to 23 across the toolbar redesign, and then menu.mjs,
181# written afterwards by a different session, reproduced the whole defect
182# from scratch. Five of the 23 were its.
183echo "=== fixtures: the editor's chrome writes only what changed"
184(cd fixtures && node --test toolbar.test.mjs)
185
186echo "=== the editor bundle must match a fresh build"
187(cd fixtures && node build-bundles.mjs)
188if [ -n "$(git status --porcelain -- internal/docs/static/)" ]; then
189 echo "gate: internal/docs/static/ does not match a fresh build. Run 'cd fixtures && node build-bundles.mjs' and commit the result." >&2
190 # The rebuild above just mutated a committed directory as a side
191 # effect of checking it. Restore it to what's actually committed —
192 # tracked changes via checkout, any new hashed file the rebuild
193 # wrote via clean — so the only trace of this failure is the
194 # message and exit code above, not a stray dirty file a later
195 # command (or a re-run of this same script) trips over, or a
196 # rebuilt-to-fresh tree that would silently pass a second run
197 # without anyone having committed anything.
198 git checkout -- internal/docs/static/
199 git clean -fd -- internal/docs/static/ >/dev/null
200 exit 1
201fi
202
203echo "=== rust: fmt"
204cargo fmt --manifest-path core/Cargo.toml --check
205
206# build-wasm.sh is the ONLY clippy invocation in this gate (it runs
207# --all-targets -D warnings -D clippy::disallowed_methods) and it runs
208# BEFORE cargo test, for two reasons that used to be two separate bugs:
209#
210# 1. A second, looser clippy invocation here previously let a banned
211# method planted in a test file sail through this fast check and get
212# caught only ~40s later, inside build-wasm.sh's own clippy run --
213# defeating the point of failing fast. One invocation, no drift.
214# 2. core/tests/no_host_imports.rs asserts zero wasm imports by reading
215# the wasm32 artifact off disk; it only exists after this line. Running
216# cargo test first made the assertion's own presence into a race —
217# it silently reported PASS-via-SKIP unless some other test happened
218# to build the artifact as a side effect first. Building here removes
219# the race instead of hoping to win it.
220echo "=== wasm: clippy, then the artifact the host embeds"
221./scripts/build-wasm.sh
222
223echo "=== rust: build, test"
224# DOCS_REQUIRE_WASM=1 turns core/tests/no_host_imports.rs's "artifact
225# missing" branch into a failure instead of a skip. It is always set here
226# because the wasm artifact was just built above; unset, a bare local
227# `cargo test` still skips gracefully on a fresh clone.
228export DOCS_REQUIRE_WASM=1
229cargo build --release --manifest-path core/Cargo.toml
230cargo test --manifest-path core/Cargo.toml
231
232echo "=== the embedded wasm must match a fresh build"
233if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then
234 echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2
235 exit 1
236fi
237
238echo "=== wire compatibility, both directions"
239./scripts/wire-gate.sh
240
241echo "=== go: vet, test, gofmt"
242go vet ./...
243# The browser checks (internal/docs/*_browser_test.go) need a Chromium, so
244# the gate does not run them; it vets them, so a test behind a tag that
245# nothing compiles cannot rot silently.
246go vet -tags browser ./internal/docs/
247go test ./... -count=1
248test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; }
249
250echo "=== go: build ./... (the app, not just the packages tests import)"
251# go vet/go test above already type-check every package they touch,
252# but neither one proves cmd/docs itself links into a runnable binary
253# under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) —
254# a plain build is the only step that actually does. CGO_ENABLED is
255# already exported above; stated again here so this line reads
256# correctly on its own.
257CGO_ENABLED=0 go build ./...
258
259# There used to be a further step here: build cmd/docs with -tags
260# release and assert the result refuses to start with -dev-auth on
261# its command line, proving a release binary had no code path that
262# could ever turn the development-only stand-in identity on
263# (cmd/docs/devauth_flag_release.go). Fix round 1 on Task 1 deleted
264# that identity outright — internal/docs/devauth.go had zero readers
265# once rastrillo/auth's RequireSession started guarding every
266# document route unconditionally, with no flag anywhere capable of
267# disabling it — so the `release` build tag it was the only user of
268# is gone too, and there is no longer a release/non-release
269# distinction for this gate to hold apart. The property that check
270# guarded (an unauthenticated binary reachable at a real URL) is now
271# covered a different way: go test above runs
272# TestNewRefusesMissingInstanceKeyOutsideDevelopment and
273# TestNewRefusesMissingMailOutsideDevelopment (internal/docs/auth_test.go),
274# proving Env other than "development" still fails closed on its own
275# fail-closed configuration, and TestSignInRequiredForEverything
276# proves every document route — GET and POST alike — refuses a
277# signed-out request. A stray "-dev-auth" on any build's command line
278# today reaches rastrillo.Resolve's own flag.FlagSet undefined, and is
279# refused exactly like any other unrecognized flag.
280
281echo "gate: ok"
282