testing_conventions/e2e.rs
1//! `e2e attest` / `e2e verify` — the e2e attestation nudge.
2//!
3//! `attest` runs the e2e suite locally and records that it ran against the
4//! current commit; `verify` 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
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{bail, Context, Result};
13use serde::{Deserialize, Serialize};
14
15/// Where the committed attestation lives, relative to the repo root.
16pub const ATTESTATION_PATH: &str = "e2e-attestation.json";
17
18/// A record of one local e2e run — written to disk and committed by [`attest`].
19///
20/// `commit` is the SHA of the code commit the run was made against (HEAD at
21/// attest time); [`verify`](crate::e2e) checks it against the latest code
22/// commit. The rest is information for humans — nothing is gated on it.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Attestation {
25 /// The command that was run (e.g. `pnpm run e2e`).
26 pub command: String,
27 /// When it ran, as a Unix timestamp (seconds).
28 pub ran_at: u64,
29 /// The command's exit code — recorded, never gated on.
30 pub exit_code: i32,
31 /// The commit the run was made against (HEAD at attest time).
32 pub commit: String,
33}
34
35/// Run `command` in `repo`, write an [`Attestation`] naming the current HEAD to
36/// `repo`/[`ATTESTATION_PATH`], and commit it on top. Returns the attestation.
37///
38/// Writes regardless of the command's exit code — this forces a *run*, not a
39/// *pass*.
40pub fn attest(repo: &Path, command: &str) -> Result<Attestation> {
41 let commit = git_capture(repo, &["rev-parse", "HEAD"])
42 .context("resolving HEAD — `e2e attest` must run inside a git repo with a commit")?;
43
44 // Run the e2e command via the shell, streaming its output through.
45 let status = Command::new("sh")
46 .arg("-c")
47 .arg(command)
48 .current_dir(repo)
49 .status()
50 .with_context(|| format!("running e2e command `{command}`"))?;
51 let exit_code = status.code().unwrap_or(-1);
52
53 let ran_at = SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .map(|d| d.as_secs())
56 .unwrap_or(0);
57
58 let attestation = Attestation {
59 command: command.to_string(),
60 ran_at,
61 exit_code,
62 commit: commit.clone(),
63 };
64
65 // Write the attestation, then commit just that file on top — it names the
66 // code commit it was run against (a commit can't name its own SHA).
67 let path = repo.join(ATTESTATION_PATH);
68 let json = serde_json::to_string_pretty(&attestation).context("serializing the attestation")?;
69 std::fs::write(&path, format!("{json}\n"))
70 .with_context(|| format!("writing {}", path.display()))?;
71
72 git_run(repo, &["add", ATTESTATION_PATH])?;
73 let short = &commit[..commit.len().min(7)];
74 let message = format!("e2e attestation for {short}");
75 // A plain commit that inherits the repo's signing policy: a repo requiring
76 // verified signatures gets a signed (mergeable) attestation, instead of the
77 // unsigned commit a forced `commit.gpgsign=false` would leave behind.
78 git_run(repo, &["commit", "-q", "-m", message.as_str()])?;
79
80 Ok(attestation)
81}
82
83/// The outcome of [`verify`] — whether the committed attestation names the latest
84/// code commit, and if not, why.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum Verification {
87 /// The attestation names the latest code commit — the gate passes.
88 Fresh,
89 /// No attestation file is present — the gate fails.
90 Missing,
91 /// An attestation is present but names an older commit than the latest code
92 /// commit (code changed since it was attested) — the gate fails.
93 Stale {
94 /// The commit the attestation names.
95 attested: String,
96 /// The latest code commit (newest one touching a non-attestation path).
97 latest: String,
98 },
99}
100
101/// Verify that the committed attestation names the latest code commit — the
102/// CI side of the nudge. Reads only the committed attestation: never runs e2e,
103/// never inspects the recorded exit code or output.
104///
105/// Equivalent to [`verify_scoped`] with `scope` set to `repo` — the freshness
106/// walk covers everything under `repo`.
107pub fn verify(repo: &Path) -> Result<Verification> {
108 verify_scoped(repo, repo)
109}
110
111/// Verify the committed attestation at `repo`, scoping the "latest code commit"
112/// walk to `scope` instead of all of `repo`.
113///
114/// `repo` and `scope` serve different roles: `repo` is where
115/// `e2e-attestation.json` lives (the package root — a manifest's own natural
116/// home), while `scope` is what counts as "code" for freshness (the directory a
117/// `path`-scoped call actually named, which can be narrower — a package root
118/// commonly also holds `tests/`, docs, and config files that aren't the
119/// attestable source). `scope` must be `repo` or a descendant of it.
120///
121/// Equivalent to [`verify_since`] with `base` set to `None` — the walk covers
122/// all reachable history.
123pub fn verify_scoped(repo: &Path, scope: &Path) -> Result<Verification> {
124 verify_since(repo, scope, None)
125}
126
127/// Equivalent to [`verify_extra_scoped`] with no extra roots and no excludes —
128/// the freshness walk covers only `scope`.
129pub fn verify_since(repo: &Path, scope: &Path, base: Option<&str>) -> Result<Verification> {
130 verify_extra_scoped(repo, scope, base, &[], &[])
131}
132
133/// Verify the committed attestation at `repo`, joining **extra freshness roots**
134/// outside `scope` into the "latest code commit" walk.
135///
136/// `extra_scopes` are **repo-root-relative** directories — a shared source tree
137/// that is a sibling of the package (a native core bound into several language
138/// bindings), which no `scope` at-or-below the attestation directory can reach.
139/// Their commits count as code just like commits under `scope`, so a change to
140/// the shared tree stales an attestation whose own subtree it doesn't touch.
141/// `excludes` are repo-root-relative directories carved back out of that union —
142/// a feature-gated subtree (a core `cli/` compiled out of the bindings) whose
143/// changes must not stale them. Because the roots may lie outside the package
144/// subtree — that's the point — the descendant constraint stays on `scope` only.
145///
146/// The freshness rule is unchanged: the attestation must name the newest in-range
147/// commit touching the **union** of `scope` and `extra_scopes`, minus `excludes`.
148/// `base` diff-scopes the walk exactly as in [`verify_since`]. Empty
149/// `extra_scopes` and `excludes` are byte-identical to [`verify_since`].
150pub fn verify_extra_scoped(
151 repo: &Path,
152 scope: &Path,
153 base: Option<&str>,
154 extra_scopes: &[PathBuf],
155 excludes: &[PathBuf],
156) -> Result<Verification> {
157 let path = repo.join(ATTESTATION_PATH);
158 let Ok(contents) = std::fs::read_to_string(&path) else {
159 return Ok(Verification::Missing);
160 };
161 let attestation: Attestation =
162 serde_json::from_str(&contents).context("parsing the attestation")?;
163
164 match latest_code_commit(repo, scope, base, extra_scopes, excludes)? {
165 // With `--base`, an empty walk means this branch introduced no scoped
166 // code commit — there is nothing to re-attest, so it's fresh by
167 // construction (mirrors the changed-line coverage/mutation gates, which
168 // pass on an empty diff). This is what keeps the gate squash-safe: a
169 // stale-on-base attestation never reds a PR that didn't touch the source.
170 // Without `--base` the walk covers all history and only comes back empty
171 // for a scope with no commits at all, where an equality check against the
172 // attested SHA (below) is the right answer — same as before this flag.
173 None if base.is_some() => Ok(Verification::Fresh),
174 latest => {
175 let latest = latest.unwrap_or_default();
176 if attestation.commit == latest {
177 Ok(Verification::Fresh)
178 } else {
179 Ok(Verification::Stale {
180 attested: attestation.commit,
181 latest,
182 })
183 }
184 }
185 }
186}
187
188/// The newest commit that changed any path other than the attestation file,
189/// under the union of `scope` and `extra_scopes` minus `excludes` — the "latest
190/// code commit" the attestation must name to be fresh. Uses an `:(exclude)`
191/// pathspec so the attestation's own commit never counts as code. `extra_scopes`
192/// and `excludes` are repo-root-relative (`:(top)` pathspec magic), so they match
193/// from the top of the tree regardless of `repo` being a subdirectory cwd — that
194/// is how a sibling tree outside `scope` joins the walk. When `base` is
195/// `Some`, the walk is restricted to the commits this branch introduced
196/// (`<base>..HEAD`); `None` when that range holds no such commit. Without `base`
197/// the walk covers all reachable history.
198fn latest_code_commit(
199 repo: &Path,
200 scope: &Path,
201 base: Option<&str>,
202 extra_scopes: &[PathBuf],
203 excludes: &[PathBuf],
204) -> Result<Option<String>> {
205 validate_scopes(repo, scope, extra_scopes)?;
206 let pathspec = relative_pathspec(repo, scope);
207 let mut args: Vec<String> = vec!["log".into(), "-1".into(), "--format=%H".into()];
208 // `<base>..HEAD` — commits reachable from HEAD but not `base`, i.e. the ones
209 // this branch introduced. A commit that also landed on `base` (a squash of an
210 // earlier PR) is reachable from `base`, so it's excluded here — that's the
211 // whole point.
212 if let Some(base) = base {
213 args.push(format!("{base}..HEAD"));
214 }
215 args.push("--".into());
216 // Positive pathspecs: the package's own `scope`, plus each extra root as a
217 // repo-root-relative `:(top)` pathspec so it matches from the tree top rather
218 // than relative to `repo` (the package cwd git runs in).
219 args.push(pathspec);
220 for extra in extra_scopes {
221 args.push(format!(":(top){}", extra.display()));
222 }
223 // Negative pathspecs: the attestation itself never counts as code, and each
224 // excluded (feature-gated) subtree of an extra root is carved back out.
225 args.push(format!(":(exclude){ATTESTATION_PATH}"));
226 for exclude in excludes {
227 args.push(format!(":(top,exclude){}", exclude.display()));
228 }
229 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
230 let out = git_capture(repo, &arg_refs)?;
231 Ok((!out.is_empty()).then_some(out))
232}
233
234/// `scope` as a pathspec relative to `repo` (git resolves pathspecs relative to
235/// the invocation's cwd, which is always `repo` here). `.` when `scope` is
236/// `repo` itself.
237fn relative_pathspec(repo: &Path, scope: &Path) -> String {
238 if scope == repo {
239 return ".".to_string();
240 }
241 match scope.strip_prefix(repo) {
242 Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
243 _ => scope.to_string_lossy().into_owned(),
244 }
245}
246
247/// Confirm `scope` and every `extra_scope` name at least one path git tracks under
248/// `repo`, erroring loudly on one that matches nothing (#391).
249///
250/// The documented "`scope` must be `repo` or a descendant of it" constraint was
251/// never enforced: a typo'd or outside `scope` falls through [`relative_pathspec`]
252/// as a pathspec matching nothing, and with `--base` that empty walk reports
253/// [`Verification::Fresh`] — a stale attestation that passes forever. Each
254/// `extra_scope` has the same failure mode: a misspelled shared-tree root silently
255/// drops out of the walk. Confirming the pathspec matches a tracked path before the
256/// walk turns both into an honest error naming the bad scope.
257fn validate_scopes(repo: &Path, scope: &Path, extra_scopes: &[PathBuf]) -> Result<()> {
258 let scope_spec = relative_pathspec(repo, scope);
259 if !pathspec_matches_tracked(repo, &scope_spec)? {
260 bail!(
261 "e2e verify: --scope `{}` matches no tracked path under `{}` — \
262 --scope must name `{}` or a directory beneath it that git tracks",
263 scope.display(),
264 repo.display(),
265 repo.display(),
266 );
267 }
268 for extra in extra_scopes {
269 let extra_spec = format!(":(top){}", extra.display());
270 if !pathspec_matches_tracked(repo, &extra_spec)? {
271 bail!(
272 "e2e verify: --extra-scope `{}` matches no tracked path — \
273 --extra-scope must name a repo-root-relative directory that git tracks",
274 extra.display(),
275 );
276 }
277 }
278 Ok(())
279}
280
281/// `true` when git tracks at least one path matching `pathspec` (run with cwd
282/// `repo`). A pathspec git rejects as outside the repository exits non-zero; that
283/// is treated identically to "matches nothing" — either way the scope names no
284/// tracked path.
285fn pathspec_matches_tracked(repo: &Path, pathspec: &str) -> Result<bool> {
286 let out = Command::new("git")
287 .args(["ls-files", "--", pathspec])
288 .current_dir(repo)
289 .output()
290 .with_context(|| format!("running `git ls-files -- {pathspec}`"))?;
291 Ok(out.status.success() && !out.stdout.is_empty())
292}
293
294/// Run `git` with `args` in `repo`, returning trimmed stdout; errors if git fails.
295fn git_capture(repo: &Path, args: &[&str]) -> Result<String> {
296 let out = Command::new("git")
297 .args(args)
298 .current_dir(repo)
299 .output()
300 .with_context(|| format!("running `git {}`", args.join(" ")))?;
301 if !out.status.success() {
302 bail!(
303 "`git {}` failed: {}",
304 args.join(" "),
305 String::from_utf8_lossy(&out.stderr).trim()
306 );
307 }
308 Ok(String::from_utf8(out.stdout)?.trim().to_string())
309}
310
311/// Run `git` with `args` in `repo` for its side effect; errors if git fails.
312fn git_run(repo: &Path, args: &[&str]) -> Result<()> {
313 let status = Command::new("git")
314 .args(args)
315 .current_dir(repo)
316 .status()
317 .with_context(|| format!("running `git {}`", args.join(" ")))?;
318 if !status.success() {
319 bail!("`git {}` failed", args.join(" "));
320 }
321 Ok(())
322}