Add GET /api/runs/:id/bootstrap endpoint for quire-ci API transport
Implements slice 2 of the quire-ci ↔ quire-server API migration
(see docs/plans/2026-05-14-quire-ci-server-api-design.md).

Server changes:
- Migration 0004: adds git_dir, sentry_dsn, sentry_trace_id, and
  bootstrap_fetched columns to the runs table
- Run::store_bootstrap_data persists bootstrap fields to the DB when
  the API transport is active, called from execute() before spawning
- execute() skips writing bootstrap.json and omits --bootstrap in API
  mode; quire-ci fetches bootstrap via the new endpoint instead
- GET /api/runs/:id/bootstrap returns the Bootstrap payload (meta,
  git_dir, sentry); one-shot: sets bootstrap_fetched=1 and returns
  410 Gone on any subsequent call; 404 if git_dir was never stored

quire-ci changes:
- --bootstrap is now optional; filesystem transport still requires it,
  API transport omits it and calls fetch_bootstrap() instead
- ApiClient::fetch_bootstrap() calls GET /api/runs/:id/bootstrap and
  deserializes the Bootstrap response
- main() dispatches bootstrap loading based on transport.mode
change
commit 60ce4f968799d02b14de78744306f33e9b32832a
author Claude <noreply@anthropic.com>
date
parent 1718baf6
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index d09d4b6..77cdb5a 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -7,7 +7,7 @@ use std::rc::Rc;
 
 use facet::Facet;
 use figue::{self as args, Driver, FigueBuiltins};
-use miette::{IntoDiagnostic, Result};
+use miette::{IntoDiagnostic, Result, miette};
 use quire_core::api::SecretResponse;
 use quire_core::ci::bootstrap::Bootstrap;
 use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
@@ -114,9 +114,10 @@ enum Commands {
         out_dir: Option<PathBuf>,
 
         /// Path to a JSON bootstrap file produced by the orchestrator.
-        /// Carries push metadata and the Sentry trace id.
-        #[facet(args::named)]
-        bootstrap: PathBuf,
+        /// Required for `filesystem` transport; omitted for `api`
+        /// transport, which fetches bootstrap via the server API instead.
+        #[facet(args::named, default)]
+        bootstrap: Option<PathBuf>,
     },
 }
 
@@ -207,6 +208,36 @@ impl ApiClient {
         Self { session }
     }
 
+    /// Fetch the bootstrap payload from the server API.
+    ///
+    /// One-shot: the server marks the bootstrap as fetched after the first
+    /// successful call and returns 410 on any subsequent call.
+    fn fetch_bootstrap(&self) -> Result<(PathBuf, RunMeta, Option<String>)> {
+        let url = format!(
+            "{}/api/runs/{}/bootstrap",
+            self.session.server_url, self.session.run_id
+        );
+        let token = self.session.auth_token.clone();
+        tokio::runtime::Handle::current().block_on(async move {
+            let resp = reqwest::Client::new()
+                .get(&url)
+                .bearer_auth(&token)
+                .send()
+                .await
+                .map_err(|e| miette!("bootstrap request failed: {e}"))?;
+
+            let status = resp.status();
+            if !status.is_success() {
+                miette::bail!("bootstrap API returned {status}");
+            }
+            let bootstrap: Bootstrap = resp
+                .json()
+                .await
+                .map_err(|e| miette!("failed to parse bootstrap response: {e}"))?;
+            Ok((bootstrap.git_dir, bootstrap.meta, bootstrap.sentry_trace_id))
+        })
+    }
+
     /// Fetch a single secret by name from the server.
     fn fetch_secret(&self, name: &str) -> SecretResult<String> {
         let url = format!(
@@ -282,9 +313,6 @@ fn main() -> Result<()> {
                     (path, Some(DumpLogsOnDrop { dir }))
                 }
             };
-
-            let (git_dir, meta, sentry_trace_id) = load_bootstrap(&bootstrap)?;
-
             // Sentry's reqwest transport spawns Tokio tasks for HTTP
             // sends, so the client must be constructed and dropped from
             // within a runtime context. A single worker thread is
@@ -297,6 +325,27 @@ fn main() -> Result<()> {
                 .into_diagnostic()?;
             let _enter = rt.enter();
 
+            let session = ApiSession {
+                run_id: cli.quire.run_id,
+                server_url: cli.quire.server_url,
+                auth_token: cli.quire.auth_token,
+            };
+            let transport = Transport {
+                session: session.clone(),
+                mode: cli.quire.transport,
+            };
+            let client = ApiClient::new(session);
+
+            let (git_dir, meta, sentry_trace_id) = match transport.mode {
+                TransportMode::Api => client.fetch_bootstrap()?,
+                TransportMode::Filesystem => {
+                    let path = bootstrap.ok_or_else(|| {
+                        miette!("--bootstrap is required for filesystem transport")
+                    })?;
+                    load_bootstrap(&path)?
+                }
+            };
+
             // Drop order: `_sentry` flushes first (still inside the
             // runtime), then `_enter`, then `rt`.
             let _sentry = cli
@@ -313,17 +362,6 @@ fn main() -> Result<()> {
             let miette_layer = MietteLayer::new();
             telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
 
-            let session = ApiSession {
-                run_id: cli.quire.run_id,
-                server_url: cli.quire.server_url,
-                auth_token: cli.quire.auth_token,
-            };
-            let transport = Transport {
-                session: session.clone(),
-                mode: cli.quire.transport,
-            };
-
-            let client = ApiClient::new(session);
             let registry = SecretRegistry::new(move |name| client.fetch_secret(name));
 
             run_pipeline(workspace, sink, log_dir, git_dir, meta, registry, transport)
diff --git a/quire-server/migrations/0004_bootstrap_api.sql b/quire-server/migrations/0004_bootstrap_api.sql
new file mode 100644
index 0000000..07ca49a
--- /dev/null
+++ b/quire-server/migrations/0004_bootstrap_api.sql
@@ -0,0 +1,2 @@
+ALTER TABLE runs ADD COLUMN git_dir TEXT;
+ALTER TABLE runs ADD COLUMN sentry_trace_id TEXT;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 9c23569..ad1cbdd 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -321,8 +321,6 @@ impl Run {
         let log = fs_err::File::create(&log_path)?.into_parts().0;
         let log_clone = log.try_clone()?;
 
-        write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
-
         tracing::info!(
             run_id = %self.id,
             log = %log_path.display(),
@@ -337,17 +335,27 @@ impl Run {
             .arg("--out-dir")
             .arg(&run_dir)
             .arg("--events")
-            .arg(&events_path)
-            .arg("--bootstrap")
-            .arg(&bootstrap_path);
-
-        if let Some(t) = transport {
-            cmd.env("QUIRE__RUN_ID", &t.session.run_id);
-            cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
-            cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
-            if t.mode == TransportMode::Api {
+            .arg(&events_path);
+
+        match transport {
+            None => {
+                write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
+                cmd.arg("--bootstrap").arg(&bootstrap_path);
+            }
+            Some(t) if t.mode == TransportMode::Api => {
+                self.store_bootstrap_data(git_dir, sentry_trace_id)?;
+                cmd.env("QUIRE__RUN_ID", &t.session.run_id);
+                cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
+                cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
                 cmd.env("QUIRE__TRANSPORT", "api");
             }
+            Some(t) => {
+                write_bootstrap(&bootstrap_path, git_dir, meta, sentry_trace_id)?;
+                cmd.arg("--bootstrap").arg(&bootstrap_path);
+                cmd.env("QUIRE__RUN_ID", &t.session.run_id);
+                cmd.env("QUIRE__SERVER_URL", &t.session.server_url);
+                cmd.env("QUIRE__AUTH_TOKEN", &t.session.auth_token);
+            }
         }
         if let Some(dsn) = sentry_dsn {
             cmd.env("QUIRE__SENTRY_DSN", dsn);
@@ -501,6 +509,30 @@ impl Run {
         Ok(run_outcome)
     }
 
+    /// Persist bootstrap data in the DB so the API endpoint can serve it.
+    ///
+    /// Called by `execute` when the API transport is active, before spawning
+    /// quire-ci. quire-ci fetches this via `GET /api/runs/:id/bootstrap`
+    /// instead of reading a file.
+    fn store_bootstrap_data(
+        &self,
+        git_dir: &Path,
+        sentry_trace_id: Option<&str>,
+    ) -> Result<()> {
+        let git_dir_str = git_dir.to_str().ok_or_else(|| {
+            std::io::Error::new(
+                std::io::ErrorKind::InvalidData,
+                "git_dir path is not valid UTF-8",
+            )
+        })?;
+        let db = crate::db::open(&self.db_path)?;
+        db.execute(
+            "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
+            rusqlite::params![git_dir_str, sentry_trace_id, &self.id],
+        )?;
+        Ok(())
+    }
+
     /// Transition the run from its current state to a new state.
     ///
     /// Allowed edges (see `docs/CI-STATE.md`):
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index 0b8770b..8bc3f9f 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -15,6 +15,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
         M::up(include_str!("../migrations/0001_initial.sql")),
         M::up(include_str!("../migrations/0002_sh_events.sql")),
         M::up(include_str!("../migrations/0003_ci_api.sql")),
+        M::up(include_str!("../migrations/0004_bootstrap_api.sql")),
     ])
 });
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index bf9f37e..253ad57 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -5,6 +5,7 @@
 //! is created and scoped to that run's ID.
 
 use std::collections::HashMap;
+use std::path::PathBuf;
 
 use axum::extract::{FromRequestParts, Path as AxumPath, State};
 use axum::http::StatusCode;
@@ -13,6 +14,8 @@ use axum::response::{IntoResponse, Response, Result};
 use axum_extra::TypedHeader;
 use axum_extra::headers::Authorization;
 use axum_extra::headers::authorization::Bearer;
+use quire_core::ci::bootstrap::Bootstrap;
+use quire_core::ci::run::RunMeta;
 
 use crate::Quire;
 
@@ -23,6 +26,7 @@ use crate::Quire;
 /// Intended to be mounted at `/api` via `Router::nest`.
 pub fn router(quire: Quire) -> axum::Router {
     let run_routes = axum::Router::new()
+        .route("/bootstrap", axum::routing::get(get_bootstrap))
         .route("/secrets/{name}", axum::routing::get(get_secret))
         .layer(axum::middleware::from_fn_with_state(
             quire.clone(),
@@ -40,6 +44,8 @@ enum ApiError {
     NotFound,
     #[error("unauthorized")]
     Unauthorized,
+    #[error("gone")]
+    Gone,
     #[error(transparent)]
     Db(rusqlite::Error),
     #[error(transparent)]
@@ -62,6 +68,7 @@ impl IntoResponse for ApiError {
         match self {
             ApiError::NotFound => StatusCode::NOT_FOUND.into_response(),
             ApiError::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
+            ApiError::Gone => StatusCode::GONE.into_response(),
             e => {
                 tracing::error!(error = %e, "api error");
                 StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -130,6 +137,81 @@ async fn verify_bearer(
     Ok(next.run(req).await)
 }
 
+/// `GET /api/runs/:run_id/bootstrap`
+///
+/// Returns the bootstrap payload for a run. One-shot: the server marks
+/// bootstrap as fetched on the first successful read and returns 410 on
+/// any subsequent call. Auth is handled by [`verify_bearer`] middleware.
+///
+/// Returns 404 if the run does not have API bootstrap data (e.g. the run
+/// was created with filesystem transport and `store_bootstrap_data` was
+/// never called).
+async fn get_bootstrap(
+    State(quire): State<Quire>,
+    AxumPath(params): AxumPath<HashMap<String, String>>,
+) -> Result<axum::Json<Bootstrap>, ApiError> {
+    let run_id = params.get("run_id").cloned().ok_or(ApiError::NotFound)?;
+
+    let bootstrap =
+        tokio::task::spawn_blocking(move || -> std::result::Result<Bootstrap, ApiError> {
+            let db = crate::db::open(&quire.db_path()).map_err(ApiError::Db)?;
+
+            let (sha, ref_name, pushed_at_ms, git_dir_opt, sentry_trace_id, state): (
+                String,
+                String,
+                i64,
+                Option<String>,
+                Option<String>,
+                String,
+            ) = db
+                .query_row(
+                    "SELECT sha, ref_name, pushed_at_ms, git_dir, sentry_trace_id, state
+                     FROM runs WHERE id = ?1",
+                    rusqlite::params![run_id],
+                    |row| {
+                        Ok((
+                            row.get(0)?,
+                            row.get(1)?,
+                            row.get(2)?,
+                            row.get(3)?,
+                            row.get(4)?,
+                            row.get(5)?,
+                        ))
+                    },
+                )
+                .map_err(ApiError::from)?;
+
+            if state != "pending" {
+                return Err(ApiError::Gone);
+            }
+
+            let git_dir: PathBuf = git_dir_opt.map(PathBuf::from).ok_or(ApiError::NotFound)?;
+
+            let meta = RunMeta {
+                sha,
+                r#ref: ref_name,
+                pushed_at: jiff::Timestamp::from_millisecond(pushed_at_ms)
+                    .expect("db stores valid timestamps"),
+            };
+
+            let started_at_ms = jiff::Timestamp::now().as_millisecond();
+            db.execute(
+                "UPDATE runs SET state = 'active', started_at_ms = ?1 WHERE id = ?2",
+                rusqlite::params![started_at_ms, run_id],
+            )?;
+
+            Ok(Bootstrap {
+                meta,
+                git_dir,
+                sentry_trace_id,
+            })
+        })
+        .await
+        .expect("blocking task panicked")?;
+
+    Ok(axum::Json(bootstrap))
+}
+
 /// `GET /api/runs/:run_id/secrets/:name`
 ///
 /// Returns the plain-text value of a named secret from the global config.
@@ -260,6 +342,147 @@ mod tests {
         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
     }
 
+    async fn create_run_with_bootstrap(
+        env: &TestEnv,
+        transport: &crate::ci::Transport,
+        git_dir: &str,
+        sentry_trace_id: Option<&str>,
+    ) -> String {
+        let run = env
+            .runs()
+            .create(&TestEnv::meta(), Some(transport))
+            .expect("create run");
+        let run_id = run.id().to_string();
+
+        let db = crate::db::open(&env.quire.db_path()).expect("db open");
+        db.execute(
+            "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
+            rusqlite::params![git_dir, sentry_trace_id, &run_id],
+        )
+        .expect("update bootstrap data");
+        run_id
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_401_without_auth() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        let run_id =
+            create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+        let url = format!("/runs/{run_id}/bootstrap");
+
+        let resp = get(env.app(), &url, None).await;
+        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_404_for_unknown_run() {
+        let env = TestEnv::new();
+        let resp = get(
+            env.app(),
+            "/runs/00000000-0000-0000-0000-000000000001/bootstrap",
+            Some("token"),
+        )
+        .await;
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_404_when_git_dir_not_stored() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        env.runs()
+            .create(&TestEnv::meta(), Some(&transport))
+            .expect("create run");
+        let url = format!("/runs/{}/bootstrap", transport.session.run_id);
+
+        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_payload_on_first_fetch() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        let run_id =
+            create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+        let url = format!("/runs/{run_id}/bootstrap");
+
+        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        assert_eq!(resp.status(), StatusCode::OK);
+
+        use http_body_util::BodyExt;
+        let body = resp.into_body().collect().await.unwrap().to_bytes();
+        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+        assert_eq!(parsed["git_dir"], "/repos/test.git");
+        assert_eq!(parsed["meta"]["sha"], "abc1".repeat(10));
+        assert_eq!(parsed["meta"]["ref"], "refs/heads/main");
+        assert!(parsed["sentry_trace_id"].is_null());
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_sentry_trace_id_when_stored() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        let run_id = create_run_with_bootstrap(
+            &env,
+            &transport,
+            "/repos/test.git",
+            Some("aaaabbbbccccdddd0000111122223333"),
+        )
+        .await;
+        let url = format!("/runs/{run_id}/bootstrap");
+
+        let resp = get(env.app(), &url, Some(&transport.session.auth_token)).await;
+        assert_eq!(resp.status(), StatusCode::OK);
+
+        use http_body_util::BodyExt;
+        let body = resp.into_body().collect().await.unwrap().to_bytes();
+        let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
+        assert_eq!(
+            parsed["sentry_trace_id"],
+            "aaaabbbbccccdddd0000111122223333"
+        );
+    }
+
+    #[tokio::test]
+    async fn bootstrap_returns_410_on_second_fetch() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        let run_id =
+            create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+        let url = format!("/runs/{run_id}/bootstrap");
+        let token = &transport.session.auth_token;
+
+        let first = get(env.app(), &url, Some(token)).await;
+        assert_eq!(first.status(), StatusCode::OK);
+
+        let second = get(env.app(), &url, Some(token)).await;
+        assert_eq!(second.status(), StatusCode::GONE);
+    }
+
+    #[tokio::test]
+    async fn bootstrap_transitions_run_to_active() {
+        let env = TestEnv::new();
+        let transport = new_transport(TransportMode::Api, 3000);
+        let run_id =
+            create_run_with_bootstrap(&env, &transport, "/repos/test.git", None).await;
+        let url = format!("/runs/{run_id}/bootstrap");
+
+        get(env.app(), &url, Some(&transport.session.auth_token)).await;
+
+        let db = crate::db::open(&env.quire.db_path()).expect("db open");
+        let (state, started_at_ms): (String, Option<i64>) = db
+            .query_row(
+                "SELECT state, started_at_ms FROM runs WHERE id = ?1",
+                rusqlite::params![run_id],
+                |row| Ok((row.get(0)?, row.get(1)?)),
+            )
+            .expect("query");
+        assert_eq!(state, "active");
+        assert!(started_at_ms.is_some());
+    }
+
     #[tokio::test]
     async fn secret_returns_plaintext_value() {
         let env = TestEnv::new();