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.
106pub fn verify(repo: &Path) -> Result<Verification> {
107 let path = repo.join(ATTESTATION_PATH);
108 let Ok(contents) = std::fs::read_to_string(&path) else {
109 return Ok(Verification::Missing);
110 };
111 let attestation: Attestation =
112 serde_json::from_str(&contents).context("parsing the attestation")?;
113
114 let latest = latest_code_commit(repo)?;
115 if attestation.commit == latest {
116 Ok(Verification::Fresh)
117 } else {
118 Ok(Verification::Stale {
119 attested: attestation.commit,
120 latest,
121 })
122 }
123}
124
125/// The newest commit that changed any path other than the attestation file — the
126/// "latest code commit" the attestation must name to be fresh. Uses an
127/// `:(exclude)` pathspec so the attestation's own commit never counts as code.
128fn latest_code_commit(repo: &Path) -> Result<String> {
129 let exclude = format!(":(exclude){ATTESTATION_PATH}");
130 git_capture(
131 repo,
132 &["log", "-1", "--format=%H", "--", ".", exclude.as_str()],
133 )
134}
135
136/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
137fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
138 let out = Command::new("git")
139 .args(args)
140 .current_dir(repo)
141 .output()
142 .with_context(|| format!("running `git {}`", args.join(" ")))?;
143 if !out.status.success() {
144 bail!(
145 "`git {}` failed: {}",
146 args.join(" "),
147 String::from_utf8_lossy(&out.stderr).trim()
148 );
149 }
150 Ok(String::from_utf8(out.stdout)?.trim().to_string())
151}
152
153/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
154fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
155 let status = Command::new("git")
156 .args(args)
157 .current_dir(repo)
158 .status()
159 .with_context(|| format!("running `git {}`", args.join(" ")))?;
160 if !status.success() {
161 bail!("`git {}` failed", args.join(" "));
162 }
163 Ok(())
164}