Skip to main content

ostraka_runtime/
promote.rs

1//! Promoting an approved run to a branch someone can merge.
2//!
3//! A run commits inside its worktree and stops. Nothing here merges anything:
4//! promotion gives an approved change a branch of its own in the repository, so
5//! the worktree can go away and a person can merge or open a pull request as a
6//! deliberate act of their own.
7//!
8//! The invariant that has to survive the process boundary is that a change which
9//! never obtained a [`crate::gate::MergeToken`] cannot be promoted. It survives
10//! by asking the gate again rather than by storing an answer: a serialized token
11//! would be a token anyone could write. See [`crate::gate::reaffirm`].
12//!
13//! Two independent records have to agree before anything is named. The run
14//! record says what the gate decided; the commit's own trailers say who wrote
15//! and who reviewed it, and those are in git history where the runtime put them.
16//! Rule 6 applies here as much as anywhere: what happened is read from git, not
17//! from a file describing git.
18
19use crate::gate::{self, MergeToken, Refusal};
20use crate::{Error, Result};
21use ostraka_core::config::Config;
22use ostraka_core::record::{Outcome, RunRecord};
23use std::path::Path;
24use std::process::Command;
25
26/// A promoted run: what was named, and what it was named after.
27#[derive(Debug)]
28pub struct Promotion {
29    pub branch: String,
30    pub commit: String,
31    pub run_branch: String,
32    pub token: MergeToken,
33}
34
35/// Why a promotion did not happen.
36#[derive(Debug)]
37pub enum NotPromoted {
38    /// The gate would not mint a token from the run's evidence.
39    Refused(Refusal),
40    /// The record and the commit disagree, or the commit is not there.
41    Unverifiable(String),
42}
43
44impl std::fmt::Display for NotPromoted {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::Refused(r) => write!(f, "the gate refuses to promote this run: {r:?}"),
48            Self::Unverifiable(why) => write!(f, "{why}"),
49        }
50    }
51}
52
53/// The branch a run is promoted to when the caller names none.
54pub fn default_branch(run_id: &str) -> String {
55    format!("promoted/{run_id}")
56}
57
58/// Gives an approved run's commit a branch of its own.
59///
60/// Refuses, without touching the repository, unless every one of these holds:
61/// the record says the run was approved, the gate mints a token from its
62/// evidence against the project's *current* checks, the run's branch exists,
63/// and its head commit's trailers name the same run, author and reviewer the
64/// record does.
65pub fn promote(
66    repo: &Path,
67    records_root: &Path,
68    run_id: &str,
69    config: &Config,
70    branch: Option<&str>,
71) -> Result<std::result::Result<Promotion, NotPromoted>> {
72    let record = read_record(records_root, run_id)?;
73
74    // The recorded outcome is checked first only so the refusal is legible; the
75    // gate below is what actually decides, and it does not consult this field.
76    if record.outcome != Some(Outcome::Approved) {
77        return Ok(Err(NotPromoted::Unverifiable(format!(
78            "run {run_id:?} ended as {:?}, not approved",
79            record.outcome
80        ))));
81    }
82
83    let token = match gate::reaffirm(
84        &config.gate,
85        &record,
86        config.gate.review.must_differ_from_author,
87    ) {
88        Ok(token) => token,
89        Err(refusal) => return Ok(Err(NotPromoted::Refused(refusal))),
90    };
91
92    let run_branch = format!("ostraka/{run_id}");
93    let commit = match rev_parse(repo, &run_branch)? {
94        Some(sha) => sha,
95        None => {
96            return Ok(Err(NotPromoted::Unverifiable(format!(
97                "no branch {run_branch:?} in this repository; the run's commit is not here"
98            ))));
99        }
100    };
101
102    let message = commit_message(repo, &commit)?;
103    if let Err(why) = trailers_agree(&message, run_id, &record, &token) {
104        return Ok(Err(NotPromoted::Unverifiable(why)));
105    }
106
107    let target = branch
108        .map(str::to_string)
109        .unwrap_or_else(|| default_branch(run_id));
110    if rev_parse(repo, &target)?.is_some() {
111        return Ok(Err(NotPromoted::Unverifiable(format!(
112            "branch {target:?} already exists; name another with --branch"
113        ))));
114    }
115
116    let out = Command::new("git")
117        .args(["branch", &target, &commit])
118        .current_dir(repo)
119        .output()?;
120    if !out.status.success() {
121        return Err(Error::Other(format!(
122            "git branch failed: {}",
123            String::from_utf8_lossy(&out.stderr).trim()
124        )));
125    }
126
127    Ok(Ok(Promotion {
128        branch: target,
129        commit,
130        run_branch,
131        token,
132    }))
133}
134
135fn read_record(records_root: &Path, run_id: &str) -> Result<RunRecord> {
136    let path = records_root.join("runs").join(run_id).join("record.json");
137    let text = std::fs::read_to_string(&path)
138        .map_err(|e| Error::Other(format!("no run {run_id:?}: {e}")))?;
139    serde_json::from_str(&text).map_err(|e| Error::Other(format!("run record is unreadable: {e}")))
140}
141
142fn rev_parse(repo: &Path, rev: &str) -> Result<Option<String>> {
143    let out = Command::new("git")
144        .args(["rev-parse", "--verify", "--quiet", rev])
145        .current_dir(repo)
146        .output()?;
147    if !out.status.success() {
148        return Ok(None);
149    }
150    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
151    Ok((!sha.is_empty()).then_some(sha))
152}
153
154fn commit_message(repo: &Path, commit: &str) -> Result<String> {
155    let out = Command::new("git")
156        .args(["log", "-1", "--format=%B", commit])
157        .current_dir(repo)
158        .output()?;
159    if !out.status.success() {
160        return Err(Error::Other(format!(
161            "git log failed: {}",
162            String::from_utf8_lossy(&out.stderr).trim()
163        )));
164    }
165    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
166}
167
168/// Checks the commit's trailers against the record the gate just read.
169///
170/// The record is a file beside the repository; the trailers are inside history.
171/// Requiring both to say the same thing means a promotion cannot be conjured by
172/// editing one of them.
173fn trailers_agree(
174    message: &str,
175    run_id: &str,
176    record: &RunRecord,
177    token: &MergeToken,
178) -> std::result::Result<(), String> {
179    let has = |prefix: &str, value: &str| {
180        message.lines().any(|line| {
181            line.trim().strip_prefix(prefix).is_some_and(|rest| {
182                rest.trim() == value || rest.trim().starts_with(&format!("{value} ("))
183            })
184        })
185    };
186
187    if !has("Run:", run_id) {
188        return Err(format!(
189            "the commit on this run's branch does not carry `Run: {run_id}`; it is not the commit \
190             this run produced"
191        ));
192    }
193    if !has("Authored-by:", record.author.as_str()) {
194        return Err(format!(
195            "the commit says it was written by someone other than {}, which the run record names",
196            record.author
197        ));
198    }
199    if !has("Reviewed-by:", token.reviewer().as_str()) {
200        return Err(format!(
201            "the commit says it was reviewed by someone other than {}, which the approval names",
202            token.reviewer()
203        ));
204    }
205    Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use ostraka_core::gate::{Approval, CheckRecord, Verdict};
212    use ostraka_core::identity::ActorId;
213
214    fn record() -> RunRecord {
215        RunRecord {
216            run_id: "r1".into(),
217            task_id: "t1".into(),
218            prompt: "do a thing".into(),
219            author: ActorId::new("archon"),
220            adapter: "a".into(),
221            repository: "only".into(),
222            started_at: "now".into(),
223            finished_at: None,
224            checks: vec![CheckRecord {
225                name: "test".into(),
226                cmd: "true".into(),
227                exit_code: Some(0),
228                stdout: String::new(),
229                stderr: String::new(),
230                duration_ms: 1,
231            }],
232            approval: Some(Approval {
233                reviewer: ActorId::new("ephor"),
234                verdict: Verdict::Approve,
235            }),
236            usage: Vec::new(),
237            outcome: Some(Outcome::Approved),
238        }
239    }
240
241    fn token() -> MergeToken {
242        let spec = ostraka_core::gate::GateSpec {
243            timeout_secs: None,
244            checks: vec![ostraka_core::gate::Check {
245                name: "test".into(),
246                cmd: "true".into(),
247                required: true,
248            }],
249            review: ostraka_core::gate::ReviewPolicy::default(),
250        };
251        gate::reaffirm(&spec, &record(), true).expect("the record earns a token")
252    }
253
254    #[test]
255    fn agreeing_trailers_are_accepted() {
256        let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (claude-code)\nReviewed-by: \
257                       ephor (codex)\n";
258        assert!(trailers_agree(message, "r1", &record(), &token()).is_ok());
259    }
260
261    #[test]
262    fn a_commit_from_a_different_run_is_not_this_runs_commit() {
263        let message = "Do a thing\n\nRun: r2\nAuthored-by: archon (c)\nReviewed-by: ephor (d)\n";
264        let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
265        assert!(err.contains("Run: r1"), "{err}");
266    }
267
268    #[test]
269    fn a_commit_naming_a_different_reviewer_than_the_approval_is_refused() {
270        // Forging a promotion would take editing the record and rewriting the
271        // commit; agreeing with only one of them is not enough.
272        let message = "Do a thing\n\nRun: r1\nAuthored-by: archon (c)\nReviewed-by: archon (c)\n";
273        let err = trailers_agree(message, "r1", &record(), &token()).expect_err("must refuse");
274        assert!(err.contains("reviewed by"), "{err}");
275    }
276
277    #[test]
278    fn a_run_whose_required_check_never_passed_earns_no_token() {
279        let mut r = record();
280        r.checks[0].exit_code = Some(1);
281        let spec = ostraka_core::gate::GateSpec {
282            timeout_secs: None,
283            checks: vec![ostraka_core::gate::Check {
284                name: "test".into(),
285                cmd: "true".into(),
286                required: true,
287            }],
288            review: ostraka_core::gate::ReviewPolicy::default(),
289        };
290        assert!(gate::reaffirm(&spec, &r, true).is_err());
291    }
292
293    #[test]
294    fn a_check_added_since_the_run_blocks_promotion() {
295        // The record cannot show a check passing that did not exist when it was
296        // written, and promoting as though it had would launder the addition.
297        let spec = ostraka_core::gate::GateSpec {
298            timeout_secs: None,
299            checks: vec![
300                ostraka_core::gate::Check {
301                    name: "test".into(),
302                    cmd: "true".into(),
303                    required: true,
304                },
305                ostraka_core::gate::Check {
306                    name: "lint".into(),
307                    cmd: "true".into(),
308                    required: true,
309                },
310            ],
311            review: ostraka_core::gate::ReviewPolicy::default(),
312        };
313        match gate::reaffirm(&spec, &record(), true) {
314            Err(Refusal::ChecksFailed { failed, .. }) => assert_eq!(failed, ["lint"]),
315            other => panic!("expected the missing check to block: {other:?}"),
316        }
317    }
318
319    #[test]
320    fn a_self_approved_run_cannot_be_promoted() {
321        let mut r = record();
322        r.approval = Some(Approval {
323            reviewer: ActorId::new("archon"),
324            verdict: Verdict::Approve,
325        });
326        let spec = ostraka_core::gate::GateSpec {
327            timeout_secs: None,
328            checks: vec![ostraka_core::gate::Check {
329                name: "test".into(),
330                cmd: "true".into(),
331                required: true,
332            }],
333            review: ostraka_core::gate::ReviewPolicy::default(),
334        };
335        assert!(matches!(
336            gate::reaffirm(&spec, &r, true),
337            Err(Refusal::SelfApproval { .. })
338        ));
339    }
340}