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 | |||
| 79 | 79 | `anvil-worker` (the runner binary), five endpoints under `/-/runner/`, | |
| 80 | 80 | in-memory leases, and no Docker socket on the deployed container. | |
| 81 | 81 | ||
| 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 | |
| 83 | 83 | two-tier routing — a claiming runner is offered the runs that match its | |
| 84 | 84 | architecture (or name none), then the runs no *connected* runner is native to, | |
| 85 | 85 | so a lone arm64 Mac still runs `linux/amd64` pipelines under Rosetta instead of | |
| ⋯ 4 unchanged lines | |||
| 90 | 90 | **Invariants to not break.** These are the reasons the split is shaped the way | |
| 91 | 91 | it is, and each is easy to undo by accident: | |
| 92 | 92 | ||
| 93 | - | - The runner never parses `.anvil/ci.yml`. Image resolution, the allowlist | |
| 93 | + | - The runner never parses `.anvil/ci.toml`. Image resolution, the allowlist | |
| 94 | 94 | check and script assembly happen in `build_job`, so a runner cannot widen | |
| 95 | 95 | what it is permitted to run. | |
| 96 | 96 | - `store_artifact` stays server-side. The runner uploads a raw tar; `browse` | |
| ⋯ 47 unchanged lines | |||
modifiedCargo.toml+6 −1
| ⋯ 91 unchanged lines | |||
| 92 | 92 | # paying for `signal-hook-registry` and `errno` and a chunk of tokio's own | |
| 93 | 93 | # compile for nothing. Add a feature back the moment something needs it. | |
| 94 | 94 | ||
| 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 | + | ||
| 95 | 101 | aes-gcm = { version = "0.11.1" } | |
| 96 | 102 | anyhow = { version = "1.0.104" } | |
| 97 | 103 | argon2 = { version = "0.6.0-rc.8", features = ["rand_core"] } | |
| ⋯ 22 unchanged lines | |||
| 120 | 126 | rustls = { version = "0.23.43", default-features = false, features = ["ring", "logging", "std", "tls12"] } | |
| 121 | 127 | serde = { version = "1.0.229", features = ["derive"] } | |
| 122 | 128 | serde_json = { version = "1.0.151" } | |
| 123 | - | serde_yaml = { version = "0.9.34" } | |
| 124 | 129 | sha2 = { version = "0.11.0" } | |
| 125 | 130 | similar = { version = "3.2.0" } | |
| 126 | 131 | ssh-key = { version = "0.7.0-rc.11" } | |
| ⋯ 11 unchanged lines | |||
modifiedDEPLOY.md+10 −7
| ⋯ 203 unchanged lines | |||
| 204 | 204 | ||
| 205 | 205 | ## 8. The redeploy webhook (CD) | |
| 206 | 206 | ||
| 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 | |
| 208 | 208 | plus steps: | |
| 209 | 209 | ||
| 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" | |
| 216 | 219 | ``` | |
| 217 | 220 | ||
| 218 | 221 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | # Backlog | |
| 8 | 8 | ||
| 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. | |
| 9 | 15 | ||
| 10 | 16 | - [ ] agent sessions, next milestones (docs/agent-sessions.md): | |
| 11 | 17 | - 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 | |||
| 5 | 5 | license.workspace = true | |
| 6 | 6 | repository.workspace = true | |
| 7 | 7 | 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." | |
| 9 | 9 | ||
| 10 | 10 | [lints] | |
| 11 | 11 | workspace = true | |
| ⋯ 14 unchanged lines | |||
modifiedcrates/anvil-ci/src/lib.rs+33 −19
| ⋯ 6 unchanged lines | |||
| 7 | 7 | //! `docs/remote-runners.md`. | |
| 8 | 8 | //! | |
| 9 | 9 | //! 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 | |
| 11 | 11 | //! the image allowlist, opening the secret vault, assembling the shell script, | |
| 12 | 12 | //! and deciding where artifacts land on disk. A runner receives an image, a | |
| 13 | 13 | //! script, a tar and some limits ([`anvil_job::JobSpec`]) and can neither widen | |
| ⋯ 35 unchanged lines | |||
| 49 | 49 | /// | |
| 50 | 50 | /// The server's half of the split (see `docs/remote-runners.md`): image | |
| 51 | 51 | /// 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 | |
| 53 | 53 | /// permitted to run by reinterpreting one. | |
| 54 | 54 | fn build_job( | |
| 55 | 55 | run_id: i64, | |
| ⋯ 2 unchanged lines | |||
| 58 | 58 | env: &[(String, String)], | |
| 59 | 59 | ) -> Result<JobSpec, String> { | |
| 60 | 60 | // What the pipeline asked for, or the shared runner image when it omitted | |
| 61 | - | // `image:` entirely. | |
| 61 | + | // `image` entirely. | |
| 62 | 62 | let image = cfg.resolve_image(&pipeline.image); | |
| 63 | 63 | if !cfg.image_allowed(image) { | |
| 64 | 64 | return Err(format!( | |
| 65 | 65 | "image {image} is not permitted by ci.allowed_images" | |
| 66 | 66 | )); | |
| 67 | 67 | } | |
| 68 | - | // `platform:`, else `[ci] platform`, else the runner's native one. The | |
| 68 | + | // `platform`, else `[ci] platform`, else the runner's native one. The | |
| 69 | 69 | // pipeline's own value was validated at parse time; this catches a | |
| 70 | 70 | // malformed `[ci] platform`, which nothing else would. | |
| 71 | 71 | let platform = cfg.resolve_platform(&pipeline.platform); | |
| ⋯ 225 unchanged lines | |||
| 297 | 297 | .collect() | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 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 | + | ||
| 300 | 326 | /// The platform a queued run needs, for the routing decision — `None` when it | |
| 301 | 327 | /// names none and can run anywhere. | |
| 302 | 328 | /// | |
| ⋯ 1 unchanged line | |||
| 304 | 330 | /// [`prepare`] does that for the run that is actually taken. | |
| 305 | 331 | async fn target_platform(app: &App, run_id: i64) -> Result<Option<String>, String> { | |
| 306 | 332 | 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)?; | |
| 312 | 334 | Ok(app | |
| 313 | 335 | .config | |
| 314 | 336 | .ci | |
| ⋯ 25 unchanged lines | |||
| 340 | 362 | .ok_or("owner not found")?; | |
| 341 | 363 | let repo_path = storage::repo_path(&app.config.repositories_dir(), &owner.username, &repo.name); | |
| 342 | 364 | ||
| 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)?; | |
| 348 | 366 | ||
| 349 | 367 | let short = &run.commit[..run.commit.len().min(12)]; | |
| 350 | 368 | let mut log = format!( | |
| ⋯ 84 unchanged lines | |||
| 435 | 453 | name: &str, | |
| 436 | 454 | ) -> Result<Option<ArtifactSpec>, String> { | |
| 437 | 455 | 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)?; | |
| 443 | 457 | Ok(pipeline | |
| 444 | 458 | .artifacts | |
| 445 | 459 | .iter() | |
| ⋯ 491 unchanged lines | |||
modifiedcrates/anvil-core/Cargo.toml+0 −1
| ⋯ 25 unchanged lines | |||
| 26 | 26 | ssh-key.workspace = true | |
| 27 | 27 | serde.workspace = true | |
| 28 | 28 | serde_json.workspace = true | |
| 29 | - | serde_yaml.workspace = true | |
| 30 | 29 | toml.workspace = true | |
| 31 | 30 | thiserror.workspace = true | |
| 32 | 31 | tracing.workspace = true | |
| ⋯ 12 unchanged lines | |||
modifiedcrates/anvil-core/src/ci.rs+120 −41
| 1 | 1 | //! Continuous integration: the pipeline definition (`.anvil/ci.toml`) and the | |
| 2 | 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. | |
| 3 | 7 | ||
| 4 | 8 | use serde::Deserialize; | |
| 5 | 9 | ||
| ⋯ 18 unchanged lines | |||
| 24 | 28 | } | |
| 25 | 29 | ||
| 26 | 30 | /// 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"; | |
| 28 | 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. | |
| 39 | + | pub const LEGACY_PIPELINE_PATH: &str = ".anvil/ci.yml"; | |
| 40 | + | ||
| 29 | 41 | /// A parsed pipeline: a base image, ordered straight-line steps, and the | |
| 30 | 42 | /// artifacts to collect afterwards. | |
| 31 | 43 | #[derive(Clone, Debug, Deserialize)] | |
| ⋯ 86 unchanged lines | |||
| 118 | 130 | }) | |
| 119 | 131 | } | |
| 120 | 132 | ||
| 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}")))?; | |
| 125 | 137 | if pipeline.image.trim().is_empty() { | |
| 126 | 138 | return Err(Error::Invalid(format!( | |
| 127 | 139 | "{PIPELINE_PATH}: `image` is required" | |
| ⋯ 304 unchanged lines | |||
| 432 | 444 | ||
| 433 | 445 | #[test] | |
| 434 | 446 | fn parses_a_basic_pipeline() { | |
| 435 | - | // YAML is indentation-sensitive, so the fixture is flush-left. | |
| 436 | 447 | 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 | + | "#, | |
| 443 | 458 | ) | |
| 444 | 459 | .unwrap(); | |
| 445 | 460 | assert_eq!(p.image, "anvil-runner:rust"); | |
| ⋯ 5 unchanged lines | |||
| 451 | 466 | ||
| 452 | 467 | #[test] | |
| 453 | 468 | 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}"); | |
| 455 | 490 | } | |
| 456 | 491 | ||
| 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 | |
| 458 | 493 | /// `amd64` would otherwise reach Docker as an *operating system* named | |
| 459 | 494 | /// amd64, which fails much later and much less clearly. | |
| 460 | 495 | #[test] | |
| 461 | 496 | 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(); | |
| 464 | 503 | assert_eq!(p.platform, "linux/amd64"); | |
| 465 | 504 | assert!( | |
| 466 | - | parse_pipeline("image: anvil-runner:rust\nsteps: []\n") | |
| 505 | + | parse_pipeline("image = \"anvil-runner:rust\"\nsteps = []\n") | |
| 467 | 506 | .unwrap() | |
| 468 | 507 | .platform | |
| 469 | 508 | .is_empty() | |
| ⋯ 5 unchanged lines | |||
| 475 | 514 | assert!(!valid_platform("linux/")); | |
| 476 | 515 | assert!(!valid_platform("linux/amd64/v1/extra")); | |
| 477 | 516 | 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 | + | ); | |
| 479 | 525 | } | |
| 480 | 526 | ||
| 481 | 527 | #[test] | |
| 482 | 528 | fn parses_and_validates_artifacts() { | |
| 483 | 529 | 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 | + | "#, | |
| 494 | 543 | ) | |
| 495 | 544 | .unwrap(); | |
| 496 | 545 | assert_eq!(p.artifacts.len(), 2); | |
| ⋯ 6 unchanged lines | |||
| 503 | 552 | assert_eq!(p.artifacts[1].path, "target/doc", "trailing slash trimmed"); | |
| 504 | 553 | assert!(p.artifacts[1].browse); | |
| 505 | 554 | ||
| 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| { | |
| 507 | 558 | assert!( | |
| 508 | - | parse_pipeline(&format!("image: i\n{yaml}")).is_err(), | |
| 559 | + | parse_pipeline(&format!("image = \"i\"\nartifacts = {artifacts}\n")).is_err(), | |
| 509 | 560 | "{why}" | |
| 510 | 561 | ); | |
| 511 | 562 | }; | |
| 512 | 563 | must_fail( | |
| 513 | - | "artifacts: [{name: 'a b', path: x}]", | |
| 564 | + | r#"[{ name = "a b", path = "x" }]"#, | |
| 514 | 565 | "space in name rejected", | |
| 515 | 566 | ); | |
| 516 | 567 | must_fail( | |
| 517 | - | "artifacts: [{name: 'a/b', path: x}]", | |
| 568 | + | r#"[{ name = "a/b", path = "x" }]"#, | |
| 518 | 569 | "slash in name rejected", | |
| 519 | 570 | ); | |
| 520 | - | must_fail("artifacts: [{name: '', path: x}]", "empty name rejected"); | |
| 571 | + | must_fail(r#"[{ name = "", path = "x" }]"#, "empty name rejected"); | |
| 521 | 572 | must_fail( | |
| 522 | - | "artifacts: [{name: a, path: x}, {name: a, path: y}]", | |
| 573 | + | r#"[{ name = "a", path = "x" }, { name = "a", path = "y" }]"#, | |
| 523 | 574 | "duplicate name rejected", | |
| 524 | 575 | ); | |
| 525 | 576 | must_fail( | |
| 526 | - | "artifacts: [{name: a, path: /etc}]", | |
| 577 | + | r#"[{ name = "a", path = "/etc" }]"#, | |
| 527 | 578 | "absolute path rejected", | |
| 528 | 579 | ); | |
| 580 | + | must_fail(r#"[{ name = "a", path = "../up" }]"#, "traversal rejected"); | |
| 529 | 581 | 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" }]"#, | |
| 535 | 583 | "empty segment rejected", | |
| 536 | 584 | ); | |
| 537 | - | must_fail("artifacts: [{name: a, path: ''}]", "empty path rejected"); | |
| 585 | + | must_fail(r#"[{ name = "a", path = "" }]"#, "empty path rejected"); | |
| 538 | 586 | } | |
| 539 | 587 | ||
| 540 | 588 | // Exercises the run-lifecycle queries against a real SQLite database, to | |
| ⋯ 66 unchanged lines | |||
| 607 | 655 | delete_artifacts_for_commit(&db, 7, "abc").await.unwrap(); | |
| 608 | 656 | assert!(artifacts_for_repo(&db, 7).await.unwrap().is_empty()); | |
| 609 | 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 | + | } | |
| 610 | 689 | } | |
modifiedcrates/anvil-core/src/config.rs+5 −5
| ⋯ 164 unchanged lines | |||
| 165 | 165 | /// | |
| 166 | 166 | /// [`default_image`](CiConfig::default_image) is always permitted, whatever | |
| 167 | 167 | /// this says — otherwise an allowlist would break every pipeline that | |
| 168 | - | /// simply omits `image:`. | |
| 168 | + | /// simply omits `image`. | |
| 169 | 169 | 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 | |
| 171 | 171 | /// runner (`docker/runner/Dockerfile`) — the same image agent sessions run | |
| 172 | 172 | /// in, carrying tmux, git, fish and Claude Code. Built locally rather than | |
| 173 | 173 | /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller | |
| 174 | 174 | /// must tolerate a failed pull. | |
| 175 | 175 | 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 | |
| 177 | 177 | /// every job on whatever the claiming runner is native to, which is the | |
| 178 | 178 | /// behaviour of an instance that never sets this. | |
| 179 | 179 | /// | |
| ⋯ 203 unchanged lines | |||
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | 385 | /// 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`. | |
| 387 | 387 | pub fn resolve_image<'a>(&'a self, requested: &'a str) -> &'a str { | |
| 388 | 388 | if requested.is_empty() { | |
| 389 | 389 | &self.default_image | |
| ⋯ 229 unchanged lines | |||
| 619 | 619 | } | |
| 620 | 620 | ||
| 621 | 621 | /// 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 | |
| 623 | 623 | /// failing those runs is the regression this whole path risks. | |
| 624 | 624 | #[test] | |
| 625 | 625 | fn the_default_image_is_allowed_even_under_an_allowlist() { | |
| ⋯ 111 unchanged lines | |||
modifiedcrates/anvil-core/src/secrets.rs+1 −1
| ⋯ 860 unchanged lines | |||
| 861 | 861 | /// available as the error, so the runner can say exactly what is missing. | |
| 862 | 862 | /// | |
| 863 | 863 | /// 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 | |
| 865 | 865 | /// failing it here would mean no repository could run CI until someone had | |
| 866 | 866 | /// unlocked it for secrets it does not use. | |
| 867 | 867 | pub fn take( | |
| ⋯ 228 unchanged lines | |||
modifiedcrates/anvil-git/src/trigger.rs+12 −6
| 1 | 1 | //! 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`). | |
| 3 | 3 | //! | |
| 4 | 4 | //! Used by the receive-pack paths in `anvil-web` and `anvil-ssh`: snapshot the | |
| 5 | 5 | //! branch tips *before* the push, then call [`enqueue_ci_for_push`] after it | |
| ⋯ 43 unchanged lines | |||
| 49 | 49 | if before.get(branch).map(String::as_str) == Some(new_tip.as_str()) { | |
| 50 | 50 | continue; // unchanged branch | |
| 51 | 51 | } | |
| 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 | + | { | |
| 57 | 63 | continue; | |
| 58 | 64 | } | |
| 59 | 65 | 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 | |||
| 5 | 5 | //! link `anvil-core` (and therefore toasty, SQLite, gix and the rest of the | |
| 6 | 6 | //! forge) just to learn the shape of a job. | |
| 7 | 7 | //! | |
| 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`, | |
| 9 | 9 | //! checks the image allowlist, opens the secret vault and assembles the shell | |
| 10 | 10 | //! script. A runner receives an image, a script, a tar and some limits, and | |
| 11 | 11 | //! 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 | |||
| 2553 | 2553 | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" } | |
| 2554 | 2554 | @if runs.is_empty() { | |
| 2555 | 2555 | p.muted { | |
| 2556 | - | "No CI runs yet. Add a " code { ".anvil/ci.yml" } | |
| 2556 | + | "No CI runs yet. Add a " code { ".anvil/ci.toml" } | |
| 2557 | 2557 | " pipeline and push to trigger one." | |
| 2558 | 2558 | } | |
| 2559 | 2559 | } @else { | |
| ⋯ 440 unchanged lines | |||
modifieddocs/agent-sessions.md+2 −2
| ⋯ 67 unchanged lines | |||
| 68 | 68 | a failed pull as non-fatal when the image is already present locally — an | |
| 69 | 69 | unconditional pull, which is what CI used to do, fails for exactly this image. | |
| 70 | 70 | ||
| 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 | |
| 72 | 72 | simply optional, and omitting it selects `ci.default_image`. The default image | |
| 73 | 73 | 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. | |
| 75 | 75 | ||
| 76 | 76 | ## Lifecycle | |
| 77 | 77 | ||
| ⋯ 79 unchanged lines | |||
modifieddocs/ci-artifacts.md+31 −22
| ⋯ 39 unchanged lines | |||
| 40 | 40 | stream is aborted, and the artifact skipped with a logged note, when a cap | |
| 41 | 41 | is exceeded — never the run failed retroactively. | |
| 42 | 42 | ||
| 43 | - | ## Declaring artifacts in `.anvil/ci.yml` | |
| 43 | + | ## Declaring artifacts in `.anvil/ci.toml` | |
| 44 | 44 | ||
| 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" | |
| 52 | 47 | ||
| 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 | |
| 66 | 71 | ``` | |
| 67 | 72 | ||
| 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 | + | ||
| 68 | 77 | `meta` is a map of key → shell command. The commands run **inside the job | |
| 69 | 78 | container** (appended to the script after the steps, still `set -e`-free — | |
| 70 | 79 | each is best-effort), because artifact content is untrusted and must never be | |
| ⋯ 80 unchanged lines | |||
| 151 | 160 | ||
| 152 | 161 | ## Implementation order | |
| 153 | 162 | ||
| 154 | - | 1. Schema + config: `CiArtifact` model, `[ci]` caps, parse `artifacts:` in | |
| 163 | + | 1. Schema + config: `CiArtifact` model, `[ci]` caps, parse `artifacts` in | |
| 155 | 164 | `ci.rs` (with path/name validation + tests). | |
| 156 | 165 | 2. Broker: collect declared paths via `download_from_container`, store to | |
| 157 | 166 | disk, write rows; meta-extractor trailer + `.anvil-meta.json` pickup. | |
| ⋯ 4 unchanged lines | |||
modifieddocs/remote-runners.md+4 −4
| ⋯ 77 unchanged lines | |||
| 78 | 78 | `execute` it parks the job until a runner claims it. | |
| 79 | 79 | ||
| 80 | 80 | 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, | |
| 82 | 82 | sandbox caps, and a tar. That keeps the wire format stable as the pipeline | |
| 83 | 83 | schema grows. | |
| 84 | 84 | ||
| ⋯ 124 unchanged lines | |||
| 209 | 209 | **What jobs run on.** On an M2, a multi-arch image resolves to arm64, so | |
| 210 | 210 | `cargo test` tests an architecture you don't ship. That is what M2 fixes. | |
| 211 | 211 | ||
| 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 | |
| 213 | 213 | `[ci] platform`, falling back to the claiming runner's native architecture — | |
| 214 | 214 | so an instance that sets neither behaves exactly as it did before. It reaches | |
| 215 | 215 | `CreateContainerOptions.platform` and `CreateImageOptions.platform`, and the | |
| ⋯ 21 unchanged lines | |||
| 237 | 237 | those jobs the moment the new runner's first claim registers it — no | |
| 238 | 238 | configuration, which is the property the dial-out model was chosen for. | |
| 239 | 239 | ||
| 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 | |
| 241 | 241 | execution. It is a promise about *what the job runs*, which the runner enforces | |
| 242 | 242 | by inspecting the image it ended up with (`anvil-docker::check_platform`) and | |
| 243 | 243 | failing the job if the architecture is not the one asked for. Where it runs is | |
| ⋯ 177 unchanged lines | |||
| 421 | 421 | `connect_with_defaults`, `run_worker` → `run_dispatcher`, and no socket mount in | |
| 422 | 422 | the deployed container. Deploys keep using the existing webhook. | |
| 423 | 423 | ||
| 424 | - | **M2 — platform. Done.** `platform:` in `.anvil/ci.yml`, a `[ci] platform` | |
| 424 | + | **M2 — platform. Done.** `platform` in `.anvil/ci.toml`, a `[ci] platform` | |
| 425 | 425 | default, `CiConfig::resolve_platform`, the two-tier routing above (backed by a | |
| 426 | 426 | runner registry in `Dispatch`), the emulation note in the run header, and an | |
| 427 | 427 | architecture check on the image the runner actually got. | |
| ⋯ 26 unchanged lines | |||
modifieddocs/secrets.md+6 −5
| ⋯ 58 unchanged lines | |||
| 59 | 59 | ||
| 60 | 60 | A pipeline declares what it needs: | |
| 61 | 61 | ||
| 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' | |
| 67 | 68 | ``` | |
| 68 | 69 | ||
| 69 | 70 | 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 | |||
| 15 | 15 | ||
| 16 | 16 | ## 1. CI: arbitrary code execution by design | |
| 17 | 17 | ||
| 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 | |
| 19 | 19 | your hardware. That is the *point* of CI, so the question is only how well the | |
| 20 | 20 | blast radius is contained. | |
| 21 | 21 | ||
| ⋯ 152 unchanged lines | |||