Skip to main content

yah_qed/
placement_gate.rs

1//! @yah:ticket(R435-F2, "Runner gates kicks on placement: CLI refuses ci-only without --force; GHA warns/refuses local-only")
2//! @yah:assignee(agent:claude)
3//! @yah:at(2026-06-04T19:15:56Z)
4//! @yah:status(in-progress)
5//! @yah:phase(P1)
6//! @yah:parent(R435)
7//! @yah:depends_on(R435-F1)
8//! @arch:see(.yah/docs/working/W170-qed-recipe-discipline.md)
9//!
10//! Single source of truth for the W155 placement × runner matrix. Both the
11//! CLI entry (`yah qed run`) and the camp daemon `qed.run` handler consult
12//! [`evaluate`] before kicking a pipeline, so adding a new runner class or
13//! flipping a matrix cell only requires editing this file.
14//!
15//! The matrix (W155 §GHA-compat decision matrix):
16//!
17//! ```text
18//!                | local-only | anywhere | ci-only
19//!  Local runner  | Allow      | Allow    | Refuse (unless --force)
20//!  CI runner     | Warn       | Allow    | Allow
21//! ```
22
23use crate::types::Placement;
24
25/// Classification of the host kicking the pipeline. The CLI detects this
26/// from `$CI` / `$GITHUB_ACTIONS`; the camp daemon inherits its env from
27/// whatever launched it (typically a dev laptop ⇒ [`RunnerEnv::Local`]).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RunnerEnv {
30    /// Developer machine, desktop daemon, or anywhere `$CI` / `$GITHUB_ACTIONS`
31    /// are unset.
32    Local,
33    /// CI host — `$CI=true` or `$GITHUB_ACTIONS=true`.
34    Ci,
35}
36
37impl RunnerEnv {
38    /// Classify from environment variables. Mirrors GitHub Actions' own
39    /// convention plus the generic `$CI` flag set by most CI providers.
40    pub fn detect() -> Self {
41        if env_truthy("GITHUB_ACTIONS") || env_truthy("CI") {
42            RunnerEnv::Ci
43        } else {
44            RunnerEnv::Local
45        }
46    }
47}
48
49fn env_truthy(key: &str) -> bool {
50    matches!(
51        std::env::var(key).ok().as_deref(),
52        Some("1") | Some("true") | Some("TRUE") | Some("True"),
53    )
54}
55
56/// Outcome of a placement check.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GateOutcome {
59    /// Allow the kick. The optional warning is informational — the caller
60    /// should print it (stderr for CLI; log for daemon) but proceed.
61    Allow { warning: Option<String> },
62    /// Refuse the kick. The message is the operator-facing reason.
63    Refuse { reason: String },
64}
65
66/// Evaluate the W155 placement × runner matrix.
67///
68/// `force` corresponds to the CLI's `--force` flag and only matters for the
69/// one cell the matrix marks as "Refuse (unless --force)" — `ci-only` on a
70/// `Local` runner. When `force = true` that cell flips to [`GateOutcome::Allow`]
71/// with a warning so the bypass is loud.
72pub fn evaluate(placement: Placement, env: RunnerEnv, force: bool) -> GateOutcome {
73    match (placement, env) {
74        // Allow: same-environment matches and the universal `anywhere`.
75        (Placement::LocalOnly, RunnerEnv::Local)
76        | (Placement::Anywhere, _)
77        | (Placement::CiOnly, RunnerEnv::Ci) => GateOutcome::Allow { warning: None },
78
79        // Warn-but-allow: local-only on CI is almost certainly a mistake
80        // (the artifact lives on the host that ran it, which CI throws away),
81        // but refusing would silently fail any GHA workflow that drifts onto
82        // a local-only recipe — better to surface the smell loudly.
83        (Placement::LocalOnly, RunnerEnv::Ci) => GateOutcome::Allow {
84            warning: Some(
85                "recipe placement = local-only but runner is CI — the run's output \
86                 (installed binary, files in the camp tree, …) is meaningless on a \
87                 CI runner. Consider splitting the recipe or flipping placement to \
88                 `anywhere`."
89                    .to_string(),
90            ),
91        },
92
93        // Refuse: ci-only on a Local runner needs secrets / signing identity
94        // / a clean machine. `--force` exists to support local rehearsals.
95        (Placement::CiOnly, RunnerEnv::Local) => {
96            if force {
97                GateOutcome::Allow {
98                    warning: Some(
99                        "recipe placement = ci-only but --force was passed; running \
100                         locally. Steps that depend on CI secrets or signing identity \
101                         will fail unless your environment already provides them."
102                            .to_string(),
103                    ),
104                }
105            } else {
106                GateOutcome::Refuse {
107                    reason: "recipe placement = ci-only and this is not a CI runner — \
108                         the recipe needs secrets, signing identity, or a clean \
109                         runner that don't exist locally. Pass --force to run \
110                         anyway, or run it from a CI workflow."
111                        .to_string(),
112                }
113            }
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn assert_allowed(p: Placement, e: RunnerEnv, force: bool) {
123        match evaluate(p, e, force) {
124            GateOutcome::Allow { .. } => {}
125            other => panic!("expected Allow for ({p:?}, {e:?}, force={force}); got {other:?}"),
126        }
127    }
128
129    fn assert_allowed_with_warning(p: Placement, e: RunnerEnv, force: bool) {
130        match evaluate(p, e, force) {
131            GateOutcome::Allow { warning: Some(_) } => {}
132            other => panic!(
133                "expected Allow {{warning: Some(_)}} for ({p:?}, {e:?}, force={force}); got {other:?}"
134            ),
135        }
136    }
137
138    fn assert_refused(p: Placement, e: RunnerEnv, force: bool) {
139        match evaluate(p, e, force) {
140            GateOutcome::Refuse { .. } => {}
141            other => panic!("expected Refuse for ({p:?}, {e:?}, force={force}); got {other:?}"),
142        }
143    }
144
145    // ── W155 matrix: each of the 6 cells, plus the --force escape hatch ────
146
147    #[test]
148    fn matrix_local_only_on_local_allows_silently() {
149        assert_allowed(Placement::LocalOnly, RunnerEnv::Local, false);
150    }
151
152    #[test]
153    fn matrix_anywhere_on_local_allows_silently() {
154        assert_allowed(Placement::Anywhere, RunnerEnv::Local, false);
155    }
156
157    #[test]
158    fn matrix_anywhere_on_ci_allows_silently() {
159        assert_allowed(Placement::Anywhere, RunnerEnv::Ci, false);
160    }
161
162    #[test]
163    fn matrix_ci_only_on_ci_allows_silently() {
164        assert_allowed(Placement::CiOnly, RunnerEnv::Ci, false);
165    }
166
167    #[test]
168    fn matrix_local_only_on_ci_warns_but_allows() {
169        assert_allowed_with_warning(Placement::LocalOnly, RunnerEnv::Ci, false);
170    }
171
172    #[test]
173    fn matrix_ci_only_on_local_refuses_without_force() {
174        assert_refused(Placement::CiOnly, RunnerEnv::Local, false);
175    }
176
177    #[test]
178    fn matrix_ci_only_on_local_with_force_allows_with_warning() {
179        assert_allowed_with_warning(Placement::CiOnly, RunnerEnv::Local, true);
180    }
181
182    // ── force is a no-op on cells that don't refuse ────────────────────────
183
184    #[test]
185    fn force_is_no_op_on_already_allowed_cells() {
186        for p in [Placement::LocalOnly, Placement::Anywhere, Placement::CiOnly] {
187            for e in [RunnerEnv::Local, RunnerEnv::Ci] {
188                let without = evaluate(p, e, false);
189                if matches!(without, GateOutcome::Allow { warning: None }) {
190                    let with_force = evaluate(p, e, true);
191                    assert_eq!(
192                        with_force, without,
193                        "force should not change behavior for ({p:?}, {e:?})"
194                    );
195                }
196            }
197        }
198    }
199
200    // ── env detection: smoke around env_truthy ─────────────────────────────
201
202    #[test]
203    fn env_truthy_recognizes_canonical_values() {
204        // Don't mutate process env (other tests may race) — exercise the
205        // pure helper directly via temp_env-style ScopedEnv would be nicer
206        // but adds a dep. Test the matrix routing instead; detect() coverage
207        // is integration-tested via the manual `yah qed run` verify step.
208        for val in ["1", "true", "TRUE", "True"] {
209            unsafe { std::env::set_var("__QED_GATE_TEST", val) };
210            assert!(env_truthy("__QED_GATE_TEST"), "truthy: {val}");
211        }
212        for val in ["0", "false", "no", ""] {
213            unsafe { std::env::set_var("__QED_GATE_TEST", val) };
214            assert!(!env_truthy("__QED_GATE_TEST"), "not truthy: {val}");
215        }
216        unsafe { std::env::remove_var("__QED_GATE_TEST") };
217    }
218}