anvilsign in

collin/anvil

main / crates / anvil-core / src / ci.rs
1//! Continuous integration: the pipeline definition (`.anvil/ci.toml`) and the
2//! persistence/lifecycle of CI runs. Execution (Docker) lives in `anvil-ci`.
3//!
4//! TOML rather than YAML because `[ci]` config is already TOML, so the file a
5//! user writes and the file an operator writes are now one language — and
6//! because it took the last YAML parser out of the tree.
7
8use serde::Deserialize;
9
10use crate::{
11 error::{
12 Error,
13 Result,
14 },
15 models::{
16 CiArtifact,
17 CiRun,
18 },
19};
20
21/// Run status values stored in [`CiRun::status`].
22pub mod status {
23 pub const QUEUED: &str = "queued";
24 pub const RUNNING: &str = "running";
25 pub const SUCCESS: &str = "success";
26 pub const FAILURE: &str = "failure";
27 pub const ERROR: &str = "error";
28}
29
30/// Path of the pipeline definition within a repository.
31pub const PIPELINE_PATH: &str = ".anvil/ci.toml";
32
33/// Where pipelines lived before they were TOML.
34///
35/// Nothing parses YAML any more — this exists only so that a repository still
36/// carrying the old file gets a run that *fails saying so*, instead of one
37/// that silently never gets enqueued. Delete it, and the check in
38/// `anvil-git`'s push trigger, once no live repository has one.
39pub const LEGACY_PIPELINE_PATH: &str = ".anvil/ci.yml";
40
41/// A parsed pipeline: a base image, ordered straight-line steps, and the
42/// artifacts to collect afterwards.
43#[derive(Clone, Debug, Deserialize)]
44pub struct Pipeline {
45 /// Docker image the steps run in, e.g. `anvil-runner:rust`. Optional:
46 /// omitting it selects `ci.default_image`, the shared anvil runner that
47 /// agent sessions also use. Resolve it with
48 /// [`CiConfig::resolve_image`](crate::config::CiConfig::resolve_image)
49 /// rather than reading this field directly — it is empty when omitted.
50 #[serde(default)]
51 pub image: String,
52 /// Docker platform to run on, `os/arch[/variant]` — e.g. `linux/amd64` on
53 /// a repository that ships amd64 but has arm64 runners. Empty falls back to
54 /// `[ci] platform`, and if that is empty too, to whatever the claiming
55 /// runner's daemon is native to. Resolve it with
56 /// [`CiConfig::resolve_platform`](crate::config::CiConfig::resolve_platform)
57 /// rather than reading this field.
58 ///
59 /// It is both an execution setting and a scheduling one: anvil hands the
60 /// job to a runner that is natively this platform when it has one, and
61 /// only falls back to an emulating runner when it does not.
62 #[serde(default)]
63 pub platform: String,
64 #[serde(default)]
65 pub steps: Vec<Step>,
66 #[serde(default)]
67 pub artifacts: Vec<ArtifactSpec>,
68 /// Names of repository secrets to expose as environment variables (see
69 /// `docs/secrets.md`). The run fails before starting a container unless
70 /// every one of them is available, which requires the repository to be
71 /// unlocked — anvil cannot decrypt them by itself.
72 #[serde(default)]
73 pub secrets: Vec<String>,
74}
75
76/// A declared artifact: a path in the workspace to collect after the steps
77/// run, plus optional metadata extractors (see `docs/ci-artifacts.md`).
78#[derive(Clone, Debug, Deserialize)]
79pub struct ArtifactSpec {
80 /// Display/URL name; unique within the pipeline, `[A-Za-z0-9._-]+`.
81 pub name: String,
82 /// Path relative to the workspace root. A file downloads as-is; a
83 /// directory downloads as a tarball — unless `browse` is set.
84 pub path: String,
85 /// Serve this (directory) artifact as a browsable static site instead of
86 /// a download, e.g. rustdoc output.
87 #[serde(default)]
88 pub browse: bool,
89 /// Metadata extractors: key → shell command, run *inside the job
90 /// container* after the steps. Each command's stdout (trimmed, capped)
91 /// becomes the value shown next to the artifact.
92 #[serde(default)]
93 pub meta: std::collections::BTreeMap<String, String>,
94}
95
96/// One pipeline step: a shell command, with an optional display name.
97#[derive(Clone, Debug, Deserialize)]
98pub struct Step {
99 #[serde(default)]
100 pub name: String,
101 pub run: String,
102}
103
104impl Step {
105 /// Display label: the explicit name, or the command if unnamed.
106 pub fn label(&self) -> &str {
107 if self.name.is_empty() {
108 &self.run
109 } else {
110 &self.name
111 }
112 }
113}
114
115/// Whether `platform` is a well-formed Docker platform: `os/arch`, optionally
116/// with a variant (`linux/arm/v7`), all lowercase `[a-z0-9._-]`.
117///
118/// Shape only, deliberately: the set of valid architectures is the daemon's to
119/// know, and a forge that hardcoded one would need a release to gain `riscv64`.
120/// What this does catch is the mistake worth catching — a bare `amd64` with no
121/// `os/`, which Docker would silently read as an operating system.
122pub fn valid_platform(platform: &str) -> bool {
123 let parts: Vec<&str> = platform.split('/').collect();
124 (2..=3).contains(&parts.len())
125 && parts.iter().all(|part| {
126 !part.is_empty()
127 && part
128 .chars()
129 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || ".-_".contains(c))
130 })
131}
132
133/// Parse a `.anvil/ci.toml` pipeline definition.
134pub fn parse_pipeline(src: &str) -> Result<Pipeline> {
135 let mut pipeline: Pipeline =
136 toml::from_str(src).map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?;
137 if pipeline.image.trim().is_empty() {
138 return Err(Error::Invalid(format!(
139 "{PIPELINE_PATH}: `image` is required"
140 )));
141 }
142 pipeline.platform = pipeline.platform.trim().to_string();
143 if !pipeline.platform.is_empty() && !valid_platform(&pipeline.platform) {
144 return Err(Error::Invalid(format!(
145 "{PIPELINE_PATH}: platform `{}` must be os/arch, e.g. linux/amd64",
146 pipeline.platform
147 )));
148 }
149 let mut seen = std::collections::BTreeSet::new();
150 for a in &mut pipeline.artifacts {
151 let invalid =
152 |what: &str| Error::Invalid(format!("{PIPELINE_PATH}: artifact `{}`: {what}", a.name));
153 if a.name.is_empty()
154 || !a
155 .name
156 .chars()
157 .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
158 {
159 return Err(invalid("name must be non-empty [A-Za-z0-9._-]+"));
160 }
161 if !seen.insert(a.name.clone()) {
162 return Err(invalid("duplicate name"));
163 }
164 // Normalize away a trailing slash so a directory path and the same
165 // path without the slash behave identically downstream.
166 a.path = a.path.trim_end_matches('/').to_string();
167 if a.path.is_empty()
168 || a.path.starts_with('/')
169 || a.path.split('/').any(|seg| seg == ".." || seg.is_empty())
170 {
171 return Err(invalid(
172 "path must be relative to the workspace, without `..`",
173 ));
174 }
175 // Meta keys become file names in the extractor handoff, so they get
176 // the same charset as artifact names.
177 for key in a.meta.keys() {
178 if key.is_empty()
179 || !key
180 .chars()
181 .all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
182 {
183 return Err(invalid(&format!(
184 "meta key `{key}` must be [A-Za-z0-9._-]+"
185 )));
186 }
187 }
188 }
189 for name in &pipeline.secrets {
190 if !crate::secrets::valid_name(name) {
191 return Err(Error::Invalid(format!(
192 "{PIPELINE_PATH}: secret `{name}` must be A–Z, 0–9 and _, not starting with a digit"
193 )));
194 }
195 }
196 Ok(pipeline)
197}
198
199/// Create a queued CI run for a pushed commit.
200pub async fn enqueue(db: &toasty::Db, repo_id: i64, commit: &str, ref_name: &str) -> Result<CiRun> {
201 let mut conn = db.clone();
202 let run = toasty::create!(CiRun {
203 repo_id: repo_id,
204 commit: commit,
205 ref_name: ref_name,
206 status: status::QUEUED,
207 log: "",
208 created_at: crate::now(),
209 started_at: 0,
210 finished_at: 0,
211 })
212 .exec(&mut conn)
213 .await?;
214 Ok(run)
215}
216
217/// Fetch a run by id.
218pub async fn get(db: &toasty::Db, id: i64) -> Result<Option<CiRun>> {
219 let mut conn = db.clone();
220 Ok(CiRun::filter(CiRun::fields().id().eq(id))
221 .first()
222 .exec(&mut conn)
223 .await?)
224}
225
226/// List a repository's runs, newest first, up to `limit`.
227pub async fn list_by_repo(db: &toasty::Db, repo_id: i64, limit: usize) -> Result<Vec<CiRun>> {
228 let mut conn = db.clone();
229 let runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
230 .order_by(CiRun::fields().id().desc())
231 .limit(limit)
232 .exec(&mut conn)
233 .await?;
234 Ok(runs)
235}
236
237/// The most recent run for a specific commit (for status badges).
238pub async fn latest_for_commit(
239 db: &toasty::Db,
240 repo_id: i64,
241 commit: &str,
242) -> Result<Option<CiRun>> {
243 let mut conn = db.clone();
244 let run = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
245 .filter(CiRun::fields().commit().eq(commit))
246 .order_by(CiRun::fields().id().desc())
247 .first()
248 .exec(&mut conn)
249 .await?;
250 Ok(run)
251}
252
253/// Mark a run as started (running).
254pub async fn mark_running(db: &toasty::Db, id: i64) -> Result<()> {
255 let Some(mut run) = get(db, id).await? else {
256 return Ok(());
257 };
258 let mut conn = db.clone();
259 run.update()
260 .status(status::RUNNING)
261 .started_at(crate::now())
262 .exec(&mut conn)
263 .await?;
264 Ok(())
265}
266
267/// Append a chunk to a run's log.
268pub async fn append_log(db: &toasty::Db, id: i64, chunk: &str) -> Result<()> {
269 let Some(mut run) = get(db, id).await? else {
270 return Ok(());
271 };
272 let combined = format!("{}{chunk}", run.log);
273 let mut conn = db.clone();
274 run.update().log(&combined).exec(&mut conn).await?;
275 Ok(())
276}
277
278/// Finish a run with a terminal status (`success`/`failure`/`error`).
279pub async fn finish(db: &toasty::Db, id: i64, status: &str) -> Result<()> {
280 let Some(mut run) = get(db, id).await? else {
281 return Ok(());
282 };
283 let mut conn = db.clone();
284 run.update()
285 .status(status)
286 .finished_at(crate::now())
287 .exec(&mut conn)
288 .await?;
289 Ok(())
290}
291
292/// Re-queue runs left mid-flight by a crash/restart (status `running`).
293/// Returns the ids that were requeued so the runner can pick them up.
294pub async fn requeue_interrupted(db: &toasty::Db) -> Result<Vec<i64>> {
295 let mut conn = db.clone();
296 let interrupted = CiRun::filter(CiRun::fields().status().eq(status::RUNNING))
297 .exec(&mut conn)
298 .await?;
299 let mut ids = Vec::new();
300 for mut run in interrupted {
301 let mut conn = db.clone();
302 run.update().status(status::QUEUED).exec(&mut conn).await?;
303 ids.push(run.id);
304 }
305 Ok(ids)
306}
307
308/// Put one run back on the queue.
309///
310/// Used when a runner's lease expires: the job may still be executing on an
311/// unreachable machine, but from anvil's side it is no longer accounted for,
312/// and leaving it `running` forever is worse than the chance of a second
313/// attempt.
314pub async fn requeue(db: &toasty::Db, id: i64) -> Result<()> {
315 let mut conn = db.clone();
316 let Some(mut run) = get(db, id).await? else {
317 return Ok(());
318 };
319 run.update().status(status::QUEUED).exec(&mut conn).await?;
320 Ok(())
321}
322
323/// List all queued run ids (oldest first) — used on startup to drain the queue.
324pub async fn queued_ids(db: &toasty::Db) -> Result<Vec<i64>> {
325 let mut conn = db.clone();
326 let runs = CiRun::filter(CiRun::fields().status().eq(status::QUEUED))
327 .order_by(CiRun::fields().id().asc())
328 .exec(&mut conn)
329 .await?;
330 Ok(runs.into_iter().map(|r| r.id).collect())
331}
332
333/// Record a collected artifact. `meta` is a JSON object string (`{}` if none).
334#[allow(clippy::too_many_arguments)]
335pub async fn add_artifact(
336 db: &toasty::Db,
337 run_id: i64,
338 repo_id: i64,
339 commit: &str,
340 name: &str,
341 size: i64,
342 is_dir: bool,
343 browse: bool,
344 meta: &str,
345) -> Result<CiArtifact> {
346 let mut conn = db.clone();
347 let artifact = toasty::create!(CiArtifact {
348 run_id: run_id,
349 repo_id: repo_id,
350 commit: commit,
351 name: name,
352 size: size,
353 is_dir: is_dir,
354 browse: browse,
355 meta: meta,
356 created_at: crate::now(),
357 })
358 .exec(&mut conn)
359 .await?;
360 Ok(artifact)
361}
362
363/// A run's artifacts, in declaration (insertion) order.
364pub async fn artifacts_for_run(db: &toasty::Db, run_id: i64) -> Result<Vec<CiArtifact>> {
365 let mut conn = db.clone();
366 let artifacts = CiArtifact::filter(CiArtifact::fields().run_id().eq(run_id))
367 .order_by(CiArtifact::fields().id().asc())
368 .exec(&mut conn)
369 .await?;
370 Ok(artifacts)
371}
372
373/// The newest artifact named `name` for `commit` (across that commit's runs).
374pub async fn latest_artifact(
375 db: &toasty::Db,
376 repo_id: i64,
377 commit: &str,
378 name: &str,
379) -> Result<Option<CiArtifact>> {
380 let mut conn = db.clone();
381 let artifact = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
382 .filter(CiArtifact::fields().commit().eq(commit))
383 .filter(CiArtifact::fields().name().eq(name))
384 .order_by(CiArtifact::fields().id().desc())
385 .first()
386 .exec(&mut conn)
387 .await?;
388 Ok(artifact)
389}
390
391/// All artifact rows for a repository (for GC accounting). Small at our scale.
392pub async fn artifacts_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<CiArtifact>> {
393 let mut conn = db.clone();
394 let artifacts = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
395 .exec(&mut conn)
396 .await?;
397 Ok(artifacts)
398}
399
400/// Delete the artifact rows for one commit (the on-disk directory is the
401/// caller's to remove). Used when re-running a commit and by GC.
402pub async fn delete_artifacts_for_commit(
403 db: &toasty::Db,
404 repo_id: i64,
405 commit: &str,
406) -> Result<()> {
407 let mut conn = db.clone();
408 let rows = CiArtifact::filter(CiArtifact::fields().repo_id().eq(repo_id))
409 .filter(CiArtifact::fields().commit().eq(commit))
410 .exec(&mut conn)
411 .await?;
412 for row in rows {
413 let mut conn = db.clone();
414 row.delete().exec(&mut conn).await?;
415 }
416 Ok(())
417}
418
419/// Delete every run and artifact row belonging to a repository, returning the
420/// run ids that were removed so the caller can release their leases. On-disk
421/// artifact directories are the caller's to remove — see
422/// [`repos::delete`](crate::repos::delete), the only user.
423pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<i64>> {
424 for artifact in artifacts_for_repo(db, repo_id).await? {
425 let mut conn = db.clone();
426 artifact.delete().exec(&mut conn).await?;
427 }
428 let mut conn = db.clone();
429 let runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id))
430 .exec(&mut conn)
431 .await?;
432 let mut ids = Vec::with_capacity(runs.len());
433 for run in runs {
434 ids.push(run.id);
435 let mut conn = db.clone();
436 run.delete().exec(&mut conn).await?;
437 }
438 Ok(ids)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn parses_a_basic_pipeline() {
447 let p = parse_pipeline(
448 r#"
449 image = "anvil-runner:rust"
450
451 [[steps]]
452 name = "test"
453 run = "cargo test --workspace"
454
455 [[steps]]
456 run = "cargo build --release"
457 "#,
458 )
459 .unwrap();
460 assert_eq!(p.image, "anvil-runner:rust");
461 assert_eq!(p.steps.len(), 2);
462 assert_eq!(p.steps[0].label(), "test");
463 // Unnamed step falls back to its command for the label.
464 assert_eq!(p.steps[1].label(), "cargo build --release");
465 }
466
467 #[test]
468 fn requires_an_image() {
469 assert!(parse_pipeline("steps = []\n").is_err());
470 }
471
472 /// The one thing TOML makes easy to get wrong that YAML did not: a
473 /// top-level key written *after* an array-of-tables belongs to the last
474 /// table, so `image` below is `steps[0].image` and the pipeline has no
475 /// image of its own. Rejecting it for the missing `image` is the right
476 /// answer; this pins that it is rejected at all rather than silently
477 /// running a pipeline whose image came from `[ci] default_image`.
478 #[test]
479 fn rejects_a_key_stranded_after_a_table() {
480 let err = parse_pipeline(
481 r#"
482 [[steps]]
483 run = "cargo test"
484
485 image = "anvil-runner:rust"
486 "#,
487 )
488 .unwrap_err();
489 assert!(err.to_string().contains("`image` is required"), "{err}");
490 }
491
492 /// `platform` is optional, and when present has to be `os/arch` — a bare
493 /// `amd64` would otherwise reach Docker as an *operating system* named
494 /// amd64, which fails much later and much less clearly.
495 #[test]
496 fn parses_and_validates_the_platform() {
497 let p = parse_pipeline(
498 r#"image = "anvil-runner:rust"
499 platform = "linux/amd64"
500 steps = []"#,
501 )
502 .unwrap();
503 assert_eq!(p.platform, "linux/amd64");
504 assert!(
505 parse_pipeline("image = \"anvil-runner:rust\"\nsteps = []\n")
506 .unwrap()
507 .platform
508 .is_empty()
509 );
510
511 assert!(valid_platform("linux/arm64"));
512 assert!(valid_platform("linux/arm/v7"));
513 assert!(!valid_platform("amd64"));
514 assert!(!valid_platform("linux/"));
515 assert!(!valid_platform("linux/amd64/v1/extra"));
516 assert!(!valid_platform("Linux/AMD64"));
517 assert!(
518 parse_pipeline(
519 r#"image = "anvil-runner:rust"
520 platform = "amd64"
521 steps = []"#
522 )
523 .is_err()
524 );
525 }
526
527 #[test]
528 fn parses_and_validates_artifacts() {
529 let p = parse_pipeline(
530 r#"
531 image = "anvil-runner:rust"
532
533 [[artifacts]]
534 name = "anvild"
535 path = "target/release/anvild"
536 meta.version = "./target/release/anvild --version"
537
538 [[artifacts]]
539 name = "doc"
540 path = "target/doc/"
541 browse = true
542 "#,
543 )
544 .unwrap();
545 assert_eq!(p.artifacts.len(), 2);
546 assert_eq!(p.artifacts[0].name, "anvild");
547 assert_eq!(
548 p.artifacts[0].meta["version"],
549 "./target/release/anvild --version"
550 );
551 assert!(!p.artifacts[0].browse);
552 assert_eq!(p.artifacts[1].path, "target/doc", "trailing slash trimmed");
553 assert!(p.artifacts[1].browse);
554
555 // Inline tables keep each case to one line; `image` first, because a
556 // bare key after an array-of-tables would land inside it.
557 let must_fail = |artifacts: &str, why: &str| {
558 assert!(
559 parse_pipeline(&format!("image = \"i\"\nartifacts = {artifacts}\n")).is_err(),
560 "{why}"
561 );
562 };
563 must_fail(
564 r#"[{ name = "a b", path = "x" }]"#,
565 "space in name rejected",
566 );
567 must_fail(
568 r#"[{ name = "a/b", path = "x" }]"#,
569 "slash in name rejected",
570 );
571 must_fail(r#"[{ name = "", path = "x" }]"#, "empty name rejected");
572 must_fail(
573 r#"[{ name = "a", path = "x" }, { name = "a", path = "y" }]"#,
574 "duplicate name rejected",
575 );
576 must_fail(
577 r#"[{ name = "a", path = "/etc" }]"#,
578 "absolute path rejected",
579 );
580 must_fail(r#"[{ name = "a", path = "../up" }]"#, "traversal rejected");
581 must_fail(
582 r#"[{ name = "a", path = "x//y" }]"#,
583 "empty segment rejected",
584 );
585 must_fail(r#"[{ name = "a", path = "" }]"#, "empty path rejected");
586 }
587
588 // Exercises the run-lifecycle queries against a real SQLite database, to
589 // confirm the ORM-level `order_by`/`limit`/filter actually work (Toasty is
590 // pre-1.0, so we don't take that on faith).
591 #[tokio::test]
592 async fn ordering_and_limit_run_in_the_database() {
593 let dir = tempfile::tempdir().unwrap();
594 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
595
596 for c in ["aaa", "bbb", "ccc"] {
597 enqueue(&db, 1, c, "main").await.unwrap();
598 }
599 enqueue(&db, 2, "zzz", "main").await.unwrap(); // a different repo
600
601 // Newest-first, limited to 2 — and scoped to repo 1.
602 let runs = list_by_repo(&db, 1, 2).await.unwrap();
603 assert_eq!(runs.len(), 2);
604 assert!(runs[0].id > runs[1].id, "newest first");
605 assert_eq!(runs[0].commit, "ccc");
606 assert!(runs.iter().all(|r| r.repo_id == 1));
607
608 // Commit filter pushed into the query (not an in-memory retain).
609 let latest = latest_for_commit(&db, 1, "bbb").await.unwrap().unwrap();
610 assert_eq!(latest.commit, "bbb");
611 assert_eq!(latest.repo_id, 1);
612 assert!(latest_for_commit(&db, 1, "nope").await.unwrap().is_none());
613
614 // All queued, ascending by id.
615 let queued = queued_ids(&db).await.unwrap();
616 assert_eq!(queued.len(), 4);
617 assert!(queued.windows(2).all(|w| w[0] < w[1]), "ascending");
618 }
619
620 #[tokio::test]
621 async fn artifact_rows_round_trip() {
622 let dir = tempfile::tempdir().unwrap();
623 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
624
625 add_artifact(&db, 1, 7, "abc", "bin", 100, false, false, "{}")
626 .await
627 .unwrap();
628 add_artifact(&db, 1, 7, "abc", "doc", 5000, true, true, r#"{"v":"1"}"#)
629 .await
630 .unwrap();
631 // A newer run of the same commit supersedes for `latest_artifact`.
632 add_artifact(&db, 2, 7, "abc", "bin", 200, false, false, "{}")
633 .await
634 .unwrap();
635
636 let run1 = artifacts_for_run(&db, 1).await.unwrap();
637 assert_eq!(run1.len(), 2);
638 assert_eq!(run1[0].name, "bin");
639 assert!(run1[1].browse);
640
641 let latest = latest_artifact(&db, 7, "abc", "bin")
642 .await
643 .unwrap()
644 .unwrap();
645 assert_eq!(latest.run_id, 2);
646 assert_eq!(latest.size, 200);
647 assert!(
648 latest_artifact(&db, 8, "abc", "bin")
649 .await
650 .unwrap()
651 .is_none(),
652 "scoped to repo"
653 );
654
655 delete_artifacts_for_commit(&db, 7, "abc").await.unwrap();
656 assert!(artifacts_for_repo(&db, 7).await.unwrap().is_empty());
657 }
658
659 /// The docs are the only specification of this format a user ever reads,
660 /// so a pipeline printed in one has to be a pipeline this parser accepts.
661 /// Every ```toml block declaring `[[steps]]` is one; the `[ci]` config
662 /// snippets alongside them are not, and are skipped by that same rule.
663 #[test]
664 fn every_documented_pipeline_parses() {
665 let docs = [
666 ("DEPLOY.md", include_str!("../../../DEPLOY.md")),
667 ("docs/secrets.md", include_str!("../../../docs/secrets.md")),
668 (
669 "docs/ci-artifacts.md",
670 include_str!("../../../docs/ci-artifacts.md"),
671 ),
672 ];
673 let mut checked = 0;
674 for (name, text) in docs {
675 for (i, block) in text.split("```toml\n").skip(1).enumerate() {
676 let block = block.split("```").next().unwrap();
677 if !block.contains("[[steps]]") {
678 continue;
679 }
680 parse_pipeline(block)
681 .unwrap_or_else(|e| panic!("{name} block {i}: {e}\n---\n{block}"));
682 checked += 1;
683 }
684 }
685 // Not an exact count — a new example should not fail this — but zero
686 // would mean the extraction broke and the test was passing on nothing.
687 assert!(checked > 0, "found no documented pipelines to check");
688 }
689}