1use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12
13use crate::artifact::{Identity, Kind};
14use crate::clean::{remove_dir_if_present, remove_file_if_present};
15use crate::heartbeat::Heartbeat;
16use crate::publish::Store;
17
18pub(crate) const DOC_DIR: &str = "web/static/doc";
19
20pub(crate) fn run(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<()> {
22 let mut command = Command::new(program);
23 command.args(args);
24 for (key, value) in env {
25 command.env(key, value);
26 }
27 let status = command
28 .status()
29 .with_context(|| format!("failed to run {program}"))?;
30 if !status.success() {
31 bail!("{program} {} exited with {status}", args.join(" "));
32 }
33 Ok(())
34}
35
36pub fn build(heartbeat: &Heartbeat, database_url: &str) -> Result<()> {
38 let doc_dir = PathBuf::from(DOC_DIR);
39
40 heartbeat.phase("rustdoc");
41 for stale in ["rustdoc", "scripts", "nomiscript"] {
42 remove_dir_if_present(&doc_dir.join(stale))?;
43 }
44 remove_file_if_present(&doc_dir.join("disdoc.html"))?;
45 run(
48 "cargo",
49 &["doc", "--workspace", "--all-features", "--no-deps"],
50 &[("DATABASE_URL", database_url)],
51 )?;
52 std::fs::rename("target/doc", doc_dir.join("rustdoc"))
53 .context("cannot move the rustdoc tree into the static dir")?;
54
55 heartbeat.phase("org exports");
56 for dir in ["scripts", "nomiscript"] {
57 std::fs::create_dir_all(doc_dir.join(dir))
58 .with_context(|| format!("cannot make the {dir} doc dir"))?;
59 }
60 export_org(Path::new("doc/disdoc.org"))?;
61 std::fs::rename("doc/disdoc.html", doc_dir.join("disdoc.html"))
62 .context("cannot move disdoc.html")?;
63 export_dir(Path::new("doc/scripts"), &doc_dir.join("scripts"))?;
64 export_dir(Path::new("doc/nomiscript"), &doc_dir.join("nomiscript"))?;
65
66 heartbeat.phase("musl release build");
67 run(
70 "cargo",
71 &[
72 "build",
73 "--release",
74 "--target",
75 "x86_64-unknown-linux-musl",
76 "-p",
77 "web",
78 "-p",
79 "sshd",
80 "-p",
81 "fleet-migrator",
82 ],
83 &[
84 ("SQLX_OFFLINE", "true"),
85 ("CC_x86_64_unknown_linux_musl", "x86_64-linux-musl-gcc"),
86 ("CXX_x86_64_unknown_linux_musl", "x86_64-linux-musl-gcc"),
87 ],
88 )?;
89
90 std::fs::copy(
91 "target/x86_64-unknown-linux-musl/release/web",
92 "release-web",
93 )
94 .context("cannot stage release-web")?;
95 std::fs::copy(
96 "target/x86_64-unknown-linux-musl/release/nomisync-sshd",
97 "release-sshd",
98 )
99 .context("cannot stage release-sshd")?;
100 std::fs::copy(
101 "target/x86_64-unknown-linux-musl/release/nomisync-fleet-migrate",
102 "release-fleet-migrate",
103 )
104 .context("cannot stage release-fleet-migrate")?;
105
106 verify_binaries()?;
107 stage_licences()?;
108 Ok(())
109}
110
111fn export_org(org: &Path) -> Result<()> {
117 run(
118 "emacs",
119 &[
120 "-q",
121 "--batch",
122 &org.to_string_lossy(),
123 "-l",
124 "ox-html",
125 "--eval",
126 "(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))",
127 "-f",
128 "save-buffer",
129 "-f",
130 "kill-emacs",
131 ],
132 &[],
133 )
134}
135
136fn export_dir(src: &Path, dest: &Path) -> Result<()> {
137 let orgs: Vec<PathBuf> = std::fs::read_dir(src)
138 .with_context(|| format!("cannot read {}", src.display()))?
139 .filter_map(Result::ok)
140 .map(|entry| entry.path())
141 .filter(|path| path.extension().is_some_and(|ext| ext == "org"))
142 .collect();
143 if orgs.is_empty() {
144 bail!("no org files in {}", src.display());
145 }
146
147 for org in &orgs {
148 export_org(org)?;
149 }
150
151 let produced: Vec<PathBuf> = std::fs::read_dir(src)?
153 .filter_map(Result::ok)
154 .map(|entry| entry.path())
155 .filter(|path| path.extension().is_some_and(|ext| ext == "html"))
156 .collect();
157 if produced.len() != orgs.len() {
158 bail!(
159 "{}: exported {} of {} pages",
160 src.display(),
161 produced.len(),
162 orgs.len()
163 );
164 }
165 for page in produced {
166 let target = dest.join(page.file_name().context("html page has no file name")?);
167 std::fs::rename(&page, &target)
168 .with_context(|| format!("cannot move {}", page.display()))?;
169 }
170 println!(">> exported {} pages from {}", orgs.len(), src.display());
171 Ok(())
172}
173
174fn verify_binaries() -> Result<()> {
176 for binary in ["release-web", "release-sshd", "release-fleet-migrate"] {
177 let bytes = std::fs::read(binary).with_context(|| format!("cannot read {binary}"))?;
178
179 if contains(&bytes, b"__llvm_profile") {
182 bail!(
183 "{binary} contains coverage instrumentation — RUSTFLAGS leaked out of the coverage phase"
184 );
185 }
186
187 if binary == "release-web" && contains(&bytes, b"fontconfig") {
193 bail!(
194 "release-web references fontconfig — plotters' ttf backend is back; it cannot work in the scratch image"
195 );
196 }
197 }
198
199 for required in ["rustdoc", "scripts", "nomiscript", "disdoc.html"] {
206 let path = PathBuf::from(DOC_DIR).join(required);
207 if !path.exists() {
208 bail!("missing {}", path.display());
209 }
210 }
211 Ok(())
212}
213
214fn contains(haystack: &[u8], needle: &[u8]) -> bool {
215 haystack
216 .windows(needle.len())
217 .any(|window| window == needle)
218}
219
220fn stage_licences() -> Result<()> {
225 let dir = PathBuf::from("web/static/licenses");
226 std::fs::create_dir_all(&dir).context("cannot make the licenses dir")?;
227 let target = dir.join("DejaVuSans-LICENSE.txt");
228 std::fs::copy("plotting/fonts/LICENSE", &target).context("cannot copy the font licence")?;
229 if std::fs::metadata(&target)?.len() == 0 {
230 bail!("font licence missing from artifact");
231 }
232 Ok(())
233}
234
235pub fn package(kind: Kind, heartbeat: &Heartbeat, with_artifacts: bool) -> Result<()> {
237 heartbeat.phase("packaging");
238
239 let tarball = PathBuf::from(format!("nomisync-{kind}.tar"));
240 let members: &[&str] = match kind {
241 Kind::Coverage => &["web/static/doc/coverage"],
244 Kind::Release => &[
245 "release-web",
246 "release-sshd",
247 "release-fleet-migrate",
248 "web/static",
249 ],
250 };
251 let mut args = vec!["-czf", &tarball.to_str().context("tarball path")?];
252 args.extend_from_slice(members);
253 run("tar", &args, &[])?;
254
255 if !with_artifacts {
256 println!(">> WITH_ARTIFACTS unset — built and verified, not uploading");
257 return Ok(());
258 }
259
260 let identity = Identity::from_env()?;
261 let store = Store::from_env()?;
262 let meta = store.publish(kind, &tarball, &identity)?;
263 println!(
264 ">> published {} ({} bytes) for {} run {}",
265 meta.object_key(),
266 meta.bytes,
267 identity.commit,
268 identity.run_id
269 );
270 Ok(())
271}