anvilsign in

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
8585 # on whatever the claiming runner is native to.
8686 #
8787 # 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
8989 # code you ship as amd64. anvil routes a job to a runner that is natively its
9090 # platform when one is connected, and falls back to an emulating runner (Rosetta
9191 # 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
3030 /// artifacts to collect afterwards.
3131 #[derive(Clone, Debug, Deserialize)]
3232 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:
3434 /// omitting it selects `ci.default_image`, the shared anvil runner that
3535 /// agent sessions also use. Resolve it with
3636 /// [`CiConfig::resolve_image`](crate::config::CiConfig::resolve_image)
⋯ 397 unchanged lines
434434 fn parses_a_basic_pipeline() {
435435 // YAML is indentation-sensitive, so the fixture is flush-left.
436436 let p = parse_pipeline(
437- r#"image: rust:1.95-bookworm
437+ r#"image: anvil-runner:rust
438438 steps:
439439 - name: test
440440 run: cargo test --workspace
⋯ 1 unchanged line
442442 "#,
443443 )
444444 .unwrap();
445- assert_eq!(p.image, "rust:1.95-bookworm");
445+ assert_eq!(p.image, "anvil-runner:rust");
446446 assert_eq!(p.steps.len(), 2);
447447 assert_eq!(p.steps[0].label(), "test");
448448 // Unnamed step falls back to its command for the label.
⋯ 10 unchanged lines
459459 /// amd64, which fails much later and much less clearly.
460460 #[test]
461461 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();
463464 assert_eq!(p.platform, "linux/amd64");
464465 assert!(
465- parse_pipeline("image: rust:1.95\nsteps: []\n")
466+ parse_pipeline("image: anvil-runner:rust\nsteps: []\n")
466467 .unwrap()
467468 .platform
468469 .is_empty()
⋯ 5 unchanged lines
474475 assert!(!valid_platform("linux/"));
475476 assert!(!valid_platform("linux/amd64/v1/extra"));
476477 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());
478479 }
479480
480481 #[test]
481482 fn parses_and_validates_artifacts() {
482483 let p = parse_pipeline(
483- r#"image: rust:1.95
484+ r#"image: anvil-runner:rust
484485 artifacts:
485486 - name: anvild
486487 path: target/release/anvild
⋯ 123 unchanged lines
modifiedcrates/anvil-core/src/config.rs+6 −6
⋯ 160 unchanged lines
161161 pub deploy_branch: String,
162162 /// Images a pipeline may run in. Empty allows any image. An entry without a
163163 /// 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.
165165 ///
166166 /// [`default_image`](CiConfig::default_image) is always permitted, whatever
167167 /// this says — otherwise an allowlist would break every pipeline that
⋯ 10 unchanged lines
178178 /// behaviour of an instance that never sets this.
179179 ///
180180 /// 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
183183 /// `docs/remote-runners.md`.
184184 pub platform: String,
185185 /// Memory cap for a job container, in MiB (swap is capped to the same
⋯ 429 unchanged lines
615615 fn an_omitted_image_resolves_to_the_shared_runner() {
616616 let ci = CiConfig::default();
617617 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");
619619 }
620620
621621 /// An allowlist must not lock out the default image: a pipeline that simply
⋯ 7 unchanged lines
629629 };
630630 assert!(ci.image_allowed(DEFAULT_RUNNER_IMAGE));
631631 assert!(ci.image_allowed("alpine:3.20"));
632- assert!(!ci.image_allowed("rust:1.95-bookworm"));
632+ assert!(!ci.image_allowed("anvil-runner:rust"));
633633 }
634634
635635 /// Platform resolution has three levels, and the bottom one is "whatever
⋯ 85 unchanged lines
721721 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
722722 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
723723 assert!(
724- ci.image_allowed("rust:1.95-bookworm"),
724+ ci.image_allowed("rust:1.98"),
725725 "tagless entry allows any tag"
726726 );
727727 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
⋯ 9 unchanged lines
modifieddeploy/runner/Dockerfile+33 −10
⋯ 4 unchanged lines
55 # agent sessions always do (`agent.image`). Keeping them the same image means a
66 # session can reproduce a build by hand, and there is one thing to keep current.
77 #
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:
1010 #
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.
1918 #
2019 # Build both with deploy/runner/build.sh. There is NO registry behind this
2120 # image — it lives only in the host's local image store, which is why anvil
⋯ 57 unchanged lines
7978 && apt-get install -y --no-install-recommends claude-code \
8079 && rm -rf /var/lib/apt/lists/*
8180
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+
82105 # apt installs never auto-update, but Claude Code still checks on startup and
83106 # the check is pure noise in a container whose version is pinned by the image.
84107 ENV DISABLE_AUTOUPDATER=1
⋯ 47 unchanged lines
modifieddeploy/runner/build.sh+14 −9
⋯ 1 unchanged line
22 # Build the shared anvil runner image(s) — what BOTH CI jobs and agent sessions
33 # execute in.
44 #
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:
67 #
78 # 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
910 #
1011 # There is deliberately no registry push: anvil resolves its default image from
1112 # the LOCAL image store and treats a failed pull as non-fatal when the image is
⋯ 10 unchanged lines
2223 NAME="${ANVIL_RUNNER_IMAGE:-anvil-runner}"
2324 CHANNEL="${CLAUDE_CHANNEL:-stable}"
2425
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+
2530 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})"
2833 docker build \
29- --build-arg "BASE=${base}" \
34+ --build-arg "RUST_VERSION=${rust}" \
3035 --build-arg "CLAUDE_CHANNEL=${CHANNEL}" \
3136 -t "${NAME}:${tag}" \
3237 .
3338 }
3439
3540 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}" ;;
3843 all)
39- build latest ubuntu:26.04
40- build rust rust:1.95-bookworm
44+ build latest ""
45+ build rust "${RUST_VERSION}"
4146 ;;
4247 *)
4348 echo "usage: $0 [latest|rust|all]" >&2
⋯ 13 unchanged lines
modifieddocs/agent-sessions.md+6 −5
⋯ 47 unchanged lines
4848 ./deploy/runner/build.sh latest # just the slim one
4949 ```
5050
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:
5253
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 |
5758
5859 **There is no registry behind this image.** It is built straight into the
5960 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
4343 ## Declaring artifacts in `.anvil/ci.yml`
4444
4545 ```yaml
46-image: rust:1.95-bookworm
46+image: anvil-runner:rust
4747 steps:
4848 - name: build
4949 run: cargo build --release
⋯ 112 unchanged lines
modifieddocs/remote-runners.md+2 −2
⋯ 116 unchanged lines
117117 ```json
118118 {
119119 "run_id": 42,
120- "image": "rust:1.95-bookworm",
120+ "image": "anvil-runner:rust",
121121 "platform": "linux/amd64",
122122 "script": "set -e\n...",
123123 "secrets": [{"name": "CARGO_TOKEN", "value": "..."}],
⋯ 82 unchanged lines
206206 `gcr.io/distroless/static:nonroot` would make that build pure `COPY` and
207207 genuinely emulation-free. Nice-to-have.
208208
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
210210 `cargo test` tests an architecture you don't ship. That is what M2 fixes.
211211
212212 A job's platform comes from `platform:` in `.anvil/ci.yml`, falling back to
⋯ 241 unchanged lines