| 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 what the runner executes and what you |
| 4 | # run before pushing are one definition rather than two that drift. |
| 5 | set -euo pipefail |
| 6 | |
| 7 | # Run from the repo root whatever the caller's working directory is: |
| 8 | # .amadan/ci execs this file, an amadan runner with step support runs it |
| 9 | # directly, and you will run it by hand from wherever you happen to be. |
| 10 | cd "$(dirname "$0")/../.." |
| 11 | |
| 12 | # rustup installs to ~/.cargo/bin, which is on neither a systemd user |
| 13 | # unit's PATH nor a login shell's when rustup ran with --no-modify-path. |
| 14 | # Source it here rather than trusting whoever set the runner up to have |
| 15 | # got it right — a gate that only works on one machine is not a gate. |
| 16 | [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" |
| 17 | |
| 18 | for tool in cargo rustc rustfmt go node npm; do |
| 19 | command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; } |
| 20 | done |
| 21 | rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ |
| 22 | || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; } |
| 23 | |
| 24 | # The stack is cgo-free by design, here as in scripts/run-dev.sh and the |
| 25 | # Makefile. A build that quietly used cgo would not be the binary that |
| 26 | # ships to an arm64 instance. |
| 27 | export CGO_ENABLED=0 |
| 28 | export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}amadan.net/oficina/estilo.git" |
| 29 | |
| 30 | # Two of the steps below regenerate a committed artifact and then require |
| 31 | # the tree to be unchanged. Both have the same shape, so they share one |
| 32 | # definition: |
| 33 | # |
| 34 | # git diff alone is not enough. It sees a tracked file that changed or |
| 35 | # was deleted, and never the NEW file a changed content hash writes |
| 36 | # beside the old one — so a rebuild whose bytes moved leaves both a |
| 37 | # deletion and an untracked file, and checking only one of them lets |
| 38 | # half of that through. |
| 39 | # |
| 40 | # On failure the TRACKED files are put back and the untracked ones |
| 41 | # are left where they are, listed. The check mutated a committed |
| 42 | # directory as a side effect of reading it, and leaving a modified |
| 43 | # tracked file behind would make a second run of this same script |
| 44 | # pass with nobody having committed anything — the worst possible |
| 45 | # outcome for a freshness check. But an untracked file here is |
| 46 | # exactly the one the developer has to commit next (a vector for a |
| 47 | # new data file, the bundle under its new hash), and deleting it — |
| 48 | # as this once did — threw away a freshly generated artifact before |
| 49 | # anybody could `git add` it. So it stays, and the message names it. |
| 50 | require_committed() { |
| 51 | local path=$1 fix=$2 ok=1 |
| 52 | # --name-status, not a content diff: both directories hold minified |
| 53 | # or binary artifacts, and the useful answer is which file moved, not |
| 54 | # 371 KB of bundle in the runner's log. |
| 55 | git diff --exit-code --name-status -- "$path" || ok=0 |
| 56 | if [ -n "$(git status --porcelain -- "$path")" ]; then |
| 57 | git status --porcelain -- "$path" >&2 |
| 58 | ok=0 |
| 59 | fi |
| 60 | if [ "$ok" -eq 0 ]; then |
| 61 | echo "gate: $fix" >&2 |
| 62 | echo "gate: stale or uncommitted under $path (M = restored to the committed version, ?? = left for you to commit):" >&2 |
| 63 | git status --porcelain -- "$path" >&2 |
| 64 | git checkout -- "$path" |
| 65 | exit 1 |
| 66 | fi |
| 67 | } |
| 68 | |
| 69 | echo "=== rust: fmt" |
| 70 | # --manifest-path, because there is no workspace at the repo root: the |
| 71 | # crate lives in core/ and a bare `cargo fmt` here finds no Cargo.toml. |
| 72 | cargo fmt --manifest-path core/Cargo.toml --check |
| 73 | |
| 74 | echo "=== rust: clippy" |
| 75 | # clippy.toml's disallowed-methods is the real enforcement of the |
| 76 | # Doc-construction rule that core/src/lib.rs documents: Doc::new() and |
| 77 | # Doc::with_client_id() both draw entropy this build has no safe source |
| 78 | # for, and a doc comment alone has been got around before. |
| 79 | # |
| 80 | # scripts/build-wasm.sh below runs this same invocation, flag for flag, |
| 81 | # before it builds. That is deliberate: it fails here in a few seconds |
| 82 | # rather than forty seconds into the wasm build, and because the two |
| 83 | # spellings are identical there is no looser check to sail through — |
| 84 | # the drift Docs' gate hit when its two clippy runs disagreed. |
| 85 | cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D clippy::disallowed_methods |
| 86 | |
| 87 | echo "=== wasm: the artifact the host embeds" |
| 88 | # Before cargo test, not after. core/tests/no_host_imports.rs asserts the |
| 89 | # module imports nothing from the host by reading the wasm32 artifact off |
| 90 | # disk, and that file only exists once this line has run. Run the tests |
| 91 | # first and the assertion's own presence becomes a race it reports as a |
| 92 | # skip. |
| 93 | ./scripts/build-wasm.sh |
| 94 | |
| 95 | echo "=== the embedded wasm must match a fresh build" |
| 96 | if ! cmp -s core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm; then |
| 97 | echo "gate: internal/slides/core/testdata/slides_core.wasm does not match a fresh build — rebuild and commit the wasm:" >&2 |
| 98 | echo " ./scripts/build-wasm.sh && cp core/target/wasm32-unknown-unknown/release/slides_core.wasm internal/slides/core/testdata/slides_core.wasm" >&2 |
| 99 | exit 1 |
| 100 | fi |
| 101 | |
| 102 | echo "=== rust: test" |
| 103 | # SLIDES_REQUIRE_WASM=1 turns no_host_imports.rs's "artifact missing" |
| 104 | # branch from a skip into a failure. Safe to set unconditionally here |
| 105 | # because the build above just produced it; left unset, a bare local |
| 106 | # `cargo test` on a fresh clone still skips rather than forcing a wasm |
| 107 | # build first. |
| 108 | SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/Cargo.toml |
| 109 | |
| 110 | # The PDF module (core/pdf, hosted by internal/slides/pdfcore) is a |
| 111 | # second crate with the same shape and the same four steps, in the same |
| 112 | # order and for the same reasons as the CRDT core's above: fmt, clippy |
| 113 | # (the exact invocation scripts/build-pdf-wasm.sh runs, so nothing |
| 114 | # looser sails through), build the artifact BEFORE its own |
| 115 | # no_host_imports test reads it, and the byte comparison against the |
| 116 | # committed copy go:embed actually ships. A stale slides_pdf.wasm is |
| 117 | # the same trap as a stale slides_core.wasm: the Go tests pass against |
| 118 | # whatever is committed, and only this cmp knows the difference. |
| 119 | echo "=== rust: fmt (pdf)" |
| 120 | cargo fmt --manifest-path core/pdf/Cargo.toml --check |
| 121 | |
| 122 | echo "=== rust: clippy (pdf)" |
| 123 | cargo clippy --manifest-path core/pdf/Cargo.toml --all-targets -- -D warnings |
| 124 | |
| 125 | echo "=== wasm: the pdf artifact the host embeds" |
| 126 | ./scripts/build-pdf-wasm.sh |
| 127 | |
| 128 | echo "=== the embedded pdf wasm must match a fresh build" |
| 129 | if ! cmp -s core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm internal/slides/pdfcore/testdata/slides_pdf.wasm; then |
| 130 | echo "gate: internal/slides/pdfcore/testdata/slides_pdf.wasm does not match a fresh build — rebuild and commit the wasm:" >&2 |
| 131 | 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 |
| 132 | exit 1 |
| 133 | fi |
| 134 | |
| 135 | echo "=== rust: test (pdf)" |
| 136 | SLIDES_REQUIRE_WASM=1 cargo test --manifest-path core/pdf/Cargo.toml |
| 137 | |
| 138 | echo "=== fixtures: node dependencies" |
| 139 | # The versions are printed rather than pinned. What Node is only matters |
| 140 | # as a floor — 20+, for `node --test` and the ESM loading this tree |
| 141 | # assumes — because yjs and the rest come from the lockfile and esbuild's |
| 142 | # actual work happens inside a pinned native binary. |
| 143 | node --version |
| 144 | npm --version |
| 145 | |
| 146 | # The runner clones a fresh worktree per branch, so fixtures/node_modules |
| 147 | # (gitignored, like any node_modules) never carries over from a previous |
| 148 | # run. npm ci reads fixtures/package-lock.json rather than resolving from |
| 149 | # package.json, so the installed yjs is exactly the pinned 13.6.27 and not |
| 150 | # whatever a bare `npm install` would pick today. |
| 151 | # |
| 152 | # NPM_CACHE exists because the cache directory is the one part of this |
| 153 | # that is not the same everywhere. The runner's HOME is writable and |
| 154 | # $HOME/.npm is right there; a sandboxed local run may have neither, and |
| 155 | # npm then fails with an EROFS on its cache rather than with anything to |
| 156 | # do with the install. Point NPM_CACHE somewhere writable in that case. |
| 157 | echo "=== fixtures: npm ci (needs the registry — see .amadan/README.md)" |
| 158 | (cd fixtures && npm ci --cache "${NPM_CACHE:-$HOME/.npm}") |
| 159 | |
| 160 | echo "=== fixtures: the JS side's own tests" |
| 161 | # From fixtures/ with an explicit glob, never `node --test fixtures/`: |
| 162 | # Node 24 takes the directory argument as a test file, runs nothing and |
| 163 | # exits 0, which is a green-looking way to run no tests at all. |
| 164 | (cd fixtures && node --test *.test.mjs) |
| 165 | |
| 166 | echo "=== the vectors must match a fresh generation" |
| 167 | # fixtures/vectors is the contract between the two implementations of the |
| 168 | # op vocabulary (spec §2.8): generate.mjs builds each deck through |
| 169 | # ops.mjs and writes the Y update plus the JS side's view of it, and |
| 170 | # internal/slides/core/vectors_test.go — run by `make ci` below — loads |
| 171 | # the same bytes through the Rust core and requires the same view. |
| 172 | # |
| 173 | # A vector nobody regenerated is the failure this catches, and it is a |
| 174 | # silent one: the Go test still passes, because it is comparing the core |
| 175 | # against a JS implementation that no longer exists. Both sides change or |
| 176 | # neither does. |
| 177 | (cd fixtures && node generate.mjs) |
| 178 | require_committed fixtures/vectors \ |
| 179 | "vectors are stale: run node fixtures/generate.mjs and commit" |
| 180 | |
| 181 | echo "=== the editor bundle must match a fresh build" |
| 182 | # build-bundles.mjs is byte-reproducible: no sourcemap, no banner, no |
| 183 | # timestamp, no absolute path in the output, and esbuild's work happens |
| 184 | # in the pinned 0.28.2 native binary rather than in interpreted JS, so |
| 185 | # the Node running it does not enter into the result either. |
| 186 | # |
| 187 | # This is the same check as the wasm cmp above and it is here for the |
| 188 | # same reason: nothing builds the bundle at run time, the artifact is |
| 189 | # committed, and a server handed a stale one serves it perfectly happily. |
| 190 | # assets_embed.go's exactly-one rule makes a leftover hashed file a panic |
| 191 | # at boot rather than a wrong editor, which is why the untracked half of |
| 192 | # require_committed matters here too. |
| 193 | node fixtures/build-bundles.mjs |
| 194 | require_committed internal/slides/static \ |
| 195 | "the editor bundle is stale: run node fixtures/build-bundles.mjs and commit internal/slides/static" |
| 196 | |
| 197 | echo "=== go: vet, gofmt, test, migration check" |
| 198 | # The Makefile's own ci target, so the Go half of this gate and the |
| 199 | # `make ci` you run between edits cannot come apart. |
| 200 | make ci |
| 201 | |
| 202 | echo "gate: ok" |
| 203 | |