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