1use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12
13use crate::artifact::{Identity, Kind};
14use crate::heartbeat::Heartbeat;
15use crate::publish::Store;
16
17const DOC_DIR: &str = "web/static/doc";
18
19fn run(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<()> {
21 let mut command = Command::new(program);
22 command.args(args);
23 for (key, value) in env {
24 command.env(key, value);
25 }
26 let status = command
27 .status()
28 .with_context(|| format!("failed to run {program}"))?;
29 if !status.success() {
30 bail!("{program} {} exited with {status}", args.join(" "));
31 }
32 Ok(())
33}
34
35pub fn coverage(heartbeat: &Heartbeat, database_url: &str) -> Result<()> {
37 let profraw_dir = std::env::current_dir()?.join("target/coverage");
40 let _ = std::fs::remove_dir_all(&profraw_dir);
41 std::fs::create_dir_all(&profraw_dir).context("cannot make the profraw dir")?;
42 let profile_pattern = profraw_dir.join("nomisync-%p-%m.profraw");
43
44 heartbeat.phase("instrumented test run");
45 crate::watchdog::run_watched(
49 "cargo",
50 &["test", "--verbose", "--all-features", "--workspace"],
51 &[
52 ("RUSTFLAGS", "-Cinstrument-coverage"),
56 ("CARGO_INCREMENTAL", "0"),
57 ("LLVM_PROFILE_FILE", &profile_pattern.to_string_lossy()),
58 ("DATABASE_URL", database_url),
59 ],
60 )?;
61
62 let profraw_count = std::fs::read_dir(&profraw_dir)
65 .context("cannot read the profraw dir")?
66 .filter_map(Result::ok)
67 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "profraw"))
68 .count();
69 if profraw_count == 0 {
70 bail!("no .profraw produced — coverage instrumentation did not run");
71 }
72 println!(">> {profraw_count} profraw files");
73
74 heartbeat.phase("grcov");
75 let coverage_dir = PathBuf::from(DOC_DIR).join("coverage");
78 let _ = std::fs::remove_dir_all(&coverage_dir);
79 run(
80 "grcov",
81 &[
82 ".",
83 "-s",
84 ".",
85 "--binary-path",
86 "./target/debug/",
87 "-t",
88 "html",
89 "--branch",
90 "--ignore-not-existing",
91 "--ignore",
92 "/*",
93 "--ignore",
94 "*cranelift*",
95 "--ignore",
96 "*lexicon*",
97 "--ignore",
98 "*incremental*",
99 "-o",
100 &coverage_dir.to_string_lossy(),
101 ],
102 &[],
103 )?;
104
105 let pages = std::fs::read_dir(&coverage_dir)
109 .with_context(|| format!("grcov produced no {}", coverage_dir.display()))?
110 .count();
111 if pages == 0 {
112 bail!(
113 "{} is empty — the coverage report has no pages",
114 coverage_dir.display()
115 );
116 }
117 println!(">> coverage report: {pages} entries");
118 Ok(())
119}
120
121pub fn build(heartbeat: &Heartbeat, database_url: &str) -> Result<()> {
123 let doc_dir = PathBuf::from(DOC_DIR);
124
125 heartbeat.phase("rustdoc");
126 for stale in ["rustdoc", "scripts", "nomiscript"] {
127 let _ = std::fs::remove_dir_all(doc_dir.join(stale));
128 }
129 let _ = std::fs::remove_file(doc_dir.join("disdoc.html"));
130 run(
133 "cargo",
134 &["doc", "--workspace", "--all-features", "--no-deps"],
135 &[("DATABASE_URL", database_url)],
136 )?;
137 std::fs::rename("target/doc", doc_dir.join("rustdoc"))
138 .context("cannot move the rustdoc tree into the static dir")?;
139
140 heartbeat.phase("org exports");
141 std::fs::create_dir_all(doc_dir.join("scripts")).ok();
142 std::fs::create_dir_all(doc_dir.join("nomiscript")).ok();
143 export_org(Path::new("doc/disdoc.org"))?;
144 std::fs::rename("doc/disdoc.html", doc_dir.join("disdoc.html"))
145 .context("cannot move disdoc.html")?;
146 export_dir(Path::new("doc/scripts"), &doc_dir.join("scripts"))?;
147 export_dir(Path::new("doc/nomiscript"), &doc_dir.join("nomiscript"))?;
148
149 heartbeat.phase("musl release build");
150 run(
153 "cargo",
154 &[
155 "build",
156 "--release",
157 "--target",
158 "x86_64-unknown-linux-musl",
159 "-p",
160 "web",
161 "-p",
162 "sshd",
163 ],
164 &[
165 ("SQLX_OFFLINE", "true"),
166 ("CC_x86_64_unknown_linux_musl", "x86_64-linux-musl-gcc"),
167 ("CXX_x86_64_unknown_linux_musl", "x86_64-linux-musl-gcc"),
168 ],
169 )?;
170
171 std::fs::copy(
172 "target/x86_64-unknown-linux-musl/release/web",
173 "release-web",
174 )
175 .context("cannot stage release-web")?;
176 std::fs::copy(
177 "target/x86_64-unknown-linux-musl/release/nomisync-sshd",
178 "release-sshd",
179 )
180 .context("cannot stage release-sshd")?;
181
182 verify_binaries()?;
183 stage_licences()?;
184 Ok(())
185}
186
187fn export_org(org: &Path) -> Result<()> {
193 run(
194 "emacs",
195 &[
196 "-q",
197 "--batch",
198 &org.to_string_lossy(),
199 "-l",
200 "ox-html",
201 "--eval",
202 "(progn (org-babel-do-load-languages (quote org-babel-load-languages) (quote ((emacs-lisp . t) (shell . t)))) (setq org-confirm-babel-evaluate nil) (setq org-html-validation-link nil) (org-html-export-to-html))",
203 "-f",
204 "save-buffer",
205 "-f",
206 "kill-emacs",
207 ],
208 &[],
209 )
210}
211
212fn export_dir(src: &Path, dest: &Path) -> Result<()> {
213 let orgs: Vec<PathBuf> = std::fs::read_dir(src)
214 .with_context(|| format!("cannot read {}", src.display()))?
215 .filter_map(Result::ok)
216 .map(|entry| entry.path())
217 .filter(|path| path.extension().is_some_and(|ext| ext == "org"))
218 .collect();
219 if orgs.is_empty() {
220 bail!("no org files in {}", src.display());
221 }
222
223 for org in &orgs {
224 export_org(org)?;
225 }
226
227 let produced: Vec<PathBuf> = std::fs::read_dir(src)?
229 .filter_map(Result::ok)
230 .map(|entry| entry.path())
231 .filter(|path| path.extension().is_some_and(|ext| ext == "html"))
232 .collect();
233 if produced.len() != orgs.len() {
234 bail!(
235 "{}: exported {} of {} pages",
236 src.display(),
237 produced.len(),
238 orgs.len()
239 );
240 }
241 for page in produced {
242 let target = dest.join(page.file_name().context("html page has no file name")?);
243 std::fs::rename(&page, &target)
244 .with_context(|| format!("cannot move {}", page.display()))?;
245 }
246 println!(">> exported {} pages from {}", orgs.len(), src.display());
247 Ok(())
248}
249
250fn verify_binaries() -> Result<()> {
252 for binary in ["release-web", "release-sshd"] {
253 let bytes = std::fs::read(binary).with_context(|| format!("cannot read {binary}"))?;
254
255 if contains(&bytes, b"__llvm_profile") {
258 bail!(
259 "{binary} contains coverage instrumentation — RUSTFLAGS leaked out of the coverage phase"
260 );
261 }
262
263 if binary == "release-web" && contains(&bytes, b"fontconfig") {
269 bail!(
270 "release-web references fontconfig — plotters' ttf backend is back; it cannot work in the scratch image"
271 );
272 }
273 }
274
275 for required in ["rustdoc", "scripts", "nomiscript", "disdoc.html"] {
282 let path = PathBuf::from(DOC_DIR).join(required);
283 if !path.exists() {
284 bail!("missing {}", path.display());
285 }
286 }
287 Ok(())
288}
289
290fn contains(haystack: &[u8], needle: &[u8]) -> bool {
291 haystack
292 .windows(needle.len())
293 .any(|window| window == needle)
294}
295
296fn stage_licences() -> Result<()> {
301 let dir = PathBuf::from("web/static/licenses");
302 std::fs::create_dir_all(&dir).context("cannot make the licenses dir")?;
303 let target = dir.join("DejaVuSans-LICENSE.txt");
304 std::fs::copy("plotting/fonts/LICENSE", &target).context("cannot copy the font licence")?;
305 if std::fs::metadata(&target)?.len() == 0 {
306 bail!("font licence missing from artifact");
307 }
308 Ok(())
309}
310
311pub fn package(kind: Kind, heartbeat: &Heartbeat, with_artifacts: bool) -> Result<()> {
313 heartbeat.phase("packaging");
314
315 let tarball = PathBuf::from(format!("nomisync-{kind}.tar"));
316 let members: &[&str] = match kind {
317 Kind::Coverage => &["web/static/doc/coverage"],
320 Kind::Release => &["release-web", "release-sshd", "web/static"],
321 };
322 let mut args = vec!["-czf", &tarball.to_str().context("tarball path")?];
323 args.extend_from_slice(members);
324 run("tar", &args, &[])?;
325
326 if !with_artifacts {
327 println!(">> WITH_ARTIFACTS unset — built and verified, not uploading");
328 return Ok(());
329 }
330
331 let identity = Identity::from_env()?;
332 let store = Store::from_env()?;
333 let meta = store.publish(kind, &tarball, &identity)?;
334 println!(
335 ">> published {} ({} bytes) for {} run {}",
336 meta.object_key(),
337 meta.bytes,
338 identity.commit,
339 identity.run_id
340 );
341 Ok(())
342}