collin/anvil · f89a7ca7
Move the Rust runner to Rust 1.98.0 on ubuntu:26.04
Collin Richards · 2026-08-25 03:50 UTC · f89a7ca72a69100af1df2d4134c5ec6eaa9427b5 · parent c332d412 · browse files
modifiedanvil.example.toml+1 −1
| ⋯ 84 unchanged lines | |||
| 85 | 85 | # on whatever the claiming runner is native to. | |
| 86 | 86 | # | |
| 87 | 87 | # Set it to what you DEPLOY on when your runners are a different architecture: | |
| 88 | - | # an M-series Mac resolves `rust:1.95` to arm64 and will happily green-light | |
| 88 | + | # an M-series Mac resolves a multi-arch image to arm64 and will happily green-light | |
| 89 | 89 | # code you ship as amd64. anvil routes a job to a runner that is natively its | |
| 90 | 90 | # platform when one is connected, and falls back to an emulating runner (Rosetta | |
| 91 | 91 | # on macOS -- turn it on in Docker Desktop) when none is, rather than leaving the | |
| ⋯ 48 unchanged lines | |||
modifiedcrates/anvil-core/src/ci.rs+8 −7
| ⋯ 29 unchanged lines | |||
| 30 | 30 | /// artifacts to collect afterwards. | |
| 31 | 31 | #[derive(Clone, Debug, Deserialize)] | |
| 32 | 32 | pub struct Pipeline { | |
| 33 | - | /// Docker image the steps run in, e.g. `rust:1.95-bookworm`. Optional: | |
| 33 | + | /// Docker image the steps run in, e.g. `anvil-runner:rust`. Optional: | |
| 34 | 34 | /// omitting it selects `ci.default_image`, the shared anvil runner that | |
| 35 | 35 | /// agent sessions also use. Resolve it with | |
| 36 | 36 | /// [`CiConfig::resolve_image`](crate::config::CiConfig::resolve_image) | |
| ⋯ 397 unchanged lines | |||
| 434 | 434 | fn parses_a_basic_pipeline() { | |
| 435 | 435 | // YAML is indentation-sensitive, so the fixture is flush-left. | |
| 436 | 436 | let p = parse_pipeline( | |
| 437 | - | r#"image: rust:1.95-bookworm | |
| 437 | + | r#"image: anvil-runner:rust | |
| 438 | 438 | steps: | |
| 439 | 439 | - name: test | |
| 440 | 440 | run: cargo test --workspace | |
| ⋯ 1 unchanged line | |||
| 442 | 442 | "#, | |
| 443 | 443 | ) | |
| 444 | 444 | .unwrap(); | |
| 445 | - | assert_eq!(p.image, "rust:1.95-bookworm"); | |
| 445 | + | assert_eq!(p.image, "anvil-runner:rust"); | |
| 446 | 446 | assert_eq!(p.steps.len(), 2); | |
| 447 | 447 | assert_eq!(p.steps[0].label(), "test"); | |
| 448 | 448 | // Unnamed step falls back to its command for the label. | |
| ⋯ 10 unchanged lines | |||
| 459 | 459 | /// amd64, which fails much later and much less clearly. | |
| 460 | 460 | #[test] | |
| 461 | 461 | fn parses_and_validates_the_platform() { | |
| 462 | - | let p = parse_pipeline("image: rust:1.95\nplatform: linux/amd64\nsteps: []\n").unwrap(); | |
| 462 | + | let p = | |
| 463 | + | parse_pipeline("image: anvil-runner:rust\nplatform: linux/amd64\nsteps: []\n").unwrap(); | |
| 463 | 464 | assert_eq!(p.platform, "linux/amd64"); | |
| 464 | 465 | assert!( | |
| 465 | - | parse_pipeline("image: rust:1.95\nsteps: []\n") | |
| 466 | + | parse_pipeline("image: anvil-runner:rust\nsteps: []\n") | |
| 466 | 467 | .unwrap() | |
| 467 | 468 | .platform | |
| 468 | 469 | .is_empty() | |
| ⋯ 5 unchanged lines | |||
| 474 | 475 | assert!(!valid_platform("linux/")); | |
| 475 | 476 | assert!(!valid_platform("linux/amd64/v1/extra")); | |
| 476 | 477 | assert!(!valid_platform("Linux/AMD64")); | |
| 477 | - | assert!(parse_pipeline("image: rust:1.95\nplatform: amd64\nsteps: []\n").is_err()); | |
| 478 | + | assert!(parse_pipeline("image: anvil-runner:rust\nplatform: amd64\nsteps: []\n").is_err()); | |
| 478 | 479 | } | |
| 479 | 480 | ||
| 480 | 481 | #[test] | |
| 481 | 482 | fn parses_and_validates_artifacts() { | |
| 482 | 483 | let p = parse_pipeline( | |
| 483 | - | r#"image: rust:1.95 | |
| 484 | + | r#"image: anvil-runner:rust | |
| 484 | 485 | artifacts: | |
| 485 | 486 | - name: anvild | |
| 486 | 487 | path: target/release/anvild | |
| ⋯ 123 unchanged lines | |||
modifiedcrates/anvil-core/src/config.rs+6 −6
| ⋯ 160 unchanged lines | |||
| 161 | 161 | pub deploy_branch: String, | |
| 162 | 162 | /// Images a pipeline may run in. Empty allows any image. An entry without a | |
| 163 | 163 | /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag | |
| 164 | - | /// (e.g. `rust:1.95-bookworm`) allows exactly that image. | |
| 164 | + | /// (e.g. `anvil-runner:rust`) allows exactly that image. | |
| 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 | |
| ⋯ 10 unchanged lines | |||
| 178 | 178 | /// behaviour of an instance that never sets this. | |
| 179 | 179 | /// | |
| 180 | 180 | /// Worth setting to the architecture you *deploy* on, on an instance whose | |
| 181 | - | /// runners are a different one: an M-series Mac resolves `rust:1.95` to | |
| 182 | - | /// arm64 and will happily test an architecture you never ship. See | |
| 181 | + | /// runners are a different one: an M-series Mac resolves a multi-arch image | |
| 182 | + | /// to arm64 and will happily test an architecture you never ship. See | |
| 183 | 183 | /// `docs/remote-runners.md`. | |
| 184 | 184 | pub platform: String, | |
| 185 | 185 | /// Memory cap for a job container, in MiB (swap is capped to the same | |
| ⋯ 429 unchanged lines | |||
| 615 | 615 | fn an_omitted_image_resolves_to_the_shared_runner() { | |
| 616 | 616 | let ci = CiConfig::default(); | |
| 617 | 617 | assert_eq!(ci.resolve_image(""), DEFAULT_RUNNER_IMAGE); | |
| 618 | - | assert_eq!(ci.resolve_image("rust:1.95-bookworm"), "rust:1.95-bookworm"); | |
| 618 | + | assert_eq!(ci.resolve_image("anvil-runner:rust"), "anvil-runner:rust"); | |
| 619 | 619 | } | |
| 620 | 620 | ||
| 621 | 621 | /// An allowlist must not lock out the default image: a pipeline that simply | |
| ⋯ 7 unchanged lines | |||
| 629 | 629 | }; | |
| 630 | 630 | assert!(ci.image_allowed(DEFAULT_RUNNER_IMAGE)); | |
| 631 | 631 | assert!(ci.image_allowed("alpine:3.20")); | |
| 632 | - | assert!(!ci.image_allowed("rust:1.95-bookworm")); | |
| 632 | + | assert!(!ci.image_allowed("anvil-runner:rust")); | |
| 633 | 633 | } | |
| 634 | 634 | ||
| 635 | 635 | /// Platform resolution has three levels, and the bottom one is "whatever | |
| ⋯ 85 unchanged lines | |||
| 721 | 721 | ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()]; | |
| 722 | 722 | assert!(ci.image_allowed("rust"), "tagless entry, tagless image"); | |
| 723 | 723 | assert!( | |
| 724 | - | ci.image_allowed("rust:1.95-bookworm"), | |
| 724 | + | ci.image_allowed("rust:1.98"), | |
| 725 | 725 | "tagless entry allows any tag" | |
| 726 | 726 | ); | |
| 727 | 727 | assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match"); | |
| ⋯ 9 unchanged lines | |||
modifieddeploy/runner/Dockerfile+33 −10
| ⋯ 4 unchanged lines | |||
| 5 | 5 | # agent sessions always do (`agent.image`). Keeping them the same image means a | |
| 6 | 6 | # session can reproduce a build by hand, and there is one thing to keep current. | |
| 7 | 7 | # | |
| 8 | - | # Parameterized by base so the same recipe produces a small general runner and | |
| 9 | - | # a toolchain-carrying one: | |
| 8 | + | # Parameterized so the same recipe produces a small general runner and a | |
| 9 | + | # toolchain-carrying one: | |
| 10 | 10 | # | |
| 11 | - | # anvil-runner:latest BASE=ubuntu:26.04 (the default) | |
| 12 | - | # anvil-runner:rust BASE=rust:1.95-bookworm (builds anvil itself; the | |
| 13 | - | # official Rust image has no | |
| 14 | - | # Ubuntu variant, and the | |
| 15 | - | # tmux/locale fixes below | |
| 16 | - | # only matter for the | |
| 17 | - | # interactive sessions this | |
| 18 | - | # tag never runs) | |
| 11 | + | # anvil-runner:latest RUST_VERSION= (the default) | |
| 12 | + | # anvil-runner:rust RUST_VERSION=1.98.0 (builds anvil itself) | |
| 13 | + | # | |
| 14 | + | # Both sit on ubuntu:26.04. The official Rust image is Debian-based and has no | |
| 15 | + | # Ubuntu variant, so rather than let one tag drift onto a different distro the | |
| 16 | + | # toolchain is installed here with rustup; `RUST_VERSION` empty means the slim | |
| 17 | + | # tag and skips that layer entirely. | |
| 19 | 18 | # | |
| 20 | 19 | # Build both with deploy/runner/build.sh. There is NO registry behind this | |
| 21 | 20 | # image — it lives only in the host's local image store, which is why anvil | |
| ⋯ 57 unchanged lines | |||
| 79 | 78 | && apt-get install -y --no-install-recommends claude-code \ | |
| 80 | 79 | && rm -rf /var/lib/apt/lists/* | |
| 81 | 80 | ||
| 81 | + | # The Rust toolchain, for the `rust` tag only. Installed rather than inherited | |
| 82 | + | # from `rust:<ver>-bookworm` so both tags share one base — Debian bookworm's | |
| 83 | + | # tmux is the reason the slim tag left it (above), and one distro means a | |
| 84 | + | # session can reproduce a CI build without accounting for the difference. | |
| 85 | + | # Empty `RUST_VERSION` is the slim tag: no rustup, no compiler, no extra layer. | |
| 86 | + | # The three ENVs are set unconditionally (Dockerfile has no conditional ENV); | |
| 87 | + | # on the slim tag they just name directories that do not exist. | |
| 88 | + | ARG RUST_VERSION= | |
| 89 | + | ENV RUSTUP_HOME=/usr/local/rustup | |
| 90 | + | ENV CARGO_HOME=/usr/local/cargo | |
| 91 | + | ENV PATH=/usr/local/cargo/bin:$PATH | |
| 92 | + | RUN if [ -n "${RUST_VERSION}" ]; then \ | |
| 93 | + | apt-get update \ | |
| 94 | + | && apt-get install -y --no-install-recommends \ | |
| 95 | + | build-essential \ | |
| 96 | + | pkg-config \ | |
| 97 | + | && rm -rf /var/lib/apt/lists/* \ | |
| 98 | + | && curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ | |
| 99 | + | | sh -s -- -y --no-modify-path --profile minimal \ | |
| 100 | + | --default-toolchain "${RUST_VERSION}" \ | |
| 101 | + | && chmod -R a+w "${RUSTUP_HOME}" "${CARGO_HOME}" \ | |
| 102 | + | && rustc --version && cargo --version; \ | |
| 103 | + | fi | |
| 104 | + | ||
| 82 | 105 | # apt installs never auto-update, but Claude Code still checks on startup and | |
| 83 | 106 | # the check is pure noise in a container whose version is pinned by the image. | |
| 84 | 107 | ENV DISABLE_AUTOUPDATER=1 | |
| ⋯ 47 unchanged lines | |||
modifieddeploy/runner/build.sh+14 −9
| ⋯ 1 unchanged line | |||
| 2 | 2 | # Build the shared anvil runner image(s) — what BOTH CI jobs and agent sessions | |
| 3 | 3 | # execute in. | |
| 4 | 4 | # | |
| 5 | - | # Two tags from one Dockerfile, differing only in base: | |
| 5 | + | # Two tags from one Dockerfile on one base, differing only in whether the Rust | |
| 6 | + | # toolchain is installed: | |
| 6 | 7 | # | |
| 7 | 8 | # anvil-runner:latest ubuntu:26.04 general purpose, the default | |
| 8 | - | # anvil-runner:rust rust:1.95-bookworm carries the Rust toolchain | |
| 9 | + | # anvil-runner:rust ubuntu:26.04 + rustup carries the Rust toolchain | |
| 9 | 10 | # | |
| 10 | 11 | # There is deliberately no registry push: anvil resolves its default image from | |
| 11 | 12 | # the LOCAL image store and treats a failed pull as non-fatal when the image is | |
| ⋯ 10 unchanged lines | |||
| 22 | 23 | NAME="${ANVIL_RUNNER_IMAGE:-anvil-runner}" | |
| 23 | 24 | CHANNEL="${CLAUDE_CHANNEL:-stable}" | |
| 24 | 25 | ||
| 26 | + | # Toolchain baked into the `rust` tag. Keep in step with the workspace | |
| 27 | + | # `rust-version` in Cargo.toml. | |
| 28 | + | RUST_VERSION="${RUST_VERSION:-1.98.0}" | |
| 29 | + | ||
| 25 | 30 | build() { | |
| 26 | - | local tag="$1" base="$2" | |
| 27 | - | echo "==> building ${NAME}:${tag} (base ${base}, claude channel ${CHANNEL})" | |
| 31 | + | local tag="$1" rust="$2" | |
| 32 | + | echo "==> building ${NAME}:${tag} (rust ${rust:-none}, claude channel ${CHANNEL})" | |
| 28 | 33 | docker build \ | |
| 29 | - | --build-arg "BASE=${base}" \ | |
| 34 | + | --build-arg "RUST_VERSION=${rust}" \ | |
| 30 | 35 | --build-arg "CLAUDE_CHANNEL=${CHANNEL}" \ | |
| 31 | 36 | -t "${NAME}:${tag}" \ | |
| 32 | 37 | . | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | case "${1:-all}" in | |
| 36 | - | latest) build latest ubuntu:26.04 ;; | |
| 37 | - | rust) build rust rust:1.95-bookworm ;; | |
| 41 | + | latest) build latest "" ;; | |
| 42 | + | rust) build rust "${RUST_VERSION}" ;; | |
| 38 | 43 | all) | |
| 39 | - | build latest ubuntu:26.04 | |
| 40 | - | build rust rust:1.95-bookworm | |
| 44 | + | build latest "" | |
| 45 | + | build rust "${RUST_VERSION}" | |
| 41 | 46 | ;; | |
| 42 | 47 | *) | |
| 43 | 48 | echo "usage: $0 [latest|rust|all]" >&2 | |
| ⋯ 13 unchanged lines | |||
modifieddocs/agent-sessions.md+6 −5
| ⋯ 47 unchanged lines | |||
| 48 | 48 | ./deploy/runner/build.sh latest # just the slim one | |
| 49 | 49 | ``` | |
| 50 | 50 | ||
| 51 | - | Two tags from one recipe, differing only in base: | |
| 51 | + | Two tags from one recipe on one base, differing only in whether the Rust | |
| 52 | + | toolchain is installed: | |
| 52 | 53 | ||
| 53 | - | | Tag | Base | For | | |
| 54 | - | | -------------------- | --------------------- | -------------------------- | | |
| 55 | - | | `anvil-runner:latest`| `ubuntu:26.04` | the default, general work | | |
| 56 | - | | `anvil-runner:rust` | `rust:1.95-bookworm` | pipelines needing the toolchain | | |
| 54 | + | | Tag | Base | For | | |
| 55 | + | | -------------------- | ------------------------ | ------------------------------- | | |
| 56 | + | | `anvil-runner:latest`| `ubuntu:26.04` | the default, general work | | |
| 57 | + | | `anvil-runner:rust` | `ubuntu:26.04` + rustup 1.98.0 | pipelines needing the toolchain | | |
| 57 | 58 | ||
| 58 | 59 | **There is no registry behind this image.** It is built straight into the | |
| 59 | 60 | Docker daemon's local store, so it must be built on whichever host owns the | |
| ⋯ 90 unchanged lines | |||
modifieddocs/ci-artifacts.md+1 −1
| ⋯ 42 unchanged lines | |||
| 43 | 43 | ## Declaring artifacts in `.anvil/ci.yml` | |
| 44 | 44 | ||
| 45 | 45 | ```yaml | |
| 46 | - | image: rust:1.95-bookworm | |
| 46 | + | image: anvil-runner:rust | |
| 47 | 47 | steps: | |
| 48 | 48 | - name: build | |
| 49 | 49 | run: cargo build --release | |
| ⋯ 112 unchanged lines | |||
modifieddocs/remote-runners.md+2 −2
| ⋯ 116 unchanged lines | |||
| 117 | 117 | ```json | |
| 118 | 118 | { | |
| 119 | 119 | "run_id": 42, | |
| 120 | - | "image": "rust:1.95-bookworm", | |
| 120 | + | "image": "anvil-runner:rust", | |
| 121 | 121 | "platform": "linux/amd64", | |
| 122 | 122 | "script": "set -e\n...", | |
| 123 | 123 | "secrets": [{"name": "CARGO_TOKEN", "value": "..."}], | |
| ⋯ 82 unchanged lines | |||
| 206 | 206 | `gcr.io/distroless/static:nonroot` would make that build pure `COPY` and | |
| 207 | 207 | genuinely emulation-free. Nice-to-have. | |
| 208 | 208 | ||
| 209 | - | **What jobs run on.** On an M2, `rust:1.95-bookworm` resolves to arm64, so | |
| 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 | 212 | A job's platform comes from `platform:` in `.anvil/ci.yml`, falling back to | |
| ⋯ 241 unchanged lines | |||