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 deploy_render;
14
mod heartbeat;
15
mod postgres;
16
mod publish;
17
mod release;
18
mod watchdog;
19

            
20
use anyhow::{Context, Result, bail};
21
use clap::{Parser, Subcommand};
22

            
23
use artifact::Kind;
24
use heartbeat::Heartbeat;
25

            
26
#[derive(Parser)]
27
#[command(about = "nomisync CI tasks")]
28
struct Cli {
29
    #[command(subcommand)]
30
    command: Command,
31
}
32

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

            
49
fn 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.
64
fn 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.
105
fn with_artifacts() -> bool {
106
    std::env::var("WITH_ARTIFACTS").as_deref() == Ok("1")
107
}
108

            
109
fn 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.
135
fn 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
}