anvilsign in

collin/anvil · 2c82299b

Pipelines are TOML: retire serde_yaml, move `.anvil/ci.yml` to `.anvil/ci.toml`

Collin Richards · 2026-08-25 07:16 UTC · 2c82299b941fb712672cd5743a3475eb24fec84a · parent e4b80fbe · browse files

modifiedCLAUDE.md+2 −2
⋯ 78 unchanged lines
7979 `anvil-worker` (the runner binary), five endpoints under `/-/runner/`,
8080 in-memory leases, and no Docker socket on the deployed container.
8181
82-M2 is done: `platform:` in `.anvil/ci.yml`, a `[ci] platform` default, and
82+M2 is done: `platform` in `.anvil/ci.toml`, a `[ci] platform` default, and
8383 two-tier routing — a claiming runner is offered the runs that match its
8484 architecture (or name none), then the runs no *connected* runner is native to,
8585 so a lone arm64 Mac still runs `linux/amd64` pipelines under Rosetta instead of
⋯ 4 unchanged lines
9090 **Invariants to not break.** These are the reasons the split is shaped the way
9191 it is, and each is easy to undo by accident:
9292
93-- The runner never parses `.anvil/ci.yml`. Image resolution, the allowlist
93+- The runner never parses `.anvil/ci.toml`. Image resolution, the allowlist
9494 check and script assembly happen in `build_job`, so a runner cannot widen
9595 what it is permitted to run.
9696 - `store_artifact` stays server-side. The runner uploads a raw tar; `browse`
⋯ 47 unchanged lines
modifiedCargo.toml+6 −1
⋯ 91 unchanged lines
9292 # paying for `signal-hook-registry` and `errno` and a chunk of tokio's own
9393 # compile for nothing. Add a feature back the moment something needs it.
9494
95+# Both configuration languages anvil reads: the operator's `anvil.toml` and the
96+# repository's `.anvil/ci.toml`. Pipelines used to be YAML, which meant a
97+# serde_yaml — the archived dtolnay one, unmaintained since 2024 — in the tree
98+# to parse one file per CI run. Making them TOML retired that parser and left
99+# users one syntax to learn instead of two.
100+
95101 aes-gcm = { version = "0.11.1" }
96102 anyhow = { version = "1.0.104" }
97103 argon2 = { version = "0.6.0-rc.8", features = ["rand_core"] }
⋯ 22 unchanged lines
120126 rustls = { version = "0.23.43", default-features = false, features = ["ring", "logging", "std", "tls12"] }
121127 serde = { version = "1.0.229", features = ["derive"] }
122128 serde_json = { version = "1.0.151" }
123-serde_yaml = { version = "0.9.34" }
124129 sha2 = { version = "0.11.0" }
125130 similar = { version = "3.2.0" }
126131 ssh-key = { version = "0.7.0-rc.11" }
⋯ 11 unchanged lines
modifiedDEPLOY.md+10 −7
⋯ 203 unchanged lines
204204
205205 ## 8. The redeploy webhook (CD)
206206
207-Any repo with a `.anvil/ci.yml` runs CI on push. A pipeline is just an image
207+Any repo with a `.anvil/ci.toml` runs CI on push. A pipeline is just an image
208208 plus steps:
209209
210-```yaml
211-image: anvil-runner:rust
212-steps:
213- - name: test
214- run: cargo test --workspace
215- - run: cargo build --release
210+```toml
211+image = "anvil-runner:rust"
212+
213+[[steps]]
214+name = "test"
215+run = "cargo test --workspace"
216+
217+[[steps]]
218+run = "cargo build --release"
216219 ```
217220
218221 Runs show up at `/{owner}/{repo}/ci`, with a per-commit status badge on the
⋯ 60 unchanged lines
modifiedTODO.md+6 −0
⋯ 5 unchanged lines
66
77 # Backlog
88
9+- [ ] finish the CI pipeline move to TOML: rename `.anvil/ci.yml` to
10+ `.anvil/ci.toml` (and convert it) in every repo on the instance, then delete
11+ `ci::LEGACY_PIPELINE_PATH` and the legacy branch of `load_pipeline` /
12+ `enqueue_ci_for_push`. Until then a stale `.anvil/ci.yml` still enqueues a
13+ run, which fails telling you to rename the file — the point being that CI
14+ going quiet is louder than CI going missing.
915
1016 - [ ] agent sessions, next milestones (docs/agent-sessions.md):
1117 - a real checkout: the container clones from anvil's smart-HTTP endpoint and
⋯ 98 unchanged lines
modifiedcrates/anvil-ci/Cargo.toml+1 −1
⋯ 4 unchanged lines
55 license.workspace = true
66 repository.workspace = true
77 rust-version.workspace = true
8-description = "CI runner for anvil: executes .anvil/ci.yml pipelines in Docker containers via the socket."
8+description = "CI runner for anvil: executes .anvil/ci.toml pipelines in Docker containers via the socket."
99
1010 [lints]
1111 workspace = true
⋯ 14 unchanged lines
modifiedcrates/anvil-ci/src/lib.rs+33 −19
⋯ 6 unchanged lines
77 //! `docs/remote-runners.md`.
88 //!
99 //! What stays here is everything a runner must not be trusted with: resolving
10-//! the repo, materializing the commit's tree, parsing `.anvil/ci.yml`, checking
10+//! the repo, materializing the commit's tree, parsing `.anvil/ci.toml`, checking
1111 //! the image allowlist, opening the secret vault, assembling the shell script,
1212 //! and deciding where artifacts land on disk. A runner receives an image, a
1313 //! script, a tar and some limits ([`anvil_job::JobSpec`]) and can neither widen
⋯ 35 unchanged lines
4949 ///
5050 /// The server's half of the split (see `docs/remote-runners.md`): image
5151 /// resolution, the allowlist check and script assembly all happen here. A
52-/// runner therefore never parses `.anvil/ci.yml`, and cannot widen what it was
52+/// runner therefore never parses `.anvil/ci.toml`, and cannot widen what it was
5353 /// permitted to run by reinterpreting one.
5454 fn build_job(
5555 run_id: i64,
⋯ 2 unchanged lines
5858 env: &[(String, String)],
5959 ) -> Result<JobSpec, String> {
6060 // What the pipeline asked for, or the shared runner image when it omitted
61- // `image:` entirely.
61+ // `image` entirely.
6262 let image = cfg.resolve_image(&pipeline.image);
6363 if !cfg.image_allowed(image) {
6464 return Err(format!(
6565 "image {image} is not permitted by ci.allowed_images"
6666 ));
6767 }
68- // `platform:`, else `[ci] platform`, else the runner's native one. The
68+ // `platform`, else `[ci] platform`, else the runner's native one. The
6969 // pipeline's own value was validated at parse time; this catches a
7070 // malformed `[ci] platform`, which nothing else would.
7171 let platform = cfg.resolve_platform(&pipeline.platform);
⋯ 225 unchanged lines
297297 .collect()
298298 }
299299
300+/// Read and parse the pipeline a run's commit defines.
301+///
302+/// The three callers below each need the whole `Pipeline` for a different
303+/// field, so the read lives here rather than three times over.
304+///
305+/// A commit that has only the pre-TOML `.anvil/ci.yml` gets told to rename it.
306+/// Without that the run would fail as "pipeline missing", which is true and
307+/// useless — the file is right there.
308+fn load_pipeline(repo_path: &Path, commit: &str) -> Result<ci::Pipeline, String> {
309+ let src = browse::read_blob(repo_path, commit, ci::PIPELINE_PATH).map_err(|e| e.to_string())?;
310+ let Some(src) = src else {
311+ let legacy = browse::read_blob(repo_path, commit, ci::LEGACY_PIPELINE_PATH);
312+ return Err(if matches!(legacy, Ok(Some(_))) {
313+ format!(
314+ "{} is no longer read — pipelines are TOML now. Rename it to {} and convert it \
315+ (see docs/ci-artifacts.md for the shape).",
316+ ci::LEGACY_PIPELINE_PATH,
317+ ci::PIPELINE_PATH,
318+ )
319+ } else {
320+ format!("{} missing at {commit}", ci::PIPELINE_PATH)
321+ });
322+ };
323+ ci::parse_pipeline(&String::from_utf8_lossy(&src)).map_err(|e| e.to_string())
324+}
325+
300326 /// The platform a queued run needs, for the routing decision — `None` when it
301327 /// names none and can run anywhere.
302328 ///
⋯ 1 unchanged line
304330 /// [`prepare`] does that for the run that is actually taken.
305331 async fn target_platform(app: &App, run_id: i64) -> Result<Option<String>, String> {
306332 let (_, _, repo_path, run) = resolve(app, run_id).await?;
307- let yaml = browse::read_blob(&repo_path, &run.commit, ci::PIPELINE_PATH)
308- .map_err(|e| e.to_string())?
309- .ok_or_else(|| format!("{} missing at {}", ci::PIPELINE_PATH, run.commit))?;
310- let pipeline =
311- ci::parse_pipeline(&String::from_utf8_lossy(&yaml)).map_err(|e| e.to_string())?;
333+ let pipeline = load_pipeline(&repo_path, &run.commit)?;
312334 Ok(app
313335 .config
314336 .ci
⋯ 25 unchanged lines
340362 .ok_or("owner not found")?;
341363 let repo_path = storage::repo_path(&app.config.repositories_dir(), &owner.username, &repo.name);
342364
343- let yaml = browse::read_blob(&repo_path, &run.commit, ci::PIPELINE_PATH)
344- .map_err(|e| e.to_string())?
345- .ok_or_else(|| format!("{} missing at {}", ci::PIPELINE_PATH, run.commit))?;
346- let pipeline =
347- ci::parse_pipeline(&String::from_utf8_lossy(&yaml)).map_err(|e| e.to_string())?;
365+ let pipeline = load_pipeline(&repo_path, &run.commit)?;
348366
349367 let short = &run.commit[..run.commit.len().min(12)];
350368 let mut log = format!(
⋯ 84 unchanged lines
435453 name: &str,
436454 ) -> Result<Option<ArtifactSpec>, String> {
437455 let (_, _, repo_path, run) = resolve(app, run_id).await?;
438- let yaml = browse::read_blob(&repo_path, &run.commit, ci::PIPELINE_PATH)
439- .map_err(|e| e.to_string())?
440- .ok_or("pipeline missing")?;
441- let pipeline =
442- ci::parse_pipeline(&String::from_utf8_lossy(&yaml)).map_err(|e| e.to_string())?;
456+ let pipeline = load_pipeline(&repo_path, &run.commit)?;
443457 Ok(pipeline
444458 .artifacts
445459 .iter()
⋯ 491 unchanged lines
modifiedcrates/anvil-core/Cargo.toml+0 −1
⋯ 25 unchanged lines
2626 ssh-key.workspace = true
2727 serde.workspace = true
2828 serde_json.workspace = true
29-serde_yaml.workspace = true
3029 toml.workspace = true
3130 thiserror.workspace = true
3231 tracing.workspace = true
⋯ 12 unchanged lines
modifiedcrates/anvil-core/src/ci.rs+120 −41
11 //! Continuous integration: the pipeline definition (`.anvil/ci.toml`) and the
22 //! 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.
37
48 use serde::Deserialize;
59
⋯ 18 unchanged lines
2428 }
2529
2630 /// Path of the pipeline definition within a repository.
27-pub const PIPELINE_PATH: &str = ".anvil/ci.yml";
31+pub const PIPELINE_PATH: &str = ".anvil/ci.toml";
2832
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.
39+pub const LEGACY_PIPELINE_PATH: &str = ".anvil/ci.yml";
40+
2941 /// A parsed pipeline: a base image, ordered straight-line steps, and the
3042 /// artifacts to collect afterwards.
3143 #[derive(Clone, Debug, Deserialize)]
⋯ 86 unchanged lines
118130 })
119131 }
120132
121-/// Parse a `.anvil/ci.yml` pipeline definition.
122-pub fn parse_pipeline(yaml: &str) -> Result<Pipeline> {
123- let mut pipeline: Pipeline = serde_yaml::from_str(yaml)
124- .map_err(|e| Error::Invalid(format!("invalid {PIPELINE_PATH}: {e}")))?;
133+/// Parse a `.anvil/ci.toml` pipeline definition.
134+pub 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}")))?;
125137 if pipeline.image.trim().is_empty() {
126138 return Err(Error::Invalid(format!(
127139 "{PIPELINE_PATH}: `image` is required"
⋯ 304 unchanged lines
432444
433445 #[test]
434446 fn parses_a_basic_pipeline() {
435- // YAML is indentation-sensitive, so the fixture is flush-left.
436447 let p = parse_pipeline(
437- r#"image: anvil-runner:rust
438-steps:
439- - name: test
440- run: cargo test --workspace
441- - run: cargo build --release
442-"#,
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+ "#,
443458 )
444459 .unwrap();
445460 assert_eq!(p.image, "anvil-runner:rust");
⋯ 5 unchanged lines
451466
452467 #[test]
453468 fn requires_an_image() {
454- assert!(parse_pipeline("steps: []\n").is_err());
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}");
455490 }
456491
457- /// `platform:` is optional, and when present has to be `os/arch` — a bare
492+ /// `platform` is optional, and when present has to be `os/arch` — a bare
458493 /// `amd64` would otherwise reach Docker as an *operating system* named
459494 /// amd64, which fails much later and much less clearly.
460495 #[test]
461496 fn parses_and_validates_the_platform() {
462- let p =
463- parse_pipeline("image: anvil-runner:rust\nplatform: linux/amd64\nsteps: []\n").unwrap();
497+ let p = parse_pipeline(
498+ r#"image = "anvil-runner:rust"
499+ platform = "linux/amd64"
500+ steps = []"#,
501+ )
502+ .unwrap();
464503 assert_eq!(p.platform, "linux/amd64");
465504 assert!(
466- parse_pipeline("image: anvil-runner:rust\nsteps: []\n")
505+ parse_pipeline("image = \"anvil-runner:rust\"\nsteps = []\n")
467506 .unwrap()
468507 .platform
469508 .is_empty()
⋯ 5 unchanged lines
475514 assert!(!valid_platform("linux/"));
476515 assert!(!valid_platform("linux/amd64/v1/extra"));
477516 assert!(!valid_platform("Linux/AMD64"));
478- assert!(parse_pipeline("image: anvil-runner:rust\nplatform: amd64\nsteps: []\n").is_err());
517+ assert!(
518+ parse_pipeline(
519+ r#"image = "anvil-runner:rust"
520+ platform = "amd64"
521+ steps = []"#
522+ )
523+ .is_err()
524+ );
479525 }
480526
481527 #[test]
482528 fn parses_and_validates_artifacts() {
483529 let p = parse_pipeline(
484- r#"image: anvil-runner:rust
485-artifacts:
486- - name: anvild
487- path: target/release/anvild
488- meta:
489- version: ./target/release/anvild --version
490- - name: doc
491- path: target/doc/
492- browse: true
493-"#,
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+ "#,
494543 )
495544 .unwrap();
496545 assert_eq!(p.artifacts.len(), 2);
⋯ 6 unchanged lines
503552 assert_eq!(p.artifacts[1].path, "target/doc", "trailing slash trimmed");
504553 assert!(p.artifacts[1].browse);
505554
506- let must_fail = |yaml: &str, why: &str| {
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| {
507558 assert!(
508- parse_pipeline(&format!("image: i\n{yaml}")).is_err(),
559+ parse_pipeline(&format!("image = \"i\"\nartifacts = {artifacts}\n")).is_err(),
509560 "{why}"
510561 );
511562 };
512563 must_fail(
513- "artifacts: [{name: 'a b', path: x}]",
564+ r#"[{ name = "a b", path = "x" }]"#,
514565 "space in name rejected",
515566 );
516567 must_fail(
517- "artifacts: [{name: 'a/b', path: x}]",
568+ r#"[{ name = "a/b", path = "x" }]"#,
518569 "slash in name rejected",
519570 );
520- must_fail("artifacts: [{name: '', path: x}]", "empty name rejected");
571+ must_fail(r#"[{ name = "", path = "x" }]"#, "empty name rejected");
521572 must_fail(
522- "artifacts: [{name: a, path: x}, {name: a, path: y}]",
573+ r#"[{ name = "a", path = "x" }, { name = "a", path = "y" }]"#,
523574 "duplicate name rejected",
524575 );
525576 must_fail(
526- "artifacts: [{name: a, path: /etc}]",
577+ r#"[{ name = "a", path = "/etc" }]"#,
527578 "absolute path rejected",
528579 );
580+ must_fail(r#"[{ name = "a", path = "../up" }]"#, "traversal rejected");
529581 must_fail(
530- "artifacts: [{name: a, path: '../up'}]",
531- "traversal rejected",
532- );
533- must_fail(
534- "artifacts: [{name: a, path: 'x//y'}]",
582+ r#"[{ name = "a", path = "x//y" }]"#,
535583 "empty segment rejected",
536584 );
537- must_fail("artifacts: [{name: a, path: ''}]", "empty path rejected");
585+ must_fail(r#"[{ name = "a", path = "" }]"#, "empty path rejected");
538586 }
539587
540588 // Exercises the run-lifecycle queries against a real SQLite database, to
⋯ 66 unchanged lines
607655 delete_artifacts_for_commit(&db, 7, "abc").await.unwrap();
608656 assert!(artifacts_for_repo(&db, 7).await.unwrap().is_empty());
609657 }
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+ }
610689 }
modifiedcrates/anvil-core/src/config.rs+5 −5
⋯ 164 unchanged lines
165165 ///
166166 /// [`default_image`](CiConfig::default_image) is always permitted, whatever
167167 /// this says — otherwise an allowlist would break every pipeline that
168- /// simply omits `image:`.
168+ /// simply omits `image`.
169169 pub allowed_images: Vec<String>,
170- /// Image used by a pipeline that omits `image:`. This is the shared anvil
170+ /// Image used by a pipeline that omits `image`. This is the shared anvil
171171 /// runner (`docker/runner/Dockerfile`) — the same image agent sessions run
172172 /// in, carrying tmux, git, fish and Claude Code. Built locally rather than
173173 /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller
174174 /// must tolerate a failed pull.
175175 pub default_image: String,
176- /// Platform (`os/arch`) for a pipeline that omits `platform:`. Empty runs
176+ /// Platform (`os/arch`) for a pipeline that omits `platform`. Empty runs
177177 /// every job on whatever the claiming runner is native to, which is the
178178 /// behaviour of an instance that never sets this.
179179 ///
⋯ 203 unchanged lines
383383 }
384384
385385 /// The image a pipeline runs in: what it asked for, or
386- /// [`default_image`](CiConfig::default_image) when it omitted `image:`.
386+ /// [`default_image`](CiConfig::default_image) when it omitted `image`.
387387 pub fn resolve_image<'a>(&'a self, requested: &'a str) -> &'a str {
388388 if requested.is_empty() {
389389 &self.default_image
⋯ 229 unchanged lines
619619 }
620620
621621 /// An allowlist must not lock out the default image: a pipeline that simply
622- /// omits `image:` never named anything for the operator to allow, and
622+ /// omits `image` never named anything for the operator to allow, and
623623 /// failing those runs is the regression this whole path risks.
624624 #[test]
625625 fn the_default_image_is_allowed_even_under_an_allowlist() {
⋯ 111 unchanged lines
modifiedcrates/anvil-core/src/secrets.rs+1 −1
⋯ 860 unchanged lines
861861 /// available as the error, so the runner can say exactly what is missing.
862862 ///
863863 /// Asking for nothing always succeeds, sealed repository or not — the
864- /// overwhelmingly common pipeline declares no `secrets:` at all, and
864+ /// overwhelmingly common pipeline declares no `secrets` at all, and
865865 /// failing it here would mean no repository could run CI until someone had
866866 /// unlocked it for secrets it does not use.
867867 pub fn take(
⋯ 228 unchanged lines
modifiedcrates/anvil-git/src/trigger.rs+12 −6
11 //! Post-push CI trigger: detect which branches changed on a push and enqueue a
2-//! CI run for any whose new commit defines a pipeline (`.anvil/ci.yml`).
2+//! CI run for any whose new commit defines a pipeline (`.anvil/ci.toml`).
33 //!
44 //! Used by the receive-pack paths in `anvil-web` and `anvil-ssh`: snapshot the
55 //! branch tips *before* the push, then call [`enqueue_ci_for_push`] after it
⋯ 43 unchanged lines
4949 if before.get(branch).map(String::as_str) == Some(new_tip.as_str()) {
5050 continue; // unchanged branch
5151 }
52- let has_pipeline = matches!(
53- crate::browse::read_blob(repo_path, new_tip, anvil_core::ci::PIPELINE_PATH),
54- Ok(Some(_))
55- );
56- if !has_pipeline {
52+ let defines = |path| {
53+ matches!(
54+ crate::browse::read_blob(repo_path, new_tip, path),
55+ Ok(Some(_))
56+ )
57+ };
58+ // The legacy path counts as "defines a pipeline" on purpose: it gets a
59+ // run that fails telling you to rename the file (see `load_pipeline`
60+ // in anvil-ci), which a repository whose CI just stopped needs to see.
61+ if !defines(anvil_core::ci::PIPELINE_PATH) && !defines(anvil_core::ci::LEGACY_PIPELINE_PATH)
62+ {
5763 continue;
5864 }
5965 match anvil_core::ci::enqueue(db, repo_id, new_tip, branch).await {
⋯ 9 unchanged lines
modifiedcrates/anvil-job/src/lib.rs+1 −1
⋯ 4 unchanged lines
55 //! link `anvil-core` (and therefore toasty, SQLite, gix and the rest of the
66 //! forge) just to learn the shape of a job.
77 //!
8-//! The split it encodes: anvil resolves the repo, parses `.anvil/ci.yml`,
8+//! The split it encodes: anvil resolves the repo, parses `.anvil/ci.toml`,
99 //! checks the image allowlist, opens the secret vault and assembles the shell
1010 //! script. A runner receives an image, a script, a tar and some limits, and
1111 //! never parses a pipeline. That keeps this format stable as the pipeline
⋯ 170 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+1 −1
⋯ 2552 unchanged lines
25532553 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
25542554 @if runs.is_empty() {
25552555 p.muted {
2556- "No CI runs yet. Add a " code { ".anvil/ci.yml" }
2556+ "No CI runs yet. Add a " code { ".anvil/ci.toml" }
25572557 " pipeline and push to trigger one."
25582558 }
25592559 } @else {
⋯ 440 unchanged lines
modifieddocs/agent-sessions.md+2 −2
⋯ 67 unchanged lines
6868 a failed pull as non-fatal when the image is already present locally — an
6969 unconditional pull, which is what CI used to do, fails for exactly this image.
7070
71-A pipeline may still name any image it likes; `image:` in `.anvil/ci.yml` is now
71+A pipeline may still name any image it likes; `image` in `.anvil/ci.toml` is now
7272 simply optional, and omitting it selects `ci.default_image`. The default image
7373 is always permitted regardless of `ci.allowed_images`, since a pipeline that
74-omits `image:` never named anything for an operator to allow.
74+omits `image` never named anything for an operator to allow.
7575
7676 ## Lifecycle
7777
⋯ 79 unchanged lines
modifieddocs/ci-artifacts.md+31 −22
⋯ 39 unchanged lines
4040 stream is aborted, and the artifact skipped with a logged note, when a cap
4141 is exceeded — never the run failed retroactively.
4242
43-## Declaring artifacts in `.anvil/ci.yml`
43+## Declaring artifacts in `.anvil/ci.toml`
4444
45-```yaml
46-image: anvil-runner:rust
47-steps:
48- - name: build
49- run: cargo build --release
50- - name: test
51- run: cargo test --workspace
45+```toml
46+image = "anvil-runner:rust"
5247
53-artifacts:
54- - name: anvild # unique per pipeline; [a-zA-Z0-9._-]+
55- path: target/release/anvild # file → download; dir → tar.gz download
56- meta:
57- version: ./target/release/anvild --version
58- size: stat -c %s target/release/anvild
59- - name: coverage
60- path: coverage/
61- meta:
62- line_pct: jq -r .line_pct coverage/summary.json
63- - name: doc
64- path: target/doc/ # rustdoc HTML subtree
65- browse: true # serve as a static site, don't download
48+[[steps]]
49+name = "build"
50+run = "cargo build --release"
51+
52+[[steps]]
53+name = "test"
54+run = "cargo test --workspace"
55+
56+[[artifacts]]
57+name = "anvild" # unique per pipeline; [a-zA-Z0-9._-]+
58+path = "target/release/anvild" # file → download; dir → tar.gz download
59+meta.version = "./target/release/anvild --version"
60+meta.size = "stat -c %s target/release/anvild"
61+
62+[[artifacts]]
63+name = "coverage"
64+path = "coverage/"
65+meta.line_pct = "jq -r .line_pct coverage/summary.json"
66+
67+[[artifacts]]
68+name = "doc"
69+path = "target/doc/" # rustdoc HTML subtree
70+browse = true # serve as a static site, don't download
6671 ```
6772
73+Every bare key belongs to whichever `[[table]]` precedes it, so `image` (and
74+`platform`, and `secrets`) has to come before the first `[[steps]]` — put them
75+at the top and the rest reads in order.
76+
6877 `meta` is a map of key → shell command. The commands run **inside the job
6978 container** (appended to the script after the steps, still `set -e`-free —
7079 each is best-effort), because artifact content is untrusted and must never be
⋯ 80 unchanged lines
151160
152161 ## Implementation order
153162
154-1. Schema + config: `CiArtifact` model, `[ci]` caps, parse `artifacts:` in
163+1. Schema + config: `CiArtifact` model, `[ci]` caps, parse `artifacts` in
155164 `ci.rs` (with path/name validation + tests).
156165 2. Broker: collect declared paths via `download_from_container`, store to
157166 disk, write rows; meta-extractor trailer + `.anvil-meta.json` pickup.
⋯ 4 unchanged lines
modifieddocs/remote-runners.md+4 −4
⋯ 77 unchanged lines
7878 `execute` it parks the job until a runner claims it.
7979
8080 Script assembly stays server-side deliberately. The runner then never parses
81-`.anvil/ci.yml` and holds no pipeline model — it receives an image, a script,
81+`.anvil/ci.toml` and holds no pipeline model — it receives an image, a script,
8282 sandbox caps, and a tar. That keeps the wire format stable as the pipeline
8383 schema grows.
8484
⋯ 124 unchanged lines
209209 **What jobs run on.** On an M2, a multi-arch image resolves to arm64, so
210210 `cargo test` tests an architecture you don't ship. That is what M2 fixes.
211211
212-A job's platform comes from `platform:` in `.anvil/ci.yml`, falling back to
212+A job's platform comes from `platform` in `.anvil/ci.toml`, falling back to
213213 `[ci] platform`, falling back to the claiming runner's native architecture —
214214 so an instance that sets neither behaves exactly as it did before. It reaches
215215 `CreateContainerOptions.platform` and `CreateImageOptions.platform`, and the
⋯ 21 unchanged lines
237237 those jobs the moment the new runner's first claim registers it — no
238238 configuration, which is the property the dial-out model was chosen for.
239239
240-The corollary worth stating plainly: `platform:` is not a promise of native
240+The corollary worth stating plainly: `platform` is not a promise of native
241241 execution. It is a promise about *what the job runs*, which the runner enforces
242242 by inspecting the image it ended up with (`anvil-docker::check_platform`) and
243243 failing the job if the architecture is not the one asked for. Where it runs is
⋯ 177 unchanged lines
421421 `connect_with_defaults`, `run_worker` → `run_dispatcher`, and no socket mount in
422422 the deployed container. Deploys keep using the existing webhook.
423423
424-**M2 — platform. Done.** `platform:` in `.anvil/ci.yml`, a `[ci] platform`
424+**M2 — platform. Done.** `platform` in `.anvil/ci.toml`, a `[ci] platform`
425425 default, `CiConfig::resolve_platform`, the two-tier routing above (backed by a
426426 runner registry in `Dispatch`), the emulation note in the run header, and an
427427 architecture check on the image the runner actually got.
⋯ 26 unchanged lines
modifieddocs/secrets.md+6 −5
⋯ 58 unchanged lines
5959
6060 A pipeline declares what it needs:
6161
62-```yaml
63-image: alpine:3.20
64-secrets: [DEPLOY_TOKEN]
65-steps:
66- - run: curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy
62+```toml
63+image = "alpine:3.20"
64+secrets = ["DEPLOY_TOKEN"]
65+
66+[[steps]]
67+run = 'curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy'
6768 ```
6869
6970 anvil cannot open `DEPLOY_TOKEN` on its own, so the run fails immediately —
⋯ 78 unchanged lines
modifieddocs/untrusted-mode.md+1 −1
⋯ 14 unchanged lines
1515
1616 ## 1. CI: arbitrary code execution by design
1717
18-Anyone who can push to a repo with a `.anvil/ci.yml` runs arbitrary code on
18+Anyone who can push to a repo with a `.anvil/ci.toml` runs arbitrary code on
1919 your hardware. That is the *point* of CI, so the question is only how well the
2020 blast radius is contained.
2121
⋯ 152 unchanged lines