🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / ci.rs

use crate::repos::{self, Repo};
use crate::util::now;
use crate::AppState;
use std::path::PathBuf;

/// Fire CI for a repo after a push. Non-blocking: spawns a background task.
///
/// SAFETY: the runner executes code from the pushed commit on the host with no
/// sandbox. It is OFF unless `OKIBI_CI_ENABLE=1`. v1 trusts atsm members only;
/// real isolation (ephemeral Fly machine / container per run) is a follow-up.
pub fn on_push(state: AppState, owner_handle: String, repo: Repo) {
    if std::env::var("OKIBI_CI_ENABLE").ok().as_deref() != Some("1") {
        tracing::info!("CI skipped (OKIBI_CI_ENABLE != 1) for {owner_handle}/{}", repo.name);
        return;
    }
    tokio::spawn(async move {
        if let Err(e) = run(state, owner_handle, repo).await {
            tracing::error!("CI run failed: {e}");
        }
    });
}

async fn run(state: AppState, owner_handle: String, repo: Repo) -> anyhow::Result<()> {
    let cfg = state.cfg.clone();
    let (sha, _subject, _t) = match repos::head_commit(&cfg, &owner_handle, &repo.name, &repo.default_branch) {
        Some(c) => c,
        None => return Ok(()), // empty push / branch delete
    };

    let run_id: i64 = sqlx::query_scalar(
        "INSERT INTO ci_runs (repo_id, sha, ref_name, status, created_at)
         VALUES (?, ?, ?, 'running', ?) RETURNING id",
    )
    .bind(repo.id)
    .bind(&sha)
    .bind(&repo.default_branch)
    .bind(now())
    .fetch_one(&state.db)
    .await?;

    let bare = repos::repo_path(&cfg, &owner_handle, &repo.name);
    let workdir = std::env::temp_dir().join(format!("okibi-ci-{run_id}"));
    let _ = std::fs::remove_dir_all(&workdir);

    let (status, log) = execute(&cfg.git_bin, &bare, &workdir, &sha).await;
    let _ = std::fs::remove_dir_all(&workdir);

    sqlx::query("UPDATE ci_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
        .bind(&status)
        .bind(&log)
        .bind(now())
        .bind(run_id)
        .execute(&state.db)
        .await?;
    tracing::info!("CI {owner_handle}/{} #{run_id} -> {status}", repo.name);
    Ok(())
}

/// Checkout `sha` into a workdir and run the project's CI script.
async fn execute(git_bin: &str, bare: &PathBuf, workdir: &PathBuf, sha: &str) -> (String, String) {
    let mut log = String::new();

    // Clone the bare repo at the pushed sha.
    let clone = tokio::process::Command::new(git_bin)
        .args(["clone", "--quiet"])
        .arg(bare)
        .arg(workdir)
        .output()
        .await;
    if let Err(e) = clone {
        return ("error".into(), format!("clone failed: {e}"));
    }
    let _ = tokio::process::Command::new(git_bin)
        .current_dir(workdir)
        .args(["checkout", "--quiet", sha])
        .output()
        .await;

    let script = ci_script(workdir);
    let script = match script {
        Some(s) => s,
        None => return ("skipped".into(), "no .okibi/ci.sh or script: in .okibi/ci.yml\n".into()),
    };
    log.push_str(&format!("$ running .okibi CI ({} bytes)\n", script.len()));

    let fut = tokio::process::Command::new("bash")
        .current_dir(workdir)
        .arg("-eo")
        .arg("pipefail")
        .arg("-c")
        .arg(&script)
        .env("CI", "true")
        .env("OKIBI_SHA", sha)
        .output();

    match tokio::time::timeout(std::time::Duration::from_secs(600), fut).await {
        Ok(Ok(out)) => {
            log.push_str(&String::from_utf8_lossy(&out.stdout));
            log.push_str(&String::from_utf8_lossy(&out.stderr));
            let status = if out.status.success() { "success" } else { "failed" };
            (status.into(), log)
        }
        Ok(Err(e)) => ("error".into(), format!("{log}\nspawn error: {e}")),
        Err(_) => ("timeout".into(), format!("{log}\nexceeded 600s")),
    }
}

/// Resolve the CI script: prefer `.okibi/ci.sh`, else extract a `script:` list
/// from `.okibi/ci.yml` (tiny line-based parser, no YAML dep).
fn ci_script(workdir: &PathBuf) -> Option<String> {
    let sh = workdir.join(".okibi/ci.sh");
    if let Ok(s) = std::fs::read_to_string(&sh) {
        if !s.trim().is_empty() {
            return Some(s);
        }
    }
    let yml = workdir.join(".okibi/ci.yml");
    let text = std::fs::read_to_string(&yml).ok()?;
    let mut lines = Vec::new();
    let mut in_script = false;
    for raw in text.lines() {
        let trimmed = raw.trim_start();
        if trimmed.starts_with("script:") {
            in_script = true;
            // inline form: `script: echo hi`
            let rest = trimmed.trim_start_matches("script:").trim();
            if !rest.is_empty() && rest != "|" {
                lines.push(rest.trim_matches(|c| c == '"' || c == '\'').to_string());
                in_script = false;
            }
            continue;
        }
        if in_script {
            if let Some(item) = trimmed.strip_prefix("- ") {
                lines.push(item.trim_matches(|c| c == '"' || c == '\'').to_string());
            } else if trimmed.is_empty() {
                continue;
            } else if !raw.starts_with(' ') {
                in_script = false; // dedent → end of block
            } else {
                lines.push(trimmed.to_string());
            }
        }
    }
    if lines.is_empty() {
        None
    } else {
        Some(lines.join("\n"))
    }
}