scripts/ci/retry.sh already wraps every single-shot network call in CI, for the reason documented there — the runner box runs many jobs in parallel and its network sheds packets under that load. One of the largest fetches in this workspace was never wrappable that way: skia-bindings pulls ~19 MB of prebuilt Skia per target from inside its build script, with a bare curl -sS -f -L and no retry (build_support/binary_cache/utils.rs).
What it actually looked like
Measured on main, android job, 2026-08-22:
DOWNLOAD AND INSTALL FAILED: curl error code: "18"
curl stderr: "curl: (18) end of response with 17054400 bytes missing"
2 MB of 19,057,024 arrived before git.unom.io closed the connection. The same asset pulls fine from a dev box (http=200 size=19057024 in 4.7 s), so this is exactly the load-shedding retry.sh exists for.
skia-bindings then swallowed it. try_prepare_download treats a failed download as "no prebuilt available" and falls through to STARTING A FULL BUILD — a from-source Skia build the CI containers carry no deps for. So the job surfaced as "Clippy (Android target, deny warnings) failed" with a Gradle stack trace, and the real cause ~1,800 lines above it. That is why this has looked like a Rust lint problem for a while.
The fix
A retrying curl shim first on PATH is the only lever that reaches inside a build script, and the cheapest correct one: skia-bindings already passes -C - (resume) and caches the part-file under OUT_DIR/.cache, so a retry continues the truncated transfer rather than restarting it.
--retry-all-errors is load-bearing: a truncated transfer is a transfer failure, not an HTTP status, so plain --retry would let error 18 straight through.
Shim lives in RUNNER_TEMP, not a system dir — the Linux job containers run as root but the macOS runner is a persistent host.
The real curl is resolved and baked in as an absolute path before the shim is on PATH, so it cannot exec itself.
Wired into android.yml and both ci.yml rust jobs — pf-console-ui depends on skia-safe, so ci / rust downloads Skia on any target/ cache miss and is exposed to the same drop. (android caches no target/ at all, which is why it hit first and hardest.)
The prose rule is now a gate.android.yml's env: block already said "🛑 skia-bindings never fails when no archive matches — it silently builds Skia from source. Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED." That was a manual check nobody performs on a green run. The clippy step now fails the job on STARTING A FULL BUILD with a message naming the fetch, so a dropped prebuilt can never masquerade as a lint failure again.
Verification
The shim was run and exercised against the real asset, using skia-bindings' exact argument style:
sh -n clean (POSIX sh — the runner is dash, per the shader-gate note in ci.yml).
Not fixed here
deb.yml's build-publish / build-publish-gamescope jobs also fail on main, but with every step including actions/checkout@v4 marked failed — that is a runner/container problem, not a workspace one, and wants a separate look at the fleet.
`scripts/ci/retry.sh` already wraps every single-shot network call in CI, for the reason documented there — the runner box runs many jobs in parallel and its network sheds packets under that load. One of the largest fetches in this workspace was never wrappable that way: **skia-bindings pulls ~19 MB of prebuilt Skia per target from inside its build script**, with a bare `curl -sS -f -L` and no retry (`build_support/binary_cache/utils.rs`).
## What it actually looked like
Measured on `main`, android job, 2026-08-22:
```
DOWNLOAD AND INSTALL FAILED: curl error code: "18"
curl stderr: "curl: (18) end of response with 17054400 bytes missing"
```
2 MB of 19,057,024 arrived before git.unom.io closed the connection. The same asset pulls fine from a dev box (`http=200 size=19057024` in 4.7 s), so this is exactly the load-shedding `retry.sh` exists for.
skia-bindings then **swallowed it**. `try_prepare_download` treats a failed download as "no prebuilt available" and falls through to `STARTING A FULL BUILD` — a from-source Skia build the CI containers carry no deps for. So the job surfaced as **"Clippy (Android target, deny warnings) failed"** with a Gradle stack trace, and the real cause ~1,800 lines above it. That is why this has looked like a Rust lint problem for a while.
## The fix
* **A retrying `curl` shim first on PATH** is the only lever that reaches inside a build script, and the cheapest correct one: skia-bindings already passes `-C -` (resume) and caches the part-file under `OUT_DIR/.cache`, so a retry *continues* the truncated transfer rather than restarting it.
* `--retry-all-errors` is load-bearing: a truncated transfer is a transfer failure, not an HTTP status, so plain `--retry` would let error 18 straight through.
* Shim lives in `RUNNER_TEMP`, not a system dir — the Linux job containers run as root but the macOS runner is a persistent host.
* The real `curl` is resolved and baked in as an absolute path before the shim is on PATH, so it cannot exec itself.
* **Wired into `android.yml` and both `ci.yml` rust jobs** — `pf-console-ui` depends on `skia-safe`, so `ci / rust` downloads Skia on any `target/` cache miss and is exposed to the same drop. (android caches no `target/` at all, which is why it hit first and hardest.)
* **The prose rule is now a gate.** `android.yml`'s `env:` block already said "🛑 skia-bindings never fails when no archive matches — it silently builds Skia from source. Every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`." That was a manual check nobody performs on a green run. The clippy step now fails the job on `STARTING A FULL BUILD` with a message naming the fetch, so a dropped prebuilt can never masquerade as a lint failure again.
## Verification
The shim was run and exercised against the real asset, using skia-bindings' exact argument style:
```
$ PATH="$shim_dir:$PATH" curl -sS -f -L -o /dev/null -w '%{http_code} %{size_download}' <skia asset>
200 19057024
```
`sh -n` clean (POSIX sh — the runner is dash, per the shader-gate note in `ci.yml`).
## Not fixed here
`deb.yml`'s `build-publish` / `build-publish-gamescope` jobs also fail on main, but with **every step including `actions/checkout@v4` marked failed** — that is a runner/container problem, not a workspace one, and wants a separate look at the fleet.
scripts/ci/retry.sh already wraps every single-shot network call in CI, for the
reason documented there: the runner box runs many jobs in parallel and its
network sheds packets under that load. One of the largest fetches in this
workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt
Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and
no retry (build_support/binary_cache/utils.rs).
Measured on main 2026-08-22, android job:
DOWNLOAD AND INSTALL FAILED: curl error code: "18"
curl stderr: "curl: (18) end of response with 17054400 bytes missing"
2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same
asset pulls fine from a dev box. skia-bindings then swallowed it - its
try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia
build the CI containers carry no deps for - so the job surfaced as
'Clippy (Android target) failed' with a Gradle stack trace and the real cause
1,800 lines above it.
* A retrying curl shim first on PATH is the only lever that reaches inside a
build script, and the cheapest correct one: skia-bindings already passes
'-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES
the truncated transfer rather than restarting it. --retry-all-errors is
load-bearing: a truncated transfer is not an HTTP status, so plain --retry
would let error 18 through.
* Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls
skia-safe too, so ci/rust downloads Skia on any target-cache miss.
* The rule android.yml's env block states in prose ('Every ABI's log must show
DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on
STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint
failure again.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
scripts/ci/retry.shalready wraps every single-shot network call in CI, for the reason documented there — the runner box runs many jobs in parallel and its network sheds packets under that load. One of the largest fetches in this workspace was never wrappable that way: skia-bindings pulls ~19 MB of prebuilt Skia per target from inside its build script, with a barecurl -sS -f -Land no retry (build_support/binary_cache/utils.rs).What it actually looked like
Measured on
main, android job, 2026-08-22:2 MB of 19,057,024 arrived before git.unom.io closed the connection. The same asset pulls fine from a dev box (
http=200 size=19057024in 4.7 s), so this is exactly the load-sheddingretry.shexists for.skia-bindings then swallowed it.
try_prepare_downloadtreats a failed download as "no prebuilt available" and falls through toSTARTING A FULL BUILD— a from-source Skia build the CI containers carry no deps for. So the job surfaced as "Clippy (Android target, deny warnings) failed" with a Gradle stack trace, and the real cause ~1,800 lines above it. That is why this has looked like a Rust lint problem for a while.The fix
curlshim first on PATH is the only lever that reaches inside a build script, and the cheapest correct one: skia-bindings already passes-C -(resume) and caches the part-file underOUT_DIR/.cache, so a retry continues the truncated transfer rather than restarting it.--retry-all-errorsis load-bearing: a truncated transfer is a transfer failure, not an HTTP status, so plain--retrywould let error 18 straight through.RUNNER_TEMP, not a system dir — the Linux job containers run as root but the macOS runner is a persistent host.curlis resolved and baked in as an absolute path before the shim is on PATH, so it cannot exec itself.android.ymland bothci.ymlrust jobs —pf-console-uidepends onskia-safe, soci / rustdownloads Skia on anytarget/cache miss and is exposed to the same drop. (android caches notarget/at all, which is why it hit first and hardest.)android.yml'senv:block already said "🛑 skia-bindings never fails when no archive matches — it silently builds Skia from source. Every ABI's log must showDOWNLOAD AND INSTALL SUCCEEDED." That was a manual check nobody performs on a green run. The clippy step now fails the job onSTARTING A FULL BUILDwith a message naming the fetch, so a dropped prebuilt can never masquerade as a lint failure again.Verification
The shim was run and exercised against the real asset, using skia-bindings' exact argument style:
sh -nclean (POSIX sh — the runner is dash, per the shader-gate note inci.yml).Not fixed here
deb.yml'sbuild-publish/build-publish-gamescopejobs also fail on main, but with every step includingactions/checkout@v4marked failed — that is a runner/container problem, not a workspace one, and wants a separate look at the fleet.scripts/ci/retry.sh already wraps every single-shot network call in CI, for the reason documented there: the runner box runs many jobs in parallel and its network sheds packets under that load. One of the largest fetches in this workspace was never wrappable that way - skia-bindings pulls ~19 MB of prebuilt Skia per target from INSIDE its build script, with a bare 'curl -sS -f -L' and no retry (build_support/binary_cache/utils.rs). Measured on main 2026-08-22, android job: DOWNLOAD AND INSTALL FAILED: curl error code: "18" curl stderr: "curl: (18) end of response with 17054400 bytes missing" 2 MB of 19,057,024 arrived before git.unom.io closed the connection; the same asset pulls fine from a dev box. skia-bindings then swallowed it - its try_prepare_download falls through to STARTING A FULL BUILD, a from-source Skia build the CI containers carry no deps for - so the job surfaced as 'Clippy (Android target) failed' with a Gradle stack trace and the real cause 1,800 lines above it. * A retrying curl shim first on PATH is the only lever that reaches inside a build script, and the cheapest correct one: skia-bindings already passes '-C -' and caches the part-file under OUT_DIR/.cache, so a retry CONTINUES the truncated transfer rather than restarting it. --retry-all-errors is load-bearing: a truncated transfer is not an HTTP status, so plain --retry would let error 18 through. * Wired into android.yml and both ci.yml rust jobs - pf-console-ui pulls skia-safe too, so ci/rust downloads Skia on any target-cache miss. * The rule android.yml's env block states in prose ('Every ABI's log must show DOWNLOAD AND INSTALL SUCCEEDED') is now a gate that fails the job on STARTING A FULL BUILD, so a dropped prebuilt can never masquerade as a lint failure again.