Skip to main content

testing_conventions/
e2e.rs

1//! `e2e attest` / `e2e verify` (#17) — the e2e attestation nudge.
2//!
3//! `attest` runs the e2e suite locally and records that it ran against the
4//! current commit; `verify` (a later slice, #68) confirms in CI that the latest
5//! code commit is attested. The point is to *nudge* agents to run e2e locally —
6//! CI never runs e2e, it only checks the committed attestation.
7//!
8//! This module implements both `attest` (#67) and `verify` (#68).
9
10use std::path::Path;
11use std::process::Command;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use anyhow::{bail, Context, Result};
15use serde::{Deserialize, Serialize};
16
17/// Where the committed attestation lives, relative to the repo root.
18pub const ATTESTATION_PATH: &str = "e2e-attestation.json";
19
20/// A record of one local e2e run — written to disk and committed by [`attest`].
21///
22/// `commit` is the SHA of the code commit the run was made against (HEAD at
23/// attest time); [`verify`](crate::e2e) (#68) checks it against the latest code
24/// commit. The rest is information for humans — nothing is gated on it.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Attestation {
27    /// The command that was run (e.g. `pnpm run e2e`).
28    pub command: String,
29    /// When it ran, as a Unix timestamp (seconds).
30    pub ran_at: u64,
31    /// The command's exit code — recorded, never gated on.
32    pub exit_code: i32,
33    /// The commit the run was made against (HEAD at attest time).
34    pub commit: String,
35}
36
37/// Run `command` in `repo`, write an [`Attestation`] naming the current HEAD to
38/// `repo`/[`ATTESTATION_PATH`], and commit it on top. Returns the attestation.
39///
40/// Writes regardless of the command's exit code — this forces a *run*, not a
41/// *pass*.
42pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
43    let commit = git_capture(repo, &["rev-parse", "HEAD"])
44        .context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
45
46    // Run the e2e command via the shell, streaming its output through.
47    let status = Command::new("sh")
48        .arg("-c")
49        .arg(command)
50        .current_dir(repo)
51        .status()
52        .with_context(|| format!("running e2e command `{command}`"))?;
53    let exit_code = status.code().unwrap_or(-1);
54
55    let ran_at = SystemTime::now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_secs())
58        .unwrap_or(0);
59
60    let attestation = Attestation {
61        command: command.to_string(),
62        ran_at,
63        exit_code,
64        commit: commit.clone(),
65    };
66
67    // Write the attestation, then commit just that file on top — it names the
68    // code commit it was run against (a commit can't name its own SHA).
69    let path = repo.join(ATTESTATION_PATH);
70    let json = serde_json::to_string_pretty(&attestation).context("serializing the attestation")?;
71    std::fs::write(&path, format!("{json}\n"))
72        .with_context(|| format!("writing {}", path.display()))?;
73
74    git_run(repo, &["add", ATTESTATION_PATH])?;
75    let short = &commit[..commit.len().min(7)];
76    let message = format!("e2e attestation for {short}");
77    // A plain commit that inherits the repo's signing policy: a repo requiring
78    // verified signatures gets a signed (mergeable) attestation, instead of the
79    // unsigned commit a forced `commit.gpgsign=false` would leave behind (#128).
80    git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
81
82    Ok(attestation)
83}
84
85/// The outcome of [`verify`] — whether the committed attestation names the latest
86/// code commit, and if not, why.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum Verification {
89    /// The attestation names the latest code commit — the gate passes.
90    Fresh,
91    /// No attestation file is present — the gate fails.
92    Missing,
93    /// An attestation is present but names an older commit than the latest code
94    /// commit (code changed since it was attested) — the gate fails.
95    Stale {
96        /// The commit the attestation names.
97        attested: String,
98        /// The latest code commit (newest one touching a non-attestation path).
99        latest: String,
100    },
101}
102
103/// Verify that the committed attestation names the latest code commit (#68) — the
104/// CI side of the nudge. Reads only the committed attestation: never runs e2e,
105/// never inspects the recorded exit code or output.
106///
107/// Equivalent to [`verify_scoped`] with `scope` set to `repo` — the freshness
108/// walk covers everything under `repo`, same as before #294.
109pub fn verify(repo: &Path) -> Result<Verification> {
110    verify_scoped(repo, repo)
111}
112
113/// Verify the committed attestation at `repo`, scoping the "latest code commit"
114/// walk to `scope` instead of all of `repo` (#294).
115///
116/// `repo` and `scope` serve different roles: `repo` is where
117/// `e2e-attestation.json` lives (the package root — a manifest's own natural
118/// home), while `scope` is what counts as "code" for freshness (the directory a
119/// `path`-scoped call actually named, which can be narrower — a package root
120/// commonly also holds `tests/`, docs, and config files that aren't the
121/// attestable source). `scope` must be `repo` or a descendant of it.
122///
123/// Equivalent to [`verify_since`] with `base` set to `None` — the walk covers
124/// all reachable history, same as before #319.
125pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
126    verify_since(repo, scope, None)
127}
128
129/// Verify the committed attestation at `repo`, optionally restricting the
130/// "latest code commit" walk to the commits this branch introduced —
131/// `<base>..HEAD` — instead of all reachable history (#319).
132///
133/// This makes freshness **diff-relative** rather than history-absolute, matching
134/// the changed-line coverage/mutation gates (`--base`). When `base` is `Some`,
135/// only commits reachable from `HEAD` but not `base` count as "the latest code
136/// commit": if this branch introduced no scoped code commit, there is nothing to
137/// re-attest, so the result is [`Verification::Fresh`] regardless of what the
138/// attestation names — a stale-on-`base` attestation (e.g. a squash merge
139/// rewrote the attested commit into a new one on the base branch) never reds a
140/// PR that didn't touch the scoped source. When `base` is `None`, the walk
141/// covers all reachable history, byte-identical to before this flag existed.
142pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
143    let path = repo.join(ATTESTATION_PATH);
144    let Ok(contents) = std::fs::read_to_string(&path) else {
145        return Ok(Verification::Missing);
146    };
147    let attestation: Attestation =
148        serde_json::from_str(&contents).context("parsing the attestation")?;
149
150    match latest_code_commit(repo, scope, base)? {
151        // With `--base`, an empty walk means this branch introduced no scoped
152        // code commit — there is nothing to re-attest, so it's fresh by
153        // construction (mirrors the changed-line coverage/mutation gates, which
154        // pass on an empty diff). This is what keeps the gate squash-safe: a
155        // stale-on-base attestation never reds a PR that didn't touch the source.
156        // Without `--base` the walk covers all history and only comes back empty
157        // for a scope with no commits at all, where an equality check against the
158        // attested SHA (below) is the right answer — same as before this flag.
159        None if base.is_some() => Ok(Verification::Fresh),
160        latest => {
161            let latest = latest.unwrap_or_default();
162            if attestation.commit == latest {
163                Ok(Verification::Fresh)
164            } else {
165                Ok(Verification::Stale {
166                    attested: attestation.commit,
167                    latest,
168                })
169            }
170        }
171    }
172}
173
174/// The newest commit that changed any path other than the attestation file,
175/// under `scope` — the "latest code commit" the attestation must name to be
176/// fresh. Uses an `:(exclude)` pathspec so the attestation's own commit never
177/// counts as code. When `base` is `Some`, the walk is restricted to the commits
178/// this branch introduced (`<base>..HEAD`); `None` when that range holds no such
179/// commit. Without `base` the walk covers all reachable history.
180fn latest_code_commit(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Option<String>> {
181    let exclude = format!(":(exclude){ATTESTATION_PATH}");
182    let pathspec = relative_pathspec(repo, scope);
183    let mut args = vec!["log", "-1", "--format=%H"];
184    // `<base>..HEAD` — commits reachable from HEAD but not `base`, i.e. the ones
185    // this branch introduced. A commit that also landed on `base` (a squash of an
186    // earlier PR) is reachable from `base`, so it's excluded here — that's the
187    // whole point.
188    let range = base.map(|base| format!("{base}..HEAD"));
189    if let Some(range) = range.as_deref() {
190        args.push(range);
191    }
192    args.push("--");
193    args.push(pathspec.as_str());
194    args.push(exclude.as_str());
195    let out = git_capture(repo, &args)?;
196    Ok((!out.is_empty()).then_some(out))
197}
198
199/// `scope` as a pathspec relative to `repo` (git resolves pathspecs relative to
200/// the invocation's cwd, which is always `repo` here). `.` when `scope` is
201/// `repo` itself — byte-identical to the pre-#294 pathspec.
202fn relative_pathspec(repo: &Path, scope: &Path) -> String {
203    if scope == repo {
204        return ".".to_string();
205    }
206    match scope.strip_prefix(repo) {
207        Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
208        _ => scope.to_string_lossy().into_owned(),
209    }
210}
211
212/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
213fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
214    let out = Command::new("git")
215        .args(args)
216        .current_dir(repo)
217        .output()
218        .with_context(|| format!("running `git {}`", args.join(" ")))?;
219    if !out.status.success() {
220        bail!(
221            "`git {}` failed: {}",
222            args.join(" "),
223            String::from_utf8_lossy(&out.stderr).trim()
224        );
225    }
226    Ok(String::from_utf8(out.stdout)?.trim().to_string())
227}
228
229/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
230fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
231    let status = Command::new("git")
232        .args(args)
233        .current_dir(repo)
234        .status()
235        .with_context(|| format!("running `git {}`", args.join(" ")))?;
236    if !status.success() {
237        bail!("`git {}` failed", args.join(" "));
238    }
239    Ok(())
240}