Skip to main content

max / makenotwork

11.4 KB · 271 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::fs;
3 use std::hash::{Hash, Hasher};
4 use std::path::Path;
5 use std::process::Command;
6
7 fn main() {
8 stamp_git_hash();
9 build_frontend();
10 fingerprint_assets();
11 }
12
13 /// Stamp the short commit sha into `GIT_HASH` for `option_env!()` to pick up
14 /// in the `/api/health` body.
15 ///
16 /// Empty on a build with no git metadata (a source tarball, a container
17 /// without `.git`), which the health body maps to `null` rather than to a
18 /// misleading empty string.
19 fn stamp_git_hash() {
20 println!("cargo::rustc-env=GIT_HASH={}", git_hash());
21 }
22
23 /// Content-hash the served static files and emit the template partials that
24 /// carry a `?v=<hash>` cache-buster on every asset URL.
25 ///
26 /// Runs after `build_frontend` so the hash covers the JS that was just emitted
27 /// into `static/dist/`, not the previous build's.
28 ///
29 /// One global version rather than a per-file hash: an upgrade to any watched
30 /// file re-fetches all of them, which costs a handful of requests once and
31 /// keeps the templates free of per-asset bookkeeping. What it buys is that a
32 /// redeployed file can never be served stale from browser cache, for a chat
33 /// island that would mean old protocol logic talking to a new server, which
34 /// presents as "chat is broken for some people" rather than as a cache bug.
35 ///
36 /// The generated partials are gitignored build output. They are written only
37 /// when their content changes, so a no-op build does not retrigger Askama.
38 fn fingerprint_assets() {
39 // These watches now do double duty. They have always decided when the `?v=`
40 // hash is recomputed; since `static/` is compiled into the binary
41 // (src/static_assets.rs), they also decide when the embed is refreshed. An
42 // asset outside this set is an asset that can go stale in the binary.
43 //
44 // `static/fonts` is watched as a directory — cargo walks it — and is safe to
45 // watch because nothing in the build writes there.
46 //
47 // `static/dist` is deliberately NOT watched: `build_frontend` rewrites it on
48 // every run, so watching it would rerun this script (and npm) on every
49 // build. It is covered transitively instead, by the `frontend/src` watch in
50 // `build_frontend` — a build-script rerun recompiles the crate, which is
51 // when `include_dir!` re-reads the emitted bundles.
52 println!("cargo::rerun-if-changed=static/fonts");
53 let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"];
54
55 let mut hasher = DefaultHasher::new();
56 for path in &static_files {
57 println!("cargo::rerun-if-changed={path}");
58 if let Ok(content) = fs::read(path) {
59 content.hash(&mut hasher);
60 }
61 }
62 // Fold the emitted bundles in so `?v=` also busts when the TypeScript
63 // changes. Read-only: `frontend/src` is the watched trigger (in
64 // build_frontend); watching the outputs we generate would loop.
65 hash_dir_js(Path::new("static/dist"), &mut hasher);
66 let version = &format!("{:016x}", hasher.finish())[..8];
67
68 // Included by base.html's <head>.
69 let head = format!(
70 r#" <link rel="stylesheet" href="/static/style.css?v={version}">
71 <script src="/static/htmx.min.js?v={version}"></script>"#
72 );
73 write_if_changed(Path::new("templates/_head_assets.html"), &head);
74
75 // Included at the end of base.html's <body>. Kept separate from the head
76 // partial because mt.js is a classic (non-deferred) script and has to keep
77 // running after the document is parsed.
78 let scripts = format!(r#" <script src="/static/mt.js?v={version}"></script>"#);
79 write_if_changed(Path::new("templates/_body_scripts.html"), &scripts);
80
81 // Per-page island loader, for the compiled ESM under static/dist/. Used as
82 // `{% import "_island.html" as island %}{% call island::island("chat") %}`
83 // and cache-busted by the same content hash as the head assets.
84 let island = r#"{% macro island(name) -%}
85 <script type="module" src="/static/dist/{{ name }}.js?v=__VER__"></script>
86 {%- endmacro %}
87 "#
88 .replace("__VER__", version);
89 write_if_changed(Path::new("templates/_island.html"), &island);
90 }
91
92 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
93 fn write_if_changed(path: &Path, contents: &str) {
94 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
95 if needs_write {
96 fs::write(path, contents)
97 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
98 }
99 }
100
101 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
102 /// deterministic order. A missing directory is a no-op, the first build on a
103 /// fresh checkout runs before the frontend has been compiled.
104 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
105 let Ok(entries) = fs::read_dir(dir) else {
106 return;
107 };
108 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
109 paths.sort();
110 for path in paths {
111 if path.is_dir() {
112 hash_dir_js(&path, hasher);
113 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
114 && let Ok(content) = fs::read(&path)
115 {
116 content.hash(hasher);
117 }
118 }
119 }
120
121 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
122 /// `static/dist/` via `npm run build` (which runs `tsc`).
123 ///
124 /// This runs from `cargo build` on purpose. `static/dist/` is gitignored
125 /// regenerable output, so a fresh checkout has no JS at all, and the deploy path
126 /// builds on astra rather than on the machine that edited the TypeScript. Making
127 /// it a build script means there is no "remember to run npm build first" step
128 /// standing between an edit and a working deploy.
129 ///
130 /// Self-contained: on a host without `node_modules` it runs `npm ci` first.
131 ///
132 /// Best-effort and non-fatal. A missing Node or a compile error emits a
133 /// `cargo::warning` and lets the Rust build succeed against whatever
134 /// `static/dist/` already holds, because a type error in a chat widget should
135 /// not be able to stop the forum from building. The Sando `code_smoke` tier is
136 /// where a frontend failure is meant to be fatal.
137 ///
138 /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely.
139 fn build_frontend() {
140 println!("cargo::rerun-if-changed=frontend/src");
141 println!("cargo::rerun-if-changed=frontend/package.json");
142 println!("cargo::rerun-if-changed=frontend/package-lock.json");
143 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
144
145 if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() {
146 println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)");
147 return;
148 }
149
150 if !Path::new("frontend/node_modules").is_dir() {
151 match Command::new("npm")
152 .args(["ci"])
153 .current_dir("frontend")
154 .status()
155 {
156 Ok(s) if s.success() => {}
157 Ok(s) => {
158 println!(
159 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
160 s.code()
161 );
162 return;
163 }
164 Err(e) => {
165 println!(
166 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
167 );
168 return;
169 }
170 }
171 }
172
173 match Command::new("npm")
174 .args(["run", "build"])
175 .current_dir("frontend")
176 .status()
177 {
178 Ok(s) if s.success() => {}
179 Ok(s) => println!(
180 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
181 s.code()
182 ),
183 Err(e) => println!(
184 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
185 skipping (serving existing static/dist)"
186 ),
187 }
188 }
189
190 /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide
191 /// when this build script has to run again.
192 ///
193 /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that
194 /// does not exist as *changed*, so a watch on a path that can never exist makes
195 /// this script re-run on every single cargo invocation, and re-running it
196 /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves
197 /// against the package root: there is no `.git` in `server/` or in
198 /// `multithreaded/` because the repository root is `MNW/`. So the watch never
199 /// resolved, and the crate recompiled every time.
200 ///
201 /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded,
202 /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the
203 /// `cargo_test` gate, and it cost the same on every local `cargo build`,
204 /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were
205 /// the only two in the pipeline that recompiled on a second cargo invocation;
206 /// the ones without one were already free.
207 ///
208 /// Two rules follow, and both matter:
209 /// - resolve the paths through git rather than guessing them, and
210 /// - emit a watch ONLY for a path that exists, or the bug comes straight back.
211 fn git_hash() -> String {
212 // An explicit hash wins and skips git entirely, for a build system that
213 // already knows the sha. Nothing sets this today: Sando would have to set it
214 // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env`
215 // and therefore part of the crate fingerprint, so a value present for the
216 // release build and absent for `cargo_test` would force the recompile this
217 // function exists to remove.
218 println!("cargo::rerun-if-env-changed=MNW_GIT_HASH");
219 if let Ok(h) = std::env::var("MNW_GIT_HASH") {
220 let h = h.trim().to_string();
221 if !h.is_empty() {
222 return h;
223 }
224 }
225
226 // Watch what git actually rewrites when the checkout moves. `.git/HEAD`
227 // alone is not enough even when resolved: committing on a branch rewrites
228 // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale
229 // in the ordinary case of a commit.
230 watch_if_exists(git_path("HEAD").as_deref());
231 if let Some(r) = git_output(&["symbolic-ref", "-q", "HEAD"]) {
232 watch_if_exists(git_path(&r).as_deref());
233 }
234 // A ref that has been packed has no loose file, so this is the fallback
235 // that keeps the watch honest after a `git gc`.
236 watch_if_exists(git_path("packed-refs").as_deref());
237
238 git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default()
239 }
240
241 /// Run a git command in the package directory and return its trimmed stdout.
242 fn git_output(args: &[&str]) -> Option<String> {
243 Command::new("git")
244 .args(args)
245 .output()
246 .ok()
247 .filter(|o| o.status.success())
248 .and_then(|o| String::from_utf8(o.stdout).ok())
249 .map(|s| s.trim().to_string())
250 .filter(|s| !s.is_empty())
251 }
252
253 /// Resolve a name inside the git directory to a path, honouring worktrees and a
254 /// `.git` file that points elsewhere. `None` when this is not a checkout at all,
255 /// which is the case in a vendored or packaged build.
256 fn git_path(name: &str) -> Option<String> {
257 git_output(&["rev-parse", "--git-path", name])
258 }
259
260 /// Emit a watch for a path, but only when it exists.
261 ///
262 /// The guard is the fix. A missing path reads as changed to cargo, so emitting
263 /// one unconditionally is what caused the recompile-every-time bug.
264 fn watch_if_exists(path: Option<&str>) {
265 if let Some(p) = path
266 && Path::new(p).exists()
267 {
268 println!("cargo::rerun-if-changed={p}");
269 }
270 }
271