yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
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"))
}
}