Skip to main content

xtask/
main.rs

1//! The nomisync CI pipeline, as a program.
2//!
3//! Invoked from `.forgejo/workflows/ci.yml` as `cargo run -p xtask -- <command>`.
4//! Deliberately dependency-light: this is the first thing a CI job builds, and
5//! it starts the database the rest of the workspace compiles against, so it
6//! must not itself need one.
7//!
8//! What stays outside: `.forgejo/scripts/deploy.sh` runs in a kaniko init
9//! container that has no Rust toolchain, and `ci/builder/build.sh` builds the
10//! container image this program runs inside.
11
12mod artifact;
13mod deploy_render;
14mod heartbeat;
15mod postgres;
16mod publish;
17mod release;
18mod watchdog;
19
20use anyhow::{Context, Result, bail};
21use clap::{Parser, Subcommand};
22
23use artifact::Kind;
24use heartbeat::Heartbeat;
25
26#[derive(Parser)]
27#[command(about = "nomisync CI tasks")]
28struct Cli {
29    #[command(subcommand)]
30    command: Command,
31}
32
33#[derive(Subcommand)]
34enum Command {
35    /// Run a command against a throwaway, migrated Postgres.
36    Postgres {
37        /// The command and its arguments.
38        #[arg(required = true, trailing_var_arg = true)]
39        argv: Vec<String>,
40    },
41    /// Instrumented test run, grcov, and the coverage payload.
42    Coverage,
43    /// Docs and the static musl binaries, and the release payload.
44    Release,
45    /// Verify both published artifacts belong together, then write the manifest.
46    Finalize,
47}
48
49fn main() -> Result<()> {
50    match Cli::parse().command {
51        Command::Postgres { argv } => with_postgres(&argv),
52        Command::Coverage => produce(Kind::Coverage),
53        Command::Release => produce(Kind::Release),
54        Command::Finalize => finalize(),
55    }
56}
57
58/// The single writer of the manifest.
59///
60/// Runs after both producers, holds the write credential, and is master-only —
61/// a PR run never publishes, so there is nothing to finalize. Being the only
62/// writer is what removes the concurrent-writer case, and with it any need for
63/// a conditional put the object store may not support.
64fn finalize() -> Result<()> {
65    let identity = artifact::Identity::from_env()?;
66    let store = publish::Store::from_env()?;
67    let work =
68        tempfile::TempDir::with_prefix("nomisync-finalize").context("cannot make a temp dir")?;
69
70    let metas: Vec<artifact::Meta> = Kind::ALL
71        .iter()
72        .map(|kind| store.fetch_meta(*kind, &identity, work.path()))
73        .collect::<Result<_>>()?;
74
75    for meta in &metas {
76        let local = store.verify_object(meta, &identity, work.path())?;
77        println!(
78            ">> verified {} ({} bytes)",
79            local.file_name().unwrap_or_default().to_string_lossy(),
80            meta.bytes
81        );
82    }
83
84    let [coverage, release] = metas.as_slice() else {
85        bail!(
86            "expected exactly {} artifacts, found {}",
87            Kind::ALL.len(),
88            metas.len()
89        );
90    };
91    // Same commit, same run, same builder image. Attempts may differ, and that
92    // is deliberate: a failed-only rerun must not force a coverage rebuild.
93    artifact::check_pair(coverage, release).map_err(|err| anyhow::anyhow!("{err}"))?;
94
95    store.write_manifest(&metas, &identity, work.path())?;
96    println!(
97        ">> manifest written for {} run {}",
98        identity.commit, identity.run_id
99    );
100    Ok(())
101}
102
103/// True only where the workflow decided this run may hold the artifacts key —
104/// master. A PR run builds and verifies everything with no credential mounted.
105fn with_artifacts() -> bool {
106    std::env::var("WITH_ARTIFACTS").as_deref() == Ok("1")
107}
108
109fn produce(kind: Kind) -> Result<()> {
110    // The heartbeat starts before the database, so the pulse also covers initdb
111    // and the migrations. Nesting it inside meant a hung startup could still go
112    // quiet for longer than Forgejo tolerates.
113    let heartbeat = Heartbeat::start();
114    heartbeat.phase("starting postgres");
115
116    let runtime = tokio::runtime::Builder::new_current_thread()
117        .enable_all()
118        .build()
119        .context("cannot build a tokio runtime")?;
120    // Both kinds need it: the coverage run executes the suite, and `cargo doc`
121    // recompiles the sqlx macros.
122    let database = runtime.block_on(postgres::start())?;
123    let url = database.database_url();
124
125    match kind {
126        Kind::Coverage => release::coverage(&heartbeat, &url)?,
127        Kind::Release => release::build(&heartbeat, &url)?,
128    }
129    release::package(kind, &heartbeat, with_artifacts())
130}
131
132/// Starts Postgres, exports DATABASE_URL, and runs the given command.
133///
134/// The cluster stops when `Postgres` drops, including on the error paths.
135fn with_postgres(argv: &[String]) -> Result<()> {
136    let runtime = tokio::runtime::Builder::new_current_thread()
137        .enable_all()
138        .build()
139        .context("cannot build a tokio runtime")?;
140    let database = runtime.block_on(postgres::start())?;
141
142    let (program, args) = argv.split_first().context("no command given")?;
143    let status = std::process::Command::new(program)
144        .args(args)
145        .env("DATABASE_URL", database.database_url())
146        .status()
147        .with_context(|| format!("failed to run {program}"))?;
148
149    if !status.success() {
150        bail!("{program} exited with {status}");
151    }
152    Ok(())
153}