Build and run the per-run container in docker mode
Wires Executor::Docker through Run::execute: build → start → run, with
container.yml stamped incrementally and torn down via RAII when the
runtime drops. The Active transition runs before docker setup so the
container.yml path stays stable for DockerLifecycle's lifetime; the
runtime is dropped before the final transition so container_stopped_at
lands while the run is still in active/.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change lrtvtxswtpwqvzkwvppplourotuvulst
commit 5039993bb56ae41de5fd3649aff632f086a79eac
author Alpha Chen <alpha@kejadlen.dev>
date
parent omuvzyqr
diff --git a/src/ci/mirror.rs b/src/ci/mirror.rs
index aed4418..395c6c7 100644
--- a/src/ci/mirror.rs
+++ b/src/ci/mirror.rs
@@ -237,7 +237,7 @@ mod tests {
 
     use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
     use crate::ci::run::RunMeta;
-    use crate::ci::runtime::RuntimeHandle;
+    use crate::ci::runtime::{ExecutorRuntime, RuntimeHandle};
     use crate::secret::SecretString;
 
     /// Set up a bare git repo with one commit. Returns the tempdir,
@@ -447,6 +447,7 @@ mod tests {
             meta,
             git_dir,
             std::env::current_dir().expect("cwd"),
+            ExecutorRuntime::Host,
         ));
         let _ = RuntimeHandle(runtime.clone())
             .into_lua(runtime.lua())
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 532bc1a..72f49ac 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -13,7 +13,7 @@ use jiff::Timestamp;
 use mlua::IntoLua;
 
 use super::pipeline::{Pipeline, RunFn};
-use super::runtime::{Runtime, RuntimeHandle, ShOutput};
+use super::runtime::{ExecutorRuntime, Runtime, RuntimeHandle, ShOutput};
 use crate::display_chain;
 use crate::secret::SecretString;
 use crate::{Error, Result};
@@ -23,7 +23,47 @@ use crate::{Error, Result};
 #[derive(Debug, Clone)]
 pub enum Executor {
     Host,
-    // Docker variant added in Task 5.
+    Docker,
+}
+
+/// Owns a [`ContainerSession`](crate::ci::docker::ContainerSession)
+/// alongside the run-dir's `container.yml` path so [`Drop`] can stamp
+/// `container_stopped_at` *before* the session itself drops and fires
+/// `docker stop`.
+///
+/// Field declaration order matters: `session` is declared first so it
+/// drops first after this struct's custom `Drop` body returns. The
+/// effect is: write `container_stopped_at` → drop `session` →
+/// `docker stop`.
+pub(super) struct DockerLifecycle {
+    #[allow(dead_code)] // Held for its Drop; read in Task 9 via Runtime::sh.
+    session: crate::ci::docker::ContainerSession,
+    record_path: PathBuf,
+}
+
+impl Drop for DockerLifecycle {
+    fn drop(&mut self) {
+        // Stamp `container_stopped_at` before ContainerSession's Drop
+        // (`docker stop`) fires. Errors are logged and swallowed —
+        // Drop cannot return Result.
+        match read_yaml::<ContainerRecord>(&self.record_path) {
+            Ok(mut rec) => {
+                rec.container_stopped_at = Some(Timestamp::now());
+                if let Err(e) = write_yaml(&self.record_path, &rec) {
+                    tracing::error!(
+                        error = %display_chain(&e),
+                        "failed to write container_stopped_at"
+                    );
+                }
+            }
+            Err(e) => tracing::error!(
+                error = %display_chain(&e),
+                "failed to read container.yml before stop"
+            ),
+        }
+        // After this body returns, fields drop in declaration order:
+        // `session` drops first → ContainerSession::Drop → docker stop.
+    }
 }
 
 /// The state of a CI run.
@@ -305,17 +345,33 @@ impl Run {
         workspace: &std::path::Path,
         executor: Executor,
     ) -> Result<HashMap<String, Vec<ShOutput>>> {
-        // `executor` is not yet read; later tasks wire it into the
-        // per-run container lifecycle.
-        let _ = executor;
         let meta = self.read_meta()?;
 
+        // Transition to Active *before* building/starting the
+        // container. The docker build can take a long time and
+        // happens with the run in `active/` so the on-disk state
+        // accurately reflects "this run is in progress." It also
+        // pins `self.path()` for the lifetime of the run, so the
+        // `container.yml` path captured by `DockerLifecycle` stays
+        // valid until just before the final Complete/Failed
+        // transition (where we explicitly drop the runtime first).
+        self.transition(RunState::Active)?;
+
+        let executor_runtime = match self.build_executor_runtime(executor, workspace) {
+            Ok(rt) => rt,
+            Err(e) => {
+                self.transition(RunState::Failed)?;
+                return Err(e);
+            }
+        };
+
         let runtime = Rc::new(Runtime::new(
             pipeline,
             secrets,
             &meta,
             git_dir,
             workspace.to_path_buf(),
+            executor_runtime,
         ));
 
         let lua = runtime.lua();
@@ -323,8 +379,6 @@ impl Run {
             .into_lua(lua)
             .expect("install runtime on Lua VM");
 
-        self.transition(RunState::Active)?;
-
         let mut failed_job: Option<(String, Error)> = None;
         for job_id in runtime.topo_order() {
             let run_fn = runtime
@@ -356,6 +410,16 @@ impl Run {
 
         self.write_all_logs(&outputs)?;
 
+        // Drop the runtime *before* the final transition. In docker
+        // mode this fires `DockerLifecycle::drop`, which stamps
+        // `container_stopped_at` in `<run-dir>/container.yml`. The
+        // path it captured is still valid here (the run is in
+        // active/); the subsequent transition moves the file into
+        // place with the rest of the run dir.
+        drop(rt_value);
+        let _ = lua; // release the Lua borrow tied to `runtime`.
+        drop(runtime);
+
         if let Some((job, source)) = failed_job {
             self.transition(RunState::Failed)?;
             return Err(Error::JobFailed {
@@ -368,6 +432,49 @@ impl Run {
         Ok(outputs)
     }
 
+    /// Build the per-run container if `executor` is `Docker`, writing
+    /// `container.yml` incrementally as each phase completes. Run must
+    /// already be in `active/` so `self.path()` is stable for the
+    /// lifetime of the returned [`DockerLifecycle`].
+    fn build_executor_runtime(
+        &self,
+        executor: Executor,
+        workspace: &std::path::Path,
+    ) -> Result<ExecutorRuntime> {
+        match executor {
+            Executor::Host => Ok(ExecutorRuntime::Host),
+            Executor::Docker => {
+                let mut record = ContainerRecord::default();
+
+                // Build phase.
+                record.build_started_at = Some(Timestamp::now());
+                self.write_container_record(&record)?;
+
+                let dockerfile = workspace.join(".quire/Dockerfile");
+                let tag = format!("quire-ci/{}:{}", repo_segment(&self.base), self.id);
+
+                crate::ci::docker::docker_build(&dockerfile, workspace, &tag)?;
+
+                record.image_tag = Some(tag.clone());
+                record.build_finished_at = Some(Timestamp::now());
+                self.write_container_record(&record)?;
+
+                // Start phase.
+                let session =
+                    crate::ci::docker::ContainerSession::start(&tag, workspace, "/work")?;
+
+                record.container_id = Some(session.container_id.clone());
+                record.container_started_at = Some(session.container_started_at);
+                self.write_container_record(&record)?;
+
+                Ok(ExecutorRuntime::Docker(DockerLifecycle {
+                    session,
+                    record_path: self.path().join("container.yml"),
+                }))
+            }
+        }
+    }
+
     /// Write per-job log files from the captured `(sh …)` outputs.
     ///
     /// Creates `jobs/<job-id>/log.yml` in the run directory for each
@@ -479,6 +586,16 @@ impl Run {
     }
 }
 
+/// Take the final path component of a runs base (`runs/<repo>/`) for
+/// use as the tag segment in `quire-ci/<segment>:<id>`. Falls back to
+/// `repo` when the path has no name or it isn't UTF-8.
+fn repo_segment(base: &Path) -> String {
+    base.file_name()
+        .and_then(|s| s.to_str())
+        .map(str::to_owned)
+        .unwrap_or_else(|| "repo".to_string())
+}
+
 /// Materialize a working tree at `sha` into `workspace` via
 /// `git archive | tar -x`. Creates the workspace dir if needed.
 pub fn materialize_workspace(
@@ -1509,4 +1626,119 @@ mod tests {
         let read = run.read_container_record().expect("read");
         assert_eq!(read, record);
     }
+
+    #[test]
+    fn repo_segment_returns_final_component() {
+        assert_eq!(repo_segment(Path::new("runs/test.git")), "test.git");
+        assert_eq!(repo_segment(Path::new("/var/lib/quire/runs/repo.git")), "repo.git");
+        assert_eq!(repo_segment(Path::new("")), "repo");
+    }
+
+    #[test]
+    #[ignore = "requires docker"]
+    fn execute_docker_mode_runs_jobs_in_container() {
+        if !crate::ci::docker::is_available() {
+            return;
+        }
+
+        // Build a real git repo with a Dockerfile committed at HEAD.
+        let dir = tempfile::tempdir().expect("tempdir");
+        let src_repo = dir.path().join("src");
+        fs_err::create_dir_all(&src_repo).expect("mkdir src");
+
+        let env_vars: [(&str, &str); 6] = [
+            ("GIT_AUTHOR_NAME", "test"),
+            ("GIT_AUTHOR_EMAIL", "test@test"),
+            ("GIT_COMMITTER_NAME", "test"),
+            ("GIT_COMMITTER_EMAIL", "test@test"),
+            ("GIT_CONFIG_GLOBAL", "/dev/null"),
+            ("GIT_CONFIG_SYSTEM", "/dev/null"),
+        ];
+        for cmd in [vec!["init", "-b", "main"]] {
+            let out = std::process::Command::new("git")
+                .args(&cmd)
+                .current_dir(&src_repo)
+                .envs(env_vars)
+                .output()
+                .expect("git");
+            assert!(out.status.success());
+        }
+        fs_err::create_dir_all(src_repo.join(".quire")).expect("mkdir .quire");
+        fs_err::write(
+            src_repo.join(".quire/Dockerfile"),
+            "FROM alpine:3.19\n",
+        )
+        .expect("write Dockerfile");
+        for cmd in [vec!["add", "."], vec!["commit", "-m", "initial"]] {
+            let out = std::process::Command::new("git")
+                .args(&cmd)
+                .current_dir(&src_repo)
+                .envs(env_vars)
+                .output()
+                .expect("git");
+            assert!(out.status.success());
+        }
+        let sha = String::from_utf8(
+            std::process::Command::new("git")
+                .args(["rev-parse", "HEAD"])
+                .current_dir(&src_repo)
+                .envs(env_vars)
+                .output()
+                .expect("rev-parse")
+                .stdout,
+        )
+        .unwrap()
+        .trim()
+        .to_string();
+
+        let workspace = dir.path().join("ws");
+        materialize_workspace(&src_repo.join(".git"), &sha, &workspace).expect("materialize");
+
+        let (_qd, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let meta = RunMeta {
+            sha,
+            r#ref: "refs/heads/main".to_string(),
+            pushed_at: "2026-05-04T12:00:00Z".parse().unwrap(),
+        };
+        let run = runs.create(&meta).expect("create");
+        let run_id = run.id().to_string();
+
+        // Pipeline runs ONE job that does nothing — Task 9 will wire
+        // sh through docker exec; this test just confirms the
+        // lifecycle works.
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :probe [:quire/push] (fn [_] nil))"#,
+        );
+
+        run.execute(
+            pipeline,
+            HashMap::new(),
+            &src_repo.join(".git"),
+            &workspace,
+            Executor::Docker,
+        )
+        .expect("execute");
+
+        // Verify container.yml was written with all fields.
+        let complete = runs.base.join(RunState::Complete.dir_name()).join(&run_id);
+        let record_path = complete.join("container.yml");
+        assert!(record_path.exists(), "container.yml should exist");
+        let record: ContainerRecord =
+            serde_yaml_ng::from_str(&fs_err::read_to_string(&record_path).unwrap()).unwrap();
+        assert!(record.image_tag.is_some());
+        assert!(record.container_id.is_some());
+        assert!(record.build_started_at.is_some());
+        assert!(record.build_finished_at.is_some());
+        assert!(record.container_started_at.is_some());
+        assert!(record.container_stopped_at.is_some());
+
+        // Cleanup the image we built.
+        if let Some(tag) = record.image_tag {
+            let _ = std::process::Command::new("docker")
+                .args(["image", "rm", &tag])
+                .output();
+        }
+    }
 }
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 8763a99..6e344b7 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -13,9 +13,20 @@ use std::rc::Rc;
 use mlua::{IntoLua, Lua, LuaSerdeExt};
 
 use super::pipeline::{Job, Pipeline};
-use super::run::RunMeta;
+use super::run::{DockerLifecycle, RunMeta};
 use crate::secret::SecretString;
 
+/// The runtime-side carrier for the chosen [`Executor`](super::run::Executor).
+/// `Host` runs `sh` directly on the host. `Docker` owns a
+/// [`DockerLifecycle`] whose Drop tears down the per-run container —
+/// the variant's payload is unread until Task 9 routes `sh` through
+/// `docker exec`.
+pub(super) enum ExecutorRuntime {
+    Host,
+    #[allow(dead_code)]
+    Docker(DockerLifecycle),
+}
+
 /// Per-execution runtime: owns the Lua VM, holds the secrets exposed
 /// to the job, the per-job `(jobs name)` views, the current-job
 /// cursor, and the per-job captured `sh` outputs.
@@ -46,6 +57,9 @@ pub(super) struct Runtime {
     /// The materialized workspace for this run. Every `(sh …)` call
     /// runs here.
     workspace: std::path::PathBuf,
+    /// The chosen executor. `Host` is a no-op; `Docker` owns the
+    /// per-run container's lifecycle (teardown on Drop).
+    executor: ExecutorRuntime,
 }
 
 impl Runtime {
@@ -66,6 +80,7 @@ impl Runtime {
         meta: &RunMeta,
         git_dir: &std::path::Path,
         workspace: std::path::PathBuf,
+        executor: ExecutorRuntime,
     ) -> Self {
         let transitive = pipeline.transitive_inputs();
         let lua = pipeline.fennel().lua();
@@ -105,6 +120,7 @@ impl Runtime {
             current_job: RefCell::new(None),
             outputs: RefCell::new(HashMap::new()),
             workspace,
+            executor,
         }
     }
 
@@ -193,6 +209,7 @@ impl Runtime {
             current_job: RefCell::new(None),
             outputs: RefCell::new(HashMap::new()),
             workspace: std::env::current_dir().expect("cwd"),
+            executor: ExecutorRuntime::Host,
         }
     }
 }