Consolidate ci.fnl compilation in quire-ci
The orchestrator previously pre-compiled ci.fnl in trigger_ref as a
fail-fast check, then quire-ci recompiled inside its own process. The
duplicate path meant compile failures could be reported from either
binary (and were, since both have tracing/Sentry wired). Two Sentry
events per malformed ci.fnl — the orchestrator's "ci.fnl has errors"
on quire-server, and quire-ci's own miette-printed exit.
Move compilation to quire-ci alone:
- trigger_ref drops the ci.compile match block and just dispatches to
quire-ci via materialize_workspace + execute_via_quire_ci, which
already handles the run-state transitions on quire-ci's exit code.
- quire-ci's run_pipeline wraps pipeline::compile with tracing::error
so compile failures surface as Sentry events on the worker's side,
where the SentryHandoff carries the orchestrator's trace_id — the
two binaries' events still group on the same trace.
- quire-ci's MietteLayer registers pipeline::CompileError so the
rendered diagnostic gets stashed for extra["diagnostic"].
Inline the now single-caller Ci::compile into Ci::pipeline (used by
the `quire ci validate`/`run` CLI commands). Drop the
trigger_errors_on_invalid_pipeline test — it asserted a behavior the
orchestrator no longer owns; quire-ci-exits-nonzero coverage already
exercises the failure path.
The orchestrator still emits its own "CI trigger failed" event when
execute_via_quire_ci returns QuireCiExit, which is now redundant with
quire-ci's report; addressing that is left for a follow-up alongside
the Sentry data-scrubbing issue.
Assisted-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index c9be6b5..0e0c253 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -213,6 +213,7 @@ fn main() -> miette::Result<()> {
let _sentry = init_sentry(sentry_handoff.as_ref(), &meta);
let miette_layer = MietteLayer::new()
.with_type::<JobError>()
+ .with_type::<pipeline::CompileError>()
.with_type::<quire_core::fennel::FennelError>();
telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
@@ -335,7 +336,18 @@ fn run_pipeline(
meta: RunMeta,
secrets: HashMap<String, quire_core::secret::SecretString>,
) -> miette::Result<()> {
- let pipeline = compile_at(&workspace)?;
+ let path = workspace.join(".quire").join("ci.fnl");
+ let source = fs_err::read_to_string(&path).into_diagnostic()?;
+ let pipeline = match pipeline::compile(&source, &path.display().to_string()) {
+ Ok(p) => p,
+ Err(e) => {
+ tracing::error!(
+ error = &e as &(dyn std::error::Error + 'static),
+ "ci.fnl compilation failed",
+ );
+ return Err(e.into());
+ }
+ };
let job_ids: Vec<String> = pipeline
.topo_order()
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 27badc5..e91fa00 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -61,15 +61,7 @@ impl Ci {
let Some(source) = self.source(&commit.sha)? else {
return Ok(None);
};
- Ok(Some(self.compile(&source)?))
- }
-
- /// Compile `.quire/ci.fnl` source into a validated [`Pipeline`].
- ///
- /// Single chokepoint for compile + structural validation, used by
- /// [`Ci::pipeline`] and `trigger_ref` so the two paths can't drift.
- fn compile(&self, source: &str) -> error::Result<Pipeline> {
- Ok(pipeline::compile(source, CI_FNL)?)
+ Ok(Some(pipeline::compile(&source, CI_FNL)?))
}
/// Read the contents of `.quire/ci.fnl` at a given commit SHA.
@@ -203,9 +195,9 @@ fn trigger_ref(
) -> error::Result<()> {
let ci = repo.ci();
- let Some(source) = ci.source(&push_ref.new_sha)? else {
+ if ci.source(&push_ref.new_sha)?.is_none() {
return Ok(());
- };
+ }
let meta = RunMeta {
sha: push_ref.new_sha.clone(),
@@ -213,7 +205,7 @@ fn trigger_ref(
pushed_at,
};
- let mut run = repo.runs(db_path).create(&meta)?;
+ let run = repo.runs(db_path).create(&meta)?;
tracing::info!(
run_id = %run.id(), // cov-excl-line
@@ -222,22 +214,12 @@ fn trigger_ref(
"created CI run"
);
- let pipeline = match ci.compile(&source) {
- Ok(p) => p,
- Err(e) => {
- run.transition(RunState::Active)?;
- run.transition(RunState::Failed)?;
- return Err(e);
- }
- };
-
let workspace = run.path().join("workspace");
run::materialize_workspace(&repo.path(), &push_ref.new_sha, &workspace)?;
match executor {
Executor::QuireCi => {
- // The orchestrator already validated `pipeline` to fail-fast on
- // bad ci.fnl; `quire-ci` recompiles inside its own process.
- drop(pipeline);
+ // Compilation happens inside quire-ci so a malformed ci.fnl is
+ // reported once, with the worker's trace context.
run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry)?;
}
}
@@ -574,31 +556,6 @@ mod tests {
.expect("should succeed without ci.fnl");
}
- #[test]
- fn trigger_errors_on_invalid_pipeline() {
- let source = "(local ci (require :quire.ci))\n(ci.job :a [] (fn [] nil))";
- let (_dir, quire, name) = bare_repo_with_ci(source);
- let repo = quire.repo(&name).expect("repo");
- let sha = head_sha(&repo);
- let pushed_at: jiff::Timestamp = "2026-04-28T12:00:00Z".parse().unwrap();
- let push_ref = PushRef {
- old_sha: "0000000000000000000000000000000000000000".to_string(),
- new_sha: sha,
- r#ref: "refs/heads/main".to_string(),
- };
-
- let result = trigger_ref(
- &repo,
- &quire.db_path(),
- pushed_at,
- &push_ref,
- &HashMap::new(),
- Executor::QuireCi,
- None,
- );
- assert!(result.is_err(), "invalid pipeline should fail");
- }
-
fn push_event(repo: &str, sha: &str) -> PushEvent {
PushEvent::new(
repo.to_string(),