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

            
8
use std::path::{Path, PathBuf};
9
use std::process::Command;
10

            
11
use anyhow::{Context, Result, bail};
12

            
13
use crate::artifact::{Identity, Kind};
14
use crate::clean::{remove_dir_if_present, remove_file_if_present};
15
use crate::heartbeat::Heartbeat;
16
use crate::publish::Store;
17

            
18
pub(crate) const DOC_DIR: &str = "web/static/doc";
19

            
20
/// Runs a command, inheriting stdio so its output reaches the job log.
21
5
pub(crate) fn run(program: &str, args: &[&str], env: &[(&str, &str)]) -> Result<()> {
22
5
    let mut command = Command::new(program);
23
5
    command.args(args);
24
5
    for (key, value) in env {
25
        command.env(key, value);
26
    }
27
5
    let status = command
28
5
        .status()
29
5
        .with_context(|| format!("failed to run {program}"))?;
30
5
    if !status.success() {
31
2
        bail!("{program} {} exited with {status}", args.join(" "));
32
3
    }
33
3
    Ok(())
34
5
}
35

            
36
/// rustdoc, the org exports, and the static musl binaries.
37
pub 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.
116
fn 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

            
136
fn 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.
175
fn 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

            
214
fn 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.
224
fn 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.
236
pub 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
}