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

            
12
mod artifact;
13
mod clean;
14
mod coverage;
15
mod deploy_render;
16
mod heartbeat;
17
mod postgres;
18
mod publish;
19
mod release;
20
mod watchdog;
21

            
22
#[cfg(test)]
23
mod test_support;
24

            
25
use anyhow::{Context, Result, bail};
26
use clap::{Parser, Subcommand};
27

            
28
use artifact::Kind;
29
use heartbeat::Heartbeat;
30

            
31
#[derive(Parser)]
32
#[command(about = "nomisync CI tasks")]
33
struct Cli {
34
    #[command(subcommand)]
35
    command: Command,
36
}
37

            
38
#[derive(Subcommand)]
39
enum 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

            
54
fn 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.
69
fn 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.
110
fn with_artifacts() -> bool {
111
    std::env::var("WITH_ARTIFACTS").as_deref() == Ok("1")
112
}
113

            
114
fn 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.
140
fn 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
}