Skip to main content

xtask/
release.rs

1//! The artifact-producing phases.
2//!
3//! Ported from `ci/scripts/release-artifact.sh`. The assertions are the part
4//! worth keeping: each one exists because something once shipped wrong, and
5//! several of them are the only thing standing between a green run and an image
6//! that is missing half its content.
7
8use 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
20/// Runs a command, inheriting stdio so its output reaches the job log.
21pub(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
36/// rustdoc, the org exports, and the static musl binaries.
37pub 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    // cargo doc recompiles the sqlx macros, so this phase needs a live schema
46    // even though it produces no test results.
47    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    // CXX stays because -sys crates that build C++ need it, and the image has no
68    // x86_64-linux-musl-g++ — only the gcc driver.
69    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
111/// One emacs invocation PER FILE.
112///
113/// `emacs --batch a.org b.org --eval (org-html-export-to-html)` runs the eval
114/// once, against the last visited buffer, so a glob silently publishes a single
115/// page — the retired Drone pipeline shipped 1 of 4 script pages exactly that way.
116fn 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    // Count what was produced, not what was attempted.
152    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
174/// The assertions that prove the shipped binaries are what we think they are.
175fn 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        // Coverage instrumentation in a shipped binary is invisible until
180        // production is slow and writing .profraw files.
181        if contains(&bytes, b"__llvm_profile") {
182            bail!(
183                "{binary} contains coverage instrumentation — RUSTFLAGS leaked out of the coverage phase"
184            );
185        }
186
187        // plotters prefers its `ttf` backend when both are enabled, and that
188        // backend dlopens fontconfig — impossible in a static musl binary, so
189        // chart rendering PANICS rather than degrading. The builder still
190        // carries the fontconfig packages, which would let such a regression
191        // build cleanly and only fail inside the scratch image.
192        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    // The generated trees are gitignored, so their absence here means a phase
200    // above failed quietly. The deploy job asserts the same on the unpacked side.
201    //
202    // NOT coverage: that is the coverage job's output, published as its own
203    // artifact and overlaid at deploy time. Asserting it here is what made the
204    // first xtask release run fail — the phase had moved, the assertion had not.
205    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
220/// release-web embeds DejaVuSans via include_bytes!, and that font's licence
221/// requires its notice to travel with copies of the font software. web/static is
222/// copied wholesale into the runtime image, so putting it here makes it ship
223/// with both the tarball and the image, and be servable.
224fn 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
235/// Tars the payload for a kind and, when this run holds the write key, publishes it.
236pub 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        // Only the coverage tree, so the two payloads are disjoint by rule and
242        // the assembler can reject any overlap rather than guess.
243        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}