Lines
0 %
Functions
Branches
100 %
//! The nomisync CI pipeline, as a program.
//!
//! Invoked from `.forgejo/workflows/ci.yml` as `cargo run -p xtask -- <command>`.
//! Deliberately dependency-light: this is the first thing a CI job builds, and
//! it starts the database the rest of the workspace compiles against, so it
//! must not itself need one.
//! What stays outside: `.forgejo/scripts/deploy.sh` runs in a kaniko init
//! container that has no Rust toolchain, and `ci/builder/build.sh` builds the
//! container image this program runs inside.
mod artifact;
mod deploy_render;
mod heartbeat;
mod postgres;
mod publish;
mod release;
mod watchdog;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use artifact::Kind;
use heartbeat::Heartbeat;
#[derive(Parser)]
#[command(about = "nomisync CI tasks")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Run a command against a throwaway, migrated Postgres.
Postgres {
/// The command and its arguments.
#[arg(required = true, trailing_var_arg = true)]
argv: Vec<String>,
},
/// Instrumented test run, grcov, and the coverage payload.
Coverage,
/// Docs and the static musl binaries, and the release payload.
Release,
/// Verify both published artifacts belong together, then write the manifest.
Finalize,
fn main() -> Result<()> {
match Cli::parse().command {
Command::Postgres { argv } => with_postgres(&argv),
Command::Coverage => produce(Kind::Coverage),
Command::Release => produce(Kind::Release),
Command::Finalize => finalize(),
/// The single writer of the manifest.
///
/// Runs after both producers, holds the write credential, and is master-only —
/// a PR run never publishes, so there is nothing to finalize. Being the only
/// writer is what removes the concurrent-writer case, and with it any need for
/// a conditional put the object store may not support.
fn finalize() -> Result<()> {
let identity = artifact::Identity::from_env()?;
let store = publish::Store::from_env()?;
let work =
tempfile::TempDir::with_prefix("nomisync-finalize").context("cannot make a temp dir")?;
let metas: Vec<artifact::Meta> = Kind::ALL
.iter()
.map(|kind| store.fetch_meta(*kind, &identity, work.path()))
.collect::<Result<_>>()?;
for meta in &metas {
let local = store.verify_object(meta, &identity, work.path())?;
println!(
">> verified {} ({} bytes)",
local.file_name().unwrap_or_default().to_string_lossy(),
meta.bytes
);
let [coverage, release] = metas.as_slice() else {
bail!(
"expected exactly {} artifacts, found {}",
Kind::ALL.len(),
metas.len()
};
// Same commit, same run, same builder image. Attempts may differ, and that
// is deliberate: a failed-only rerun must not force a coverage rebuild.
artifact::check_pair(coverage, release).map_err(|err| anyhow::anyhow!("{err}"))?;
store.write_manifest(&metas, &identity, work.path())?;
">> manifest written for {} run {}",
identity.commit, identity.run_id
Ok(())
/// True only where the workflow decided this run may hold the artifacts key —
/// master. A PR run builds and verifies everything with no credential mounted.
fn with_artifacts() -> bool {
std::env::var("WITH_ARTIFACTS").as_deref() == Ok("1")
fn produce(kind: Kind) -> Result<()> {
// The heartbeat starts before the database, so the pulse also covers initdb
// and the migrations. Nesting it inside meant a hung startup could still go
// quiet for longer than Forgejo tolerates.
let heartbeat = Heartbeat::start();
heartbeat.phase("starting postgres");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("cannot build a tokio runtime")?;
// Both kinds need it: the coverage run executes the suite, and `cargo doc`
// recompiles the sqlx macros.
let database = runtime.block_on(postgres::start())?;
let url = database.database_url();
match kind {
Kind::Coverage => release::coverage(&heartbeat, &url)?,
Kind::Release => release::build(&heartbeat, &url)?,
release::package(kind, &heartbeat, with_artifacts())
/// Starts Postgres, exports DATABASE_URL, and runs the given command.
/// The cluster stops when `Postgres` drops, including on the error paths.
fn with_postgres(argv: &[String]) -> Result<()> {
let (program, args) = argv.split_first().context("no command given")?;
let status = std::process::Command::new(program)
.args(args)
.env("DATABASE_URL", database.database_url())
.status()
.with_context(|| format!("failed to run {program}"))?;
if !status.success() {
bail!("{program} exited with {status}");