Skip to content

Commit

Merge Rust builds cached through g1t's own Actions cache: sccache in deploy and CI, the cache service speaks what sccache needs, and test-only changes deploy nothing

syntaqxcommitted Parents4a3e60b911b1acBrowse files
11 files+985−450/11 viewed
+17−0
77 # typescript type checks and tests of the apps and TS services, the
88 # deploy and ops scripts, and the deploy manifest
99 # build the site, sudo and the docs build as they deploy
10+#
11+# On a push to main only `rust` runs, to keep main's caches current: a pull
12+# request's run restores from main's cache, never from another pull
13+# request's, so without it each pull request would start from nothing.
1014 name: CI
1115
1216 on:
1317 pull_request:
1418 branches: [main]
19+ push:
20+ branches: [main]
1521 workflow_dispatch:
1622
1723 # Its token only reads: it checks the code out and nothing more.
4753 !target/debug/incremental
4854 key: cargo-test-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
4955 restore-keys: cargo-test-${{ runner.os }}-
56+ # The workspace's library crates, which Cargo compiles again on every
57+ # checkout, come back from the repository's Actions cache when their
58+ # inputs did not change (scripts/sccache.sh). Test harnesses and
59+ # Workers' own crates are linked, and still compiled.
60+ - name: sccache
61+ run: bash scripts/sccache.sh install
5062 - name: Tests
5163 run: cargo test --workspace --locked --quiet
64+ - name: sccache's hits and misses
65+ if: ${{ always() }}
66+ run: bash scripts/sccache.sh stats
5267
5368 typescript:
5469 name: TypeScript
70+ if: ${{ github.event_name != 'push' }}
5571 runs-on: ubuntu-latest
5672 timeout-minutes: 30
5773 steps:
7187
7288 build:
7389 name: Build
90+ if: ${{ github.event_name != 'push' }}
7491 runs-on: ubuntu-latest
7592 timeout-minutes: 30
7693 steps:
+22−5
180180 key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
181181 restore-keys: cargo-crates-${{ runner.os }}-
182182 # The compiled dependencies of this job's units, for wasm32 and the
183− # build scripts and proc macros they run. The workspace's own crates
184− # are compiled again whatever is cached (a checkout's sources are
185− # newer), so an entry is saved only when the dependencies change: a
186− # new Cargo.lock, or a new base image (base.json names its Rust).
187− # Otherwise the nearest earlier entry, of any group, is a start.
183+ # build scripts and proc macros they run. Cargo calls rustc again for
184+ # the workspace's own crates whatever is cached (a checkout's sources
185+ # are newer); sccache, below, answers those calls. So an entry is
186+ # saved only when the dependencies change: a new Cargo.lock, or a new
187+ # base image (base.json names its Rust). Otherwise the nearest earlier
188+ # entry, of any group, is a start.
188189 - name: Cache the Cargo target
189190 if: ${{ matrix.rust }}
190191 uses: actions/cache@v4
215216 !target/**/incremental
216217 key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
217218 restore-keys: runner-musl-${{ runner.os }}-
219+ # Every rustc call that makes a library (the workspace's crates,
220+ # which Cargo compiles again on every checkout, and any dependency
221+ # not restored above) is looked up by its inputs in the repository's
222+ # Actions cache: unchanged crates come back from it instead of being
223+ # compiled. A Worker's own crate (a cdylib) and the runner's binary
224+ # are still compiled. worker-build runs Cargo, so its wasm32 builds
225+ # go through it too; wasm-bindgen and wasm-opt are not rustc.
226+ # Pinned by version and sha256 in scripts/sccache.sh; without the
227+ # cache, the job builds as before.
228+ - name: sccache
229+ if: ${{ matrix.rust || matrix.image }}
230+ run: bash scripts/sccache.sh install
218231 - name: Install
219232 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
220233 - name: Deploy ${{ matrix.units }}
224237 # are not drafted as incidents. Optional: without it, nothing is sent.
225238 STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }}
226239 run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2
240+ # Hits and misses, on the log and in the run's summary.
241+ - name: sccache's hits and misses
242+ if: ${{ always() && (matrix.rust || matrix.image) }}
243+ run: bash scripts/sccache.sh stats
227244
228245 edge:
229246 name: edge (${{ matrix.group }})
+395−25
1010 //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the
1111 //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`,
1212 //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field
13−//! names.
13+//! names; the cache's methods also in protobuf (`application/protobuf`),
14+//! which other clients of the protocol send (sccache, through OpenDAL).
1415 //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`,
1516 //! which the toolkit's client uses whenever the server it runs against is
16−//! not github.com: on g1t, that is the one it uses.
17+//! not github.com: on g1t, that is the one it uses, and sccache's too.
18+//! Its entries are sent in 32 MB chunks, or in one chunk of any size.
1719 //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those
18−//! hand out. Downloads are a plain GET. Uploads speak the part of Azure
19−//! Blob Storage's protocol the toolkit's client uses (Put Blob, Put
20−//! Block, Put Block List), mapped onto an R2 multipart upload: a block's
21−//! id ends in its index, which is its part's number.
20+//! hand out. Downloads are a GET, of the whole blob or of one byte range
21+//! (`Range`), as the toolkit's client fetches large entries in segments.
22+//! Uploads speak the part of Azure Blob Storage's protocol the toolkit's
23+//! client uses (Put Blob, Put Block, Put Block List), mapped onto an R2
24+//! multipart upload: a block's id ends in its index, which is its part's
25+//! number. An upload link carries a query, as an Azure SAS link does,
26+//! which clients that sign their requests with it need.
2227 //!
2328 //! Every call carries the job's runtime token; the actions service checks
2429 //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there).
8590
8691 // ── Twirp ───────────────────────────────────────────────────────────────────
8792
93+/// Twirp's binary encoding.
94+const PROTOBUF: &str = "application/protobuf";
95+
96+/// Whether a request's `Content-Type` is Twirp's protobuf encoding.
97+fn is_protobuf(content_type: &str) -> bool {
98+ let kind = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
99+ kind == PROTOBUF || kind == "application/x-protobuf"
100+}
101+
102+/// The cache service's messages in protobuf, read into and written from the
103+/// JSON the handlers use (`results/api/v1/cache.proto`, field numbers as
104+/// there). Only what the cache's three methods carry: strings, a repeated
105+/// string, an int64 and a bool. `metadata` (field 1 of each request) is
106+/// skipped: the runtime token says whose cache it is.
107+mod proto {
108+ use serde_json::{Map, Value};
109+
110+ #[derive(Clone, Copy)]
111+ enum Kind {
112+ Text,
113+ Texts,
114+ Int,
115+ Bool,
116+ }
117+
118+ /// A message's fields: number, JSON name, kind.
119+ type Fields = &'static [(u64, &'static str, Kind)];
120+
121+ fn request_fields(method: &str) -> Option<Fields> {
122+ Some(match method {
123+ "CreateCacheEntry" => &[(2, "key", Kind::Text), (3, "version", Kind::Text)],
124+ "FinalizeCacheEntryUpload" => &[(2, "key", Kind::Text), (3, "size_bytes", Kind::Int), (4, "version", Kind::Text)],
125+ "GetCacheEntryDownloadURL" => &[(2, "key", Kind::Text), (3, "restore_keys", Kind::Texts), (4, "version", Kind::Text)],
126+ _ => return None,
127+ })
128+ }
129+
130+ fn response_fields(method: &str) -> Fields {
131+ match method {
132+ "CreateCacheEntry" => &[(1, "ok", Kind::Bool), (2, "signed_upload_url", Kind::Text), (3, "message", Kind::Text)],
133+ "FinalizeCacheEntryUpload" => &[(1, "ok", Kind::Bool), (2, "entry_id", Kind::Int), (3, "message", Kind::Text)],
134+ "GetCacheEntryDownloadURL" => &[(1, "ok", Kind::Bool), (2, "signed_download_url", Kind::Text), (3, "matched_key", Kind::Text)],
135+ _ => &[],
136+ }
137+ }
138+
139+ fn varint(bytes: &[u8], at: &mut usize) -> Option<u64> {
140+ let mut value = 0u64;
141+ for shift in (0..64).step_by(7) {
142+ let byte = *bytes.get(*at)?;
143+ *at += 1;
144+ value |= u64::from(byte & 0x7f) << shift;
145+ if byte & 0x80 == 0 {
146+ return Some(value);
147+ }
148+ }
149+ None
150+ }
151+
152+ fn put_varint(out: &mut Vec<u8>, mut value: u64) {
153+ while value >= 0x80 {
154+ out.push((value as u8 & 0x7f) | 0x80);
155+ value >>= 7;
156+ }
157+ out.push(value as u8);
158+ }
159+
160+ /// A request of `method` as JSON, or None when it is not one.
161+ pub fn request(method: &str, bytes: &[u8]) -> Option<Value> {
162+ let fields = request_fields(method)?;
163+ let mut out = Map::new();
164+ let mut at = 0;
165+ while at < bytes.len() {
166+ let tag = varint(bytes, &mut at)?;
167+ let (number, wire) = (tag >> 3, tag & 7);
168+ let known = fields.iter().find(|(n, _, _)| *n == number);
169+ match wire {
170+ 0 => {
171+ let value = varint(bytes, &mut at)?;
172+ if let Some((_, name, Kind::Int)) = known {
173+ // An int64 is sent as its two's complement.
174+ out.insert((*name).to_owned(), Value::String((value as i64).to_string()));
175+ }
176+ }
177+ 2 => {
178+ let length = usize::try_from(varint(bytes, &mut at)?).ok()?;
179+ let end = at.checked_add(length).filter(|end| *end <= bytes.len())?;
180+ let raw = &bytes[at..end];
181+ at = end;
182+ match known {
183+ Some((_, name, Kind::Text)) => {
184+ out.insert((*name).to_owned(), Value::String(String::from_utf8(raw.to_vec()).ok()?));
185+ }
186+ Some((_, name, Kind::Texts)) => {
187+ let text = Value::String(String::from_utf8(raw.to_vec()).ok()?);
188+ match out.entry((*name).to_owned()).or_insert_with(|| Value::Array(Vec::new())) {
189+ Value::Array(list) => list.push(text),
190+ _ => return None,
191+ }
192+ }
193+ _ => {}
194+ }
195+ }
196+ 1 => at = at.checked_add(8).filter(|end| *end <= bytes.len())?,
197+ 5 => at = at.checked_add(4).filter(|end| *end <= bytes.len())?,
198+ _ => return None,
199+ }
200+ }
201+ Some(Value::Object(out))
202+ }
203+
204+ /// A response of `method` from its JSON. Defaults are left out, as
205+ /// proto3 does.
206+ pub fn response(method: &str, value: &Value) -> Vec<u8> {
207+ let mut out = Vec::new();
208+ for (number, name, kind) in response_fields(method) {
209+ let field = &value[*name];
210+ match kind {
211+ Kind::Bool if field.as_bool() == Some(true) => {
212+ put_varint(&mut out, number << 3);
213+ put_varint(&mut out, 1);
214+ }
215+ Kind::Int => {
216+ let n = field.as_i64().or_else(|| field.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
217+ if n != 0 {
218+ put_varint(&mut out, number << 3);
219+ put_varint(&mut out, n as u64);
220+ }
221+ }
222+ Kind::Text => {
223+ let text = field.as_str().unwrap_or_default();
224+ if !text.is_empty() {
225+ put_varint(&mut out, (number << 3) | 2);
226+ put_varint(&mut out, text.len() as u64);
227+ out.extend_from_slice(text.as_bytes());
228+ }
229+ }
230+ _ => {}
231+ }
232+ }
233+ out
234+ }
235+}
236+
88237 /// A Twirp error: its code and message, at the status Twirp gives it.
89238 fn twirp_error(code: &str, message: &str) -> Result<Response> {
90239 let status = match code {
92241 "permission_denied" => 403,
93242 "not_found" => 404,
94243 "already_exists" => 409,
95− "invalid_argument" => 400,
244+ "invalid_argument" | "malformed" => 400,
245+ "bad_route" => 404,
96246 "failed_precondition" => 412,
97247 "resource_exhausted" => 429,
98248 _ => 500,
155305 })
156306 }
157307
308+/// The Azure Storage version g1t's blob links answer as.
309+const AZURE_VERSION: &str = "2024-11-04";
310+
311+/// An upload link: the blob's, with a query as an Azure SAS link has one.
312+/// A client that treats it as a container, a blob and a SAS token (OpenDAL,
313+/// which sccache uses) refuses a link without one; the token in the path
314+/// is what g1t checks.
315+pub fn upload_url(api: &str, blob: &str) -> String {
316+ format!("{}?sv={AZURE_VERSION}", blob_url(api, blob))
317+}
318+
158319 /// Starts an R2 upload for an entry the service reserved, and the signed
159320 /// link the toolkit sends it to.
160321 async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> {
167328 )
168329 .await?;
169330 Ok(match signed {
170− Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)),
331+ Outcome::Ok(blob) => Outcome::Ok(upload_url(&services.addresses.api, &blob)),
171332 Outcome::Fail(refused) => Outcome::Fail(refused),
172333 })
173334 }
178339 let Some(job) = runtime_job(&token) else {
179340 return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token.");
180341 };
181− let body: Value = request.json().await.unwrap_or(Value::Null);
342+ // Twirp clients send JSON or protobuf, and are answered in kind.
343+ let binary = is_protobuf(&request.headers().get("content-type")?.unwrap_or_default());
344+ let body: Value = if binary {
345+ match proto::request(method, &request.bytes().await.unwrap_or_default()) {
346+ Some(body) => body,
347+ None => return twirp_error("malformed", "That is not a protobuf message this method takes."),
348+ }
349+ } else {
350+ request.json().await.unwrap_or(Value::Null)
351+ };
352+ let answer = |value: Value| -> Result<Response> {
353+ if binary {
354+ let mut response = Response::from_bytes(proto::response(method, &value))?;
355+ response.headers_mut().set("content-type", PROTOBUF)?;
356+ Ok(response)
357+ } else {
358+ Response::from_json(&value)
359+ }
360+ };
182361 let bucket = env.bucket("ACTIONS_CACHE")?;
183362 let actions = &services.actions;
184363 let (run, own_job) = backend_ids(&token);
201380 let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?;
202381 match found {
203382 Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => {
204− Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
383+ answer(json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key }))
205384 }
206− Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
385+ Outcome::Ok(_) => answer(json!({ "ok": false, "signed_download_url": "", "matched_key": "" })),
207386 Outcome::Fail(refused) => twirp_failure(&refused),
208387 }
209388 }
212391 let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?;
213392 let reserved = match reserved {
214393 Outcome::Ok(reserved) => reserved,
215− // The client warns with this and goes on, as for a key
216− // another job is saving.
217− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
394+ // A key already saved, or being saved by another job, to a
395+ // protobuf client (OpenDAL's) is Twirp's `already_exists`
396+ // (409), which it takes as "someone else has it": sccache
397+ // then still writes. An `ok: false` would read as a broken
398+ // cache, and sccache would only read from it.
399+ Outcome::Fail(refused) if binary && refused.code == FailureCode::Conflict => return twirp_failure(&refused),
400+ // The toolkit's client logs this ("another job may be
401+ // creating this cache") and goes on.
402+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
218403 };
219404 match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? {
220− Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })),
221− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
405+ Outcome::Ok(url) => answer(json!({ "ok": true, "signed_upload_url": url })),
406+ Outcome::Fail(refused) => answer(json!({ "ok": false, "signed_upload_url": "", "message": refused.message })),
222407 }
223408 }
224409 (CACHE_SERVICE, "FinalizeCacheEntryUpload") => {
226411 let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?;
227412 let pending = match pending {
228413 Outcome::Ok(pending) => pending,
229− Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
414+ Outcome::Fail(refused) => return answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
230415 };
231416 let Some(object) = bucket.head(&pending.object).await? else {
232− return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
417+ return answer(json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." }));
233418 };
234419 match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? {
235− Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })),
236− Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })),
420+ Outcome::Ok(()) => answer(json!({ "ok": true, "entry_id": pending.number.to_string() })),
421+ Outcome::Fail(refused) => answer(json!({ "ok": false, "entry_id": "0", "message": refused.message })),
237422 }
238423 }
239424 (ARTIFACT_SERVICE, "CreateArtifact") => {
321506 }
322507
323508 /// The part a chunk of the older protocol is, from its `Content-Range`:
324−/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them.
509+/// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. A first
510+/// chunk may be larger, up to `MAX_BLOCK_BYTES`: a client that sends an
511+/// entry in one request (sccache does) sends a single chunk from 0.
325512 pub fn chunk_part(range: &str) -> Option<(u16, u64)> {
326513 let range = range.trim().strip_prefix("bytes ")?;
327514 let (span, _) = range.split_once('/')?;
328515 let (start, end) = span.split_once('-')?;
329516 let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?);
330− if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES {
517+ if end < start {
331518 return None;
332519 }
333− Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1))
520+ let length = end - start + 1;
521+ if start == 0 && length <= MAX_BLOCK_BYTES {
522+ return Some((1, length));
523+ }
524+ if start % CACHE_PART_BYTES != 0 || length > CACHE_PART_BYTES {
525+ return None;
526+ }
527+ Some(((start / CACHE_PART_BYTES + 1) as u16, length))
334528 }
335529
530+/// The bytes a download's `Range` header asks for, out of `size`: first and
531+/// last, inclusive. None to send the whole blob (no header, or one this
532+/// does not read, such as several ranges); `Some(None)` when the range is
533+/// past the end (416).
534+pub fn byte_range(header: &str, size: u64) -> Option<Option<(u64, u64)>> {
535+ let spec = header.trim().strip_prefix("bytes=")?.trim();
536+ if spec.contains(',') {
537+ return None;
538+ }
539+ let (first, last) = spec.split_once('-')?;
540+ let (first, last) = (first.trim(), last.trim());
541+ let range = if first.is_empty() {
542+ // The last `n` bytes.
543+ let n: u64 = last.parse().ok()?;
544+ if n == 0 || size == 0 {
545+ return Some(None);
546+ }
547+ (size.saturating_sub(n), size - 1)
548+ } else {
549+ let first: u64 = first.parse().ok()?;
550+ let last: u64 = if last.is_empty() { u64::MAX } else { last.parse().ok()? };
551+ if last < first {
552+ return None;
553+ }
554+ if first >= size {
555+ return Some(None);
556+ }
557+ (first, last.min(size - 1))
558+ };
559+ Some(Some(range))
560+}
561+
336562 /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it.
337563 pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> {
338564 let token = bearer(&request);
527753 headers.set("content-length", &size.to_string())?;
528754 headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?;
529755 headers.set("x-ms-blob-type", "BlockBlob")?;
756+ headers.set("accept-ranges", "bytes")?;
530757 if let Some(name) = &grant.filename {
531758 headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?;
532759 }
538765 headers(&mut response, object.size())?;
539766 return Ok(response);
540767 }
768+ // One byte range (`Range`, or Azure's `x-ms-range`): the
769+ // toolkit's client fetches a large entry in segments, side by
770+ // side, and writes each where its range says.
771+ let asked = match request.headers().get("x-ms-range")? {
772+ Some(range) => Some(range),
773+ None => request.headers().get("range")?,
774+ };
775+ if let Some(asked) = asked.filter(|r| !r.trim().is_empty()) {
776+ let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
777+ let size = object.size();
778+ match byte_range(&asked, size) {
779+ Some(Some((first, last))) => {
780+ let length = last - first + 1;
781+ let Some(object) = bucket.get(&grant.object).range(worker::Range::OffsetWithLength { offset: first, length }).execute().await? else {
782+ return azure_error(404, "BlobNotFound", "It is gone.");
783+ };
784+ let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
785+ let mut response = Response::from_body(body.response_body()?)?.with_status(206);
786+ headers(&mut response, length)?;
787+ response.headers_mut().set("content-range", &format!("bytes {first}-{last}/{size}"))?;
788+ return Ok(response);
789+ }
790+ Some(None) => {
791+ let mut response = azure_error(416, "InvalidRange", "The range is past the end of the blob.")?;
792+ response.headers_mut().set("content-range", &format!("bytes */{size}"))?;
793+ return Ok(response);
794+ }
795+ // Not a range this reads: the whole blob.
796+ None => {}
797+ }
798+ }
541799 let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") };
542800 let size = object.size();
543801 let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") };
674932 let mb32 = CACHE_PART_BYTES;
675933 assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32)));
676934 assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100)));
677− // Not on a chunk's boundary, too long, or not a range.
935+ // A whole entry in one chunk, as sccache (OpenDAL) sends it: its
936+ // check file is 13 bytes, a compiled crate can be well over 32 MB.
937+ assert_eq!(chunk_part("bytes 0-12/*"), Some((1, 13)));
938+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), Some((1, mb32 + 1)));
939+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES - 1)), Some((1, MAX_BLOCK_BYTES)));
940+ // Not on a chunk's boundary, too long, backwards, or not a range.
678941 assert_eq!(chunk_part("bytes 5-10/*"), None);
679− assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None);
942+ assert_eq!(chunk_part(&format!("bytes {mb32}-{}/*", mb32 * 2)), None);
943+ assert_eq!(chunk_part(&format!("bytes 0-{}/*", MAX_BLOCK_BYTES)), None);
944+ assert_eq!(chunk_part("bytes 10-5/*"), None);
680945 assert_eq!(chunk_part("0-10"), None);
681946 }
682947
948+ #[test]
949+ fn downloads_read_one_byte_range() {
950+ // OpenDAL's stat: the first byte.
951+ assert_eq!(byte_range("bytes=0-0", 100), Some(Some((0, 0))));
952+ // The toolkit's segments, the last one cut at the end.
953+ assert_eq!(byte_range("bytes=0-49", 100), Some(Some((0, 49))));
954+ assert_eq!(byte_range("bytes=50-999", 100), Some(Some((50, 99))));
955+ assert_eq!(byte_range("bytes=90-", 100), Some(Some((90, 99))));
956+ assert_eq!(byte_range("bytes=-10", 100), Some(Some((90, 99))));
957+ assert_eq!(byte_range("bytes=-1000", 100), Some(Some((0, 99))));
958+ // Past the end: 416.
959+ assert_eq!(byte_range("bytes=100-200", 100), Some(None));
960+ assert_eq!(byte_range("bytes=0-0", 0), Some(None));
961+ assert_eq!(byte_range("bytes=-0", 100), Some(None));
962+ // Not read: the whole blob.
963+ assert_eq!(byte_range("bytes=0-1,5-6", 100), None);
964+ assert_eq!(byte_range("bytes=9-3", 100), None);
965+ assert_eq!(byte_range("items=0-1", 100), None);
966+ assert_eq!(byte_range("bytes=a-b", 100), None);
967+ }
968+
969+ #[test]
970+ fn upload_links_carry_a_query_as_sas_links_do() {
971+ let url = upload_url("https://api.g1t.sh", "tok.sig");
972+ assert_eq!(url, "https://api.g1t.sh/actions/toolkit/blobs/tok.sig?sv=2024-11-04");
973+ // How OpenDAL reads a signed upload link: a container, a blob in
974+ // it, and a SAS query, all of which must be there.
975+ let rest = url.strip_prefix("https://api.g1t.sh/").unwrap();
976+ let (path, query) = rest.split_once('?').unwrap();
977+ let (container, blob) = path.split_once('/').unwrap();
978+ assert_eq!((container, blob, query), ("actions", "toolkit/blobs/tok.sig", "sv=2024-11-04"));
979+ }
980+
981+ /// A protobuf length-delimited field, as prost writes it.
982+ fn pb_text(number: u8, text: &str) -> Vec<u8> {
983+ let mut out = vec![(number << 3) | 2, text.len() as u8];
984+ out.extend_from_slice(text.as_bytes());
985+ out
986+ }
987+
988+ /// The requests sccache 0.18 sends (OpenDAL 0.58's `ghac` service, with
989+ /// prost): fields in number order, defaults left out, no metadata.
990+ #[test]
991+ fn twirp_reads_sccaches_protobuf_requests() {
992+ assert!(is_protobuf("application/protobuf"));
993+ assert!(is_protobuf("Application/Protobuf; charset=utf-8"));
994+ assert!(!is_protobuf("application/json"));
995+ assert!(!is_protobuf(""));
996+
997+ let key = "sccache/f/c/b/fcb0a1d2e3";
998+ let version = "sccache-v0.18.0";
999+ let create = [pb_text(2, key), pb_text(3, version)].concat();
1000+ let read = proto::request("CreateCacheEntry", &create).unwrap();
1001+ assert_eq!((text(&read, "key"), text(&read, "version")), (key.to_owned(), version.to_owned()));
1002+
1003+ // size_bytes is field 3, a varint: 300 is 0xac 0x02.
1004+ let finalize = [pb_text(2, key), vec![0x18, 0xac, 0x02], pb_text(4, version)].concat();
1005+ let read = proto::request("FinalizeCacheEntryUpload", &finalize).unwrap();
1006+ assert_eq!(number(&read, "size_bytes"), Some(300));
1007+ assert_eq!(text(&read, "version"), version);
1008+
1009+ let lookup = [pb_text(2, key), pb_text(4, version)].concat();
1010+ let read = proto::request("GetCacheEntryDownloadURL", &lookup).unwrap();
1011+ assert_eq!(text(&read, "key"), key);
1012+ assert!(field(&read, "restore_keys").is_null());
1013+ // The toolkit's own lookup, with metadata (skipped) and restore keys.
1014+ let metadata = vec![0x0a, 0x02, 0x08, 0x07];
1015+ let with_restore = [metadata, pb_text(2, "k"), pb_text(3, "k-"), pb_text(3, "x-"), pb_text(4, "v")].concat();
1016+ let read = proto::request("GetCacheEntryDownloadURL", &with_restore).unwrap();
1017+ assert_eq!(field(&read, "restore_keys"), &json!(["k-", "x-"]));
1018+ assert_eq!(text(&read, "version"), "v");
1019+
1020+ // The same three, as prost 0.14 encodes them with OpenDAL's
1021+ // generated types (the `ghac` crate, 0.3.0), byte for byte.
1022+ let recorded = |hex: &str| -> Vec<u8> { (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect() };
1023+ let prefix = "1218736363616368652f662f632f622f66636230613164326533";
1024+ let suffix = "0f736363616368652d76302e31382e30";
1025+ assert_eq!(recorded(&format!("{prefix}1a{suffix}")), create);
1026+ assert_eq!(recorded(&format!("{prefix}18ac0222{suffix}")), finalize);
1027+ assert_eq!(recorded(&format!("{prefix}22{suffix}")), lookup);
1028+
1029+ // Cut short, or not a cache method.
1030+ assert!(proto::request("CreateCacheEntry", &create[..create.len() - 1]).is_none());
1031+ assert!(proto::request("CreateCacheEntry", &[0x12, 0xff]).is_none());
1032+ assert!(proto::request("CreateArtifact", &create).is_none());
1033+ assert_eq!(proto::request("CreateCacheEntry", &[]), Some(json!({})));
1034+ }
1035+
1036+ #[test]
1037+ fn twirp_answers_in_protobuf_as_prost_reads_it() {
1038+ let url = "https://api.g1t.sh/actions/toolkit/blobs/t?sv=2024-11-04";
1039+ let created = proto::response("CreateCacheEntry", &json!({ "ok": true, "signed_upload_url": url }));
1040+ assert_eq!(created, [vec![0x08, 0x01], pb_text(2, url)].concat());
1041+ // Refused: ok false is the default, so only the message is sent.
1042+ let refused = proto::response("CreateCacheEntry", &json!({ "ok": false, "signed_upload_url": "", "message": "no" }));
1043+ assert_eq!(refused, pb_text(3, "no"));
1044+ // entry_id is an int64, given as a string in JSON.
1045+ let finalized = proto::response("FinalizeCacheEntryUpload", &json!({ "ok": true, "entry_id": "300" }));
1046+ assert_eq!(finalized, vec![0x08, 0x01, 0x10, 0xac, 0x02]);
1047+ let found = proto::response("GetCacheEntryDownloadURL", &json!({ "ok": true, "signed_download_url": "u", "matched_key": "k" }));
1048+ assert_eq!(found, [vec![0x08, 0x01], pb_text(2, "u"), pb_text(3, "k")].concat());
1049+ // A miss is an empty message: ok false.
1050+ assert!(proto::response("GetCacheEntryDownloadURL", &json!({ "ok": false, "signed_download_url": "", "matched_key": "" })).is_empty());
1051+ }
1052+
6831053 /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact`
6841054 /// 2 send them (protobuf-ts, proto field names, no defaults).
6851055 #[test]
+72−0
4848 | `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). |
4949 | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository and branch, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
5050 | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
51+| sccache with `SCCACHE_GHA_ENABLED` | The same: its entries go to the repository's cache. See [caching Rust builds](#caching-rust-builds). |
5152 | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
5253 | `docker build`, `push`, `run`, `login`, `compose`, Buildx | The same, with a Docker Engine of the job's own. See [Docker](#docker). |
5354 | `services:` | The same: each service starts before the steps, health checks are waited for, and it is reached at `localhost` on its port and by its name. |
604605 `actions/download-artifact` and `actions/upload-artifact/merge` work:
605606 g1t runs those itself.
606607
608+## Caching Rust builds
609+
610+A checkout gives every file a new modification time, so Cargo compiles
611+your workspace's own crates again even when `target/` was restored from
612+the cache. [sccache](https://github.com/mozilla/sccache) caches each
613+compiler call by what it compiles (the source, the flags, the toolchain
614+and the dependencies), so a crate that did not change comes back from the
615+cache instead. Its GitHub Actions backend works on g1t as it is: it keeps
616+its entries in the repository's cache, under [the cache's](#the-cache)
617+limits and branch rules.
618+
619+1. Install sccache, pinned to a release and checked against its checksum.
620+2. Set `RUSTC_WRAPPER: sccache` and `SCCACHE_GHA_ENABLED: "true"`. The
621+ job's `ACTIONS_RUNTIME_TOKEN` and `ACTIONS_CACHE_URL` are already in
622+ every step's environment; no step needs to export them.
623+3. Set `CARGO_INCREMENTAL: "0"`: sccache does not cache incremental
624+ builds, and a fresh checkout gains nothing from them.
625+
626+```yaml
627+name: CI
628+on:
629+ pull_request:
630+ push:
631+ branches: [main]
632+
633+jobs:
634+ test:
635+ runs-on: ubuntu-latest
636+ env:
637+ RUSTC_WRAPPER: sccache
638+ SCCACHE_GHA_ENABLED: "true"
639+ CARGO_INCREMENTAL: "0"
640+ steps:
641+ - uses: actions/checkout@v5
642+ - name: Install sccache
643+ env:
644+ VERSION: v0.18.0
645+ SHA256: 45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89
646+ run: |
647+ name="sccache-$VERSION-x86_64-unknown-linux-musl"
648+ curl -fsSL -o "$RUNNER_TEMP/sccache.tar.gz" \
649+ "https://github.com/mozilla/sccache/releases/download/$VERSION/$name.tar.gz"
650+ echo "$SHA256 $RUNNER_TEMP/sccache.tar.gz" | sha256sum -c -
651+ tar -xzf "$RUNNER_TEMP/sccache.tar.gz" -C "$RUNNER_TEMP"
652+ install -m 0755 "$RUNNER_TEMP/$name/sccache" "$HOME/.cargo/bin/sccache"
653+ - run: cargo test --workspace --locked
654+ - name: sccache's hits and misses
655+ if: always()
656+ run: |
657+ echo '```' >> "$GITHUB_STEP_SUMMARY"
658+ sccache --show-stats | tee -a "$GITHUB_STEP_SUMMARY"
659+ echo '```' >> "$GITHUB_STEP_SUMMARY"
660+```
661+
662+What to expect:
663+
664+| | |
665+| --- | --- |
666+| What is cached | Every library crate (`rlib`): your workspace's crates and their dependencies. |
667+| What is compiled each time | What rustc links: binaries, `cdylib` crates, proc macros, build scripts and test harnesses. |
668+| Its entries | One per compiled crate, often thousands for a workspace, each a few KB to a few MB. They count toward the repository's 10 GiB like any other entry, and the ones not restored for 7 days are deleted. |
669+| Branches | A pull request reads what the default branch saved. Run the workflow on pushes to the default branch too, as above, or each pull request's first run starts with an empty cache. |
670+| Starting over | Set `SCCACHE_GHA_VERSION` to any new value. A new sccache release starts over by itself. |
671+| If the cache cannot be reached | sccache refuses to start. Set `SCCACHE_IGNORE_SERVER_IO_ERROR: "1"` to have rustc run without it instead. |
672+
673+sccache adds to `actions/cache` rather than replacing it: keep caching
674+`~/.cargo/registry/cache` and `target/` keyed by `Cargo.lock`, so Cargo
675+does not call rustc at all for dependencies that did not change, and
676+sccache answers the calls it still makes for your own crates.
677+`Swatinem/rust-cache` works the same way.
678+
607679 ## OIDC tokens
608680
609681 A job can prove which repository, branch and environment it runs for with
+60−0
236236 assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
237237 }
238238
239+/// Whether a step runs, for a job going `status`, with `matrix`.
240+fn step_runs(step: &workflow::Step, matrix: Value, status: Status) -> bool {
241+ let mut contexts = Map::new();
242+ contexts.insert("matrix".into(), matrix);
243+ let scope = Scope { contexts: &contexts, status, hash_files: None };
244+ expr::condition(step.condition.as_deref().unwrap_or_default(), &scope).unwrap()
245+}
246+
247+#[test]
248+fn rust_builds_go_through_sccache_before_cargo_and_report_after() {
249+ let step = |job: &workflow::Job, run: &str| -> (usize, workflow::Step) {
250+ let at = job.steps.iter().position(|s| s.run.as_deref() == Some(run)).unwrap_or_else(|| panic!("{}: no step runs {run}", job.id));
251+ (at, job.steps[at].clone())
252+ };
253+ let deploy = read("deploy.yml");
254+ for stage in ["core", "edge", "front"] {
255+ let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
256+ let (install_at, install) = step(job, "bash scripts/sccache.sh install");
257+ let (stats_at, stats) = step(job, "bash scripts/sccache.sh stats");
258+ // Before anything runs Cargo (worker-build's install, the build),
259+ // and the statistics last.
260+ let install_step = job.steps.iter().position(|s| s.name.as_deref() == Some("Install")).unwrap();
261+ assert!(install_at < install_step, "{stage}");
262+ assert_eq!(stats_at, job.steps.len() - 1, "{stage}");
263+ for (rust, image, uses) in [(true, false, true), (false, true, true), (false, false, false)] {
264+ let matrix = json!({ "group": "g", "units": "u", "rust": rust, "image": image });
265+ assert_eq!(step_runs(&install, matrix.clone(), Status::Success), uses, "{stage} rust={rust} image={image}");
266+ assert_eq!(step_runs(&stats, matrix.clone(), Status::Success), uses, "{stage}");
267+ // A failed build still says what was cached.
268+ assert_eq!(step_runs(&stats, matrix, Status::Failure), uses, "{stage}");
269+ }
270+ }
271+ let ci = read("ci.yml");
272+ let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
273+ let (install_at, _) = step(rust, "bash scripts/sccache.sh install");
274+ let (tests_at, _) = step(rust, "cargo test --workspace --locked --quiet");
275+ let (stats_at, stats) = step(rust, "bash scripts/sccache.sh stats");
276+ assert!(install_at < tests_at && tests_at < stats_at);
277+ assert!(step_runs(&stats, json!({}), Status::Failure));
278+ // main's runs keep the caches pull requests restore from: only Rust.
279+ assert!(ci.trigger("push").unwrap().branches.allows("main"));
280+ assert!(ci.trigger("pull_request").is_some());
281+ for (id, on_push) in [("rust", true), ("typescript", false), ("build", false)] {
282+ let job = ci.jobs.iter().find(|j| j.id == id).unwrap();
283+ for (event, expected) in [("push", on_push), ("pull_request", true)] {
284+ let mut contexts = Map::new();
285+ contexts.insert("github".into(), json!({ "event_name": event, "ref": "refs/heads/main" }));
286+ let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
287+ assert_eq!(expr::condition(job.condition.as_deref().unwrap_or_default(), &scope).unwrap(), expected, "{id} on {event}");
288+ }
289+ }
290+ // The download is pinned by version and checksum.
291+ let script = std::fs::read_to_string(workflows_dir().join("../../scripts/sccache.sh")).unwrap();
292+ assert!(script.contains("VERSION=0.18.0"));
293+ assert!(script.lines().any(|line| line.strip_prefix("SHA256=").is_some_and(|sum| sum.len() == 64)));
294+ assert!(script.contains("sha256sum -c"));
295+ assert!(script.contains("RUSTC_WRAPPER="));
296+ assert!(script.contains("GITHUB_STEP_SUMMARY"));
297+}
298+
239299 #[test]
240300 fn the_runner_base_rebuilds_weekly_on_a_machine_with_docker() {
241301 let base = read("runner-base.yml");
+66−11
158158 1. Plans: for each unit, the live commit; `git diff` from it to `HEAD`;
159159 whether the changed files touch the unit (its folder, the crates and
160160 packages it is built from, its inputs, lockfile changes that reach it).
161+ A Rust crate's `tests/`, `benches/` and `examples/` are not what it is
162+ built from, and neither is a source file compiled only for tests: one
163+ whose every `mod` declaration is under `#[cfg(test)]` (with or without
164+ `#[path]`), or inside a module that is. A change to a crate's tests
165+ alone deploys nothing (`scripts/deploy/stack.mjs`, `testOnlySource`).
161166 2. Refuses if uncommitted changes touch what would deploy (`--allow-dirty`).
162167 3. Applies every pending migration (`wrangler d1 migrations apply --remote`),
163168 in parallel. Any failure stops the deploy before code.
433438 | The runner binary, cold (builder container) | | 99 s |
434439 | A Rust CI job's build (events, search, repos; 4 vCPUs) | | 51 s cold, 13 s with the Cargo target restored (107 MB zstd entry) |
435440
441+- **sccache** (`scripts/sccache.sh`, pinned by version and sha256): a
442+ checkout gives every source a new mtime, so Cargo calls rustc again for
443+ every workspace crate a unit uses, however much of `target/` was
444+ restored. With `RUSTC_WRAPPER=sccache` and its GitHub Actions backend,
445+ each call that makes a library is looked up by its inputs in the
446+ repository's Actions cache (the same cache as `actions/cache`, scoped to
447+ `main`), and an unchanged crate comes back from it. Measured on the
448+ development machine on 2026-10-08, `repos` and `events` for wasm32,
449+ release, `-j 4`, the dependencies already built, every workspace source
450+ touched as a checkout does (sccache's local disk cache; the Actions
451+ cache adds a download per hit):
452+
453+ | | Time |
454+ | --- | --- |
455+ | Without sccache (before) | 44.3 s, 44.6 s |
456+ | sccache, empty cache (its first run, filling it) | 48.8 s |
457+ | sccache, filled (6 hits: `contracts`, `kit`, `rules`, `scan`, `secrets`, `blobstore`) | 24.2 s |
458+
459+ CI's build the same way (`cargo test --workspace --no-run`, debug,
460+ `-j 4`, `CARGO_INCREMENTAL=0`): 70.2 s and 74.6 s without sccache, 66.5 s
461+ filling it, 40.6 s filled (7 library hits; 23 test harnesses and
462+ cdylibs compiled).
463+
464+ What is left is each Worker's own crate: a `cdylib` is linked, and
465+ sccache does not cache what rustc links (binaries, cdylibs, proc
466+ macros, build scripts, test harnesses). That compile is real work
467+ anyway: a unit deploys because it, or a crate it uses, changed. The
468+ same holds for CI's `cargo test`: the workspace's libraries come back
469+ from the cache, the test harnesses are compiled. Every Rust job of
470+ Deploy and CI's Rust job end with sccache's hits and misses in the
471+ run's summary. If the cache cannot be reached, or the download fails
472+ its checksum, the job warns and builds as before. On g1t, sccache
473+ speaks the toolkit cache's older protocol (`GITHUB_SERVER_URL` is not
474+ github.com): a lookup, a reservation, one `PATCH` of the whole entry
475+ (`Content-Range: bytes 0-N/*`) and a commit per miss; a lookup and a
476+ blob `GET` per hit. Its storage check saves `sccache/.sccache_check`
477+ once, and takes the `409` on later runs as "already there".
478+- **Not restoring mtimes:** setting each file's mtime from git (so Cargo
479+ would trust the restored `target/`) was considered and left out. A
480+ restored `target/` may come from another commit (the cache's
481+ `restore-keys` take the nearest earlier entry, of any group), and a
482+ file changed by an older commit than that build would look unchanged:
483+ a stale crate deployed. sccache looks at what is compiled, not when.
436484 - **Only what changed** is the largest saving: a change to one service
437− deploys one service.
485+ deploys one service, and a change only to a crate's tests deploys
486+ nothing.
438487
439488 ## The workflow
440489
463512 be rebuilt alone (`image: true` in the matrix, also on `g1t-4core`, where
464513 it builds and pushes the image with the job's own Docker Engine). `fail-fast: false`, so one failed job does not cut
465514 another off mid-upload; the next stage then does not start.
466−- **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by
467− the merge queue's checks. `check` runs the deploy tool's own tests. When a
468− CI workflow is added, make `migrate` and the stages wait for it (`workflow_run`, or a job in
469− this file).
515+- **Tests:** `.g1t/workflows/ci.yml` tests every pull request into `main`
516+ (and, on pushes to `main`, runs only its Rust job, to keep the caches
517+ pull requests restore from current). `check` runs the deploy tool's own
518+ tests. Deploy does not wait for CI.
470519 - **Machines:** Rust jobs and the runner's image run on `g1t-4core` (4 vCPUs,
471520 12 GiB, 20 GB), the others on the standard machine
472521 (`runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}`).
477526 downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's
478527 release dependencies (`target/release` and
479528 `target/wasm32-unknown-unknown/release`, without `incremental` or
480− `.wasm`), keyed by the build group, `Cargo.lock` and `base.json`. The
481− workspace's own crates are compiled again on every run (a checkout's
482− sources are newer than any cache); the crates.io dependencies are not.
483− npm's cache is not kept: every job runs `npm ci` of only what its units
484− need (`deploy.mjs install`: Wrangler alone for Rust jobs).
529+ `.wasm`), keyed by the build group, `Cargo.lock` and `base.json`. Cargo
530+ calls rustc again for the workspace's own crates on every run (a
531+ checkout's sources are newer than any cache); **sccache** answers those
532+ calls from the repository's Actions cache when a crate's inputs did not
533+ change (see [Build speed](#build-speed)). npm's cache is not kept: every
534+ job runs `npm ci` of only what its units need (`deploy.mjs install`:
535+ Wrangler alone for Rust jobs).
485536 - **Conditions:** each stage runs with `!failure() && !cancelled()`, which
486537 on g1t (as on GitHub) is true when no job before it failed, however far
487538 back: a `migrate` job skipped for having nothing to apply does not stop
511562 miss. The image job adds `x86_64-unknown-linux-musl` (about 30 MB from
512563 `static.rust-lang.org`) and keeps its Cargo target in the cache. worker-build
513564 fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from
514−npm. All of those hosts are on the list every workflow job may reach.
565+npm. Each Rust job downloads sccache (about 10 MB) from its GitHub release
566+too (`scripts/sccache.sh`, which checks its sha256); it is not in the base
567+image, and putting it there means a pinned download in the base's
568+Dockerfile and a base rebuild (`build-base`). All of those hosts are on
569+the list every workflow job may reach.
515570
516571 ### Network
517572
+91−1
3939 versionFrom,
4040 } from "./cloudflare.mjs";
4141 import { changedNames, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
42−import { decide, planJson, pool } from "./plan.mjs";
42+import { decide, git, planJson, pool } from "./plan.mjs";
4343 import {
4444 ROOT,
4545 buildGroups,
5353 problems,
5454 resolveStack,
5555 resolvedStack,
56+ testOnlySource,
5657 touches,
5758 touchesBase,
5859 touchesImage,
328329 assert.ok(!ids(stack.units, ["packages/contracts/src/index.ts"]).includes("events"));
329330 });
330331
332+test("a crate's tests, benches and examples deploy nothing", () => {
333+ // 0cdd221 changed only this file, and the plan sent three Rust Workers.
334+ assert.deepEqual(ids(stack.units, ["crates/actions/tests/repository_workflows.rs"]), []);
335+ assert.ok(!touchesImage(unit("runner"), ["crates/actions/tests/repository_workflows.rs"]));
336+ assert.deepEqual(ids(stack.units, ["crates/kit/benches/wire.rs", "crates/scan/examples/scan.rs", "services/repos/tests/git.rs"]), []);
337+ // Their sources still do, and a folder merely named like one.
338+ assert.deepEqual(ids(stack.units, ["crates/actions/src/expr.rs"]), ["actions", "security", "runner"]);
339+ assert.ok(touchesImage(unit("runner"), ["crates/actions/src/expr.rs"]));
340+ assert.deepEqual(ids(stack.units, ["services/repos/src/tests/helpers.rs"]), ["repos"]);
341+ assert.deepEqual(ids(stack.units, ["crates/kit/testsuite/a.rs"]), ids(stack.units, ["crates/kit/src/lib.rs"]));
342+ // A unit that is not a crate keeps its whole folder.
343+ assert.deepEqual(unit("web").crateDirs, []);
344+ assert.deepEqual(unit("events").crateDirs, ["crates/contracts", "crates/kit", "services/events"]);
345+ assert.ok(unit("runner").crateDirs.includes("crates/runner"));
346+});
347+
348+/** Rust sources by path, as `testOnlySource` reads them. */
349+const sources = (files) => ({
350+ read: (path) => files[path] ?? "",
351+ list: (dir) => Object.keys(files).filter((path) => path.slice(0, path.lastIndexOf("/")) === dir),
352+});
353+
354+test("a Rust file compiled only for tests is found from what declares it", () => {
355+ const crate = ["crates/x"];
356+ const tree = sources({
357+ "crates/x/src/lib.rs": "pub mod api;\nmod store;\n#[cfg(test)]\nmod tests;\n#[cfg(test)] pub(crate) mod fixtures;\n#[cfg(any(test, feature = \"x\"))]\nmod both;\n",
358+ "crates/x/src/api.rs": "mod wire;\n#[cfg(test)]\n#[path = \"api_tests.rs\"]\nmod tests;\n",
359+ "crates/x/src/api/wire.rs": "",
360+ "crates/x/src/api_tests.rs": "use super::*;",
361+ "crates/x/src/store/mod.rs": "#[cfg(test)]\nmod memory;\n",
362+ "crates/x/src/store/memory.rs": "mod deep;",
363+ "crates/x/src/store/memory/deep.rs": "",
364+ "crates/x/src/tests.rs": "",
365+ "crates/x/src/fixtures/mod.rs": "",
366+ "crates/x/src/both.rs": "",
367+ "crates/x/src/bin/tool.rs": "",
368+ });
369+ const only = (file) => testOnlySource(file, crate, tree);
370+ for (const file of ["crates/x/src/tests.rs", "crates/x/src/fixtures/mod.rs", "crates/x/src/api_tests.rs", "crates/x/src/store/memory.rs", "crates/x/src/store/memory/deep.rs"]) {
371+ assert.ok(only(file), file);
372+ }
373+ // Built: roots, ordinary modules, a cfg that is not only test, a file
374+ // nothing declares (new, or deleted), and files outside src or the crate.
375+ for (const file of [
376+ "crates/x/src/lib.rs",
377+ "crates/x/src/api.rs",
378+ "crates/x/src/api/wire.rs",
379+ "crates/x/src/store/mod.rs",
380+ "crates/x/src/both.rs",
381+ "crates/x/src/bin/tool.rs",
382+ "crates/x/src/new.rs",
383+ "crates/x/build.rs",
384+ "crates/y/src/tests.rs",
385+ "crates/x/src/notes.md",
386+ ]) {
387+ assert.ok(!only(file), file);
388+ }
389+});
390+
391+test("this repository's test-only sources are read from git", () => {
392+ const head = git.head();
393+ const atHead = { read: (path) => git.show(head, path), list: (dir) => git.list(head, dir) };
394+ const only = (u, file) => testOnlySource(file, unit(u).crateDirs, atHead);
395+ assert.ok(only("api", "apps/api/src/responses.rs"));
396+ assert.ok(only("billing", "services/billing/src/catalogue_tests.rs"));
397+ assert.ok(only("billing", "services/billing/src/gateway_tests.rs"));
398+ assert.ok(!only("billing", "services/billing/src/catalogue.rs"));
399+ assert.ok(!only("api", "apps/api/src/toolkit.rs"));
400+});
401+
402+test("decide: a change only to tests deploys nothing", () => {
403+ const units = ["actions", "billing", "runner"].map(unit);
404+ const live = { actions: { sha: OLD }, billing: { sha: OLD }, runner: { sha: OLD } };
405+ const files = {
406+ [`${HEAD}:services/billing/src/gateway.rs`]: "#[cfg(test)]\n#[path = \"gateway_tests.rs\"]\nmod tests;\n",
407+ [`${HEAD}:services/billing/src/lib.rs`]: "mod gateway;\n",
408+ };
409+ const gitApi = {
410+ ...fakeGit(["crates/actions/tests/repository_workflows.rs", "services/billing/src/gateway_tests.rs"]),
411+ show: (sha, path) => files[`${sha}:${path}`] ?? "",
412+ list: (sha, dir) => Object.keys(files).map((k) => k.slice(sha.length + 1)).filter((p) => p.startsWith(`${dir}/`)).concat(dir === "services/billing/src" ? ["services/billing/src/gateway_tests.rs"] : []),
413+ };
414+ const decisions = decide(units, { live, head: HEAD, gitApi });
415+ assert.deepEqual(decisions.map((d) => [d.unit.id, d.deploy, d.image]), [["actions", false, false], ["billing", false, false], ["runner", false, false]]);
416+ // With a source that ships beside them, only that is listed.
417+ const mixed = decide([unit("billing")], { live, head: HEAD, gitApi: { ...gitApi, changed: () => ["services/billing/src/gateway_tests.rs", "services/billing/src/gateway.rs"] } });
418+ assert.deepEqual([mixed[0].deploy, mixed[0].files], [true, ["services/billing/src/gateway.rs"]]);
419+});
420+
331421 test("own folders, declared inputs and root files", () => {
332422 assert.deepEqual(ids(stack.units, ["services/pages/src/index.ts"]), ["pages"]);
333423 assert.deepEqual(ids(stack.units, ["apps/web/app/lib/roadmap.ts"]), ["og", "web"]);
+31−2
55 import { execFileSync } from "node:child_process";
66
77 import { changedNames, lockRoots, parseCargoLock, parseNpmLock, reaches } from "./lockfiles.mjs";
8−import { ROOT, byStage, buildGroups, codeStages, touches, touchesImage } from "./stack.mjs";
8+import { ROOT, byStage, buildGroups, codeStages, testOnlySource, touches, touchesImage } from "./stack.mjs";
99
1010 /** Git, read-only. */
1111 export const git = {
2121 return false;
2222 }
2323 },
24− /** Files changed between two commits. */
2524 /** A file's text at a commit, or "" if it is not there. */
2625 show: (sha, path) => {
2726 try {
3029 return "";
3130 }
3231 },
32+ /** The files directly in a folder at a commit (repository-relative). */
33+ list: (sha, dir) => {
34+ try {
35+ return run(["ls-tree", "--name-only", sha, "--", `${dir}/`]).split("\n").filter(Boolean);
36+ } catch {
37+ return [];
38+ }
39+ },
40+ /** Files changed between two commits. */
3341 changed: (from, to) => run(["diff", "--name-only", "--no-renames", from, to]).split("\n").filter(Boolean),
3442 /** Whether `older` is in `newer`'s history (and not the same commit). */
3543 isAncestor: (older, newer) => {
8290 }
8391 return locks.get(key);
8492 };
93+ // A Rust source compiled only for tests (`#[cfg(test)] mod tests;`) is
94+ // not in what deploys. Read at the commit being deployed, each file once.
95+ const texts = new Map();
96+ const listings = new Map();
97+ const atHead = {
98+ read: (path) => {
99+ if (!texts.has(path)) texts.set(path, gitApi.show(head, path));
100+ return texts.get(path);
101+ },
102+ list: (dir) => {
103+ if (!listings.has(dir)) listings.set(dir, gitApi.list?.(head, dir) ?? []);
104+ return listings.get(dir);
105+ },
106+ };
107+ const testOnly = new Map();
108+ const onlyForTests = (unit, file) => {
109+ if (!file.endsWith(".rs") || !unit.crateDirs?.some((dir) => file.startsWith(`${dir}/src/`))) return false;
110+ if (!testOnly.has(file)) testOnly.set(file, testOnlySource(file, unit.crateDirs, atHead));
111+ return testOnly.get(file);
112+ };
85113 const relevant = (unit, sha, files) =>
86114 files.filter((file) => {
115+ if (onlyForTests(unit, file)) return false;
87116 if (file !== "Cargo.lock" && file !== "package-lock.json") return true;
88117 const { after, names } = lockChange(sha, file);
89118 const roots = lockRoots(unit)[file === "Cargo.lock" ? "cargo" : "npm"];
+89−1
173173 unit.crate = crate;
174174 unit.pkg = pkg;
175175 unit.dependsOn = [...dirs].sort();
176+ // The Rust crates it is built from, whose tests, benches and examples
177+ // are not.
178+ unit.crateDirs = crate ? [unit.path, ...closure(cargo, crate).map((name) => cargo.get(name).dir)].sort() : [];
176179 unit.inputs = [...new Set([...(unit.inputs ?? []), ...globalInputs(unit.kind)])].sort();
177180 if (unit.image) {
178181 const name = unit.image.crate;
189192 };
190193 for (const dir of unit.image.dirs) if (dir !== unit.path) unit.dependsOn.push(dir);
191194 unit.dependsOn = [...new Set(unit.dependsOn)].sort();
195+ unit.crateDirs = [...new Set([...unit.crateDirs, ...unit.image.dirs])].sort();
192196 unit.inputs = [...new Set([...unit.inputs, "Cargo.toml", "Cargo.lock", "scripts/build-runner.mjs"])].sort();
193197 }
194198 }
202206
203207 const under = (file, dir) => file === dir || file.startsWith(`${dir}/`);
204208
209+/** A Rust crate's folders that its library and binaries are never built from. */
210+export const CRATE_TEST_DIRS = ["tests", "benches", "examples"];
211+
212+/**
213+ * Whether `file` is in the tests, benches or examples of one of
214+ * `crateDirs`: Cargo builds those only for `cargo test`, `cargo bench` and
215+ * `--example`, never into what deploys.
216+ */
217+export function inCrateTests(file, crateDirs = []) {
218+ return crateDirs.some((dir) => CRATE_TEST_DIRS.some((sub) => under(file, `${dir}/${sub}`)));
219+}
220+
221+const dirOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf("/")));
222+const baseOf = (path) => path.slice(path.lastIndexOf("/") + 1);
223+
224+/**
225+ * The `mod name;` declarations in Rust source `text` that `match` (by name,
226+ * or by a `#[path]`), each with whether it is under `#[cfg(test)]`.
227+ * Only outline modules (`mod x;`); the attributes are those directly above.
228+ */
229+function declarations(text, match) {
230+ const found = [];
231+ const pattern = /((?:#\[[^\]]*\]\s*)*)(?:pub(?:\([^)]*\))?\s+)?mod\s+(r#)?(\w+)\s*;/g;
232+ for (const [, attrs, , name] of text.matchAll(pattern)) {
233+ const path = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/.exec(attrs)?.[1] ?? null;
234+ if (!match(name, path)) continue;
235+ found.push(/#\[\s*cfg\s*\(\s*test\s*\)\s*\]/.test(attrs));
236+ }
237+ return found;
238+}
239+
240+/**
241+ * Whether Rust source `file`, in the `src/` of one of `crateDirs`, is
242+ * compiled only for tests: every module declaration of it found is under
243+ * `#[cfg(test)]`, or is in a file that is itself only for tests. A file
244+ * nothing is found to declare (a new crate root, a deleted file, a module
245+ * declared some way this does not read) counts as built, so a change to it
246+ * deploys.
247+ *
248+ * read(path): a file's text at the commit being deployed, "" if absent
249+ * list(dir): the files directly in a folder at that commit
250+ */
251+export function testOnlySource(file, crateDirs, { read, list = () => [] }, depth = 0) {
252+ if (!file.endsWith(".rs") || depth > 16) return false;
253+ const crateDir = crateDirs.find((dir) => file.startsWith(`${dir}/src/`));
254+ if (!crateDir) return false;
255+ const src = `${crateDir}/src`;
256+ const dir = dirOf(file);
257+ const base = baseOf(file).slice(0, -".rs".length);
258+ // Crate roots and binaries are not declared by anything.
259+ if (dir === src && ["lib", "main"].includes(base)) return false;
260+ if (dir === `${src}/bin` || (base === "main" && dirOf(dir) === `${src}/bin`)) return false;
261+ // Where `mod name;` for this file would be: `a/name.rs` and `a/name/mod.rs`
262+ // are declared by `a.rs` or `a/mod.rs` (by `lib.rs` or `main.rs` in src).
263+ const name = base === "mod" ? baseOf(dir) : base;
264+ const parentDir = base === "mod" ? dirOf(dir) : dir;
265+ const parents =
266+ parentDir === src ? [`${src}/lib.rs`, `${src}/main.rs`] : [`${parentDir}/mod.rs`, `${dirOf(parentDir)}/${baseOf(parentDir)}.rs`];
267+ // One declaration that is built is enough to stop: most changed files
268+ // are ordinary modules, settled by reading their parent.
269+ let declared = false;
270+ const testOnly = (declarer, isTest) => {
271+ declared = true;
272+ return isTest || testOnlySource(declarer, crateDirs, { read, list }, depth + 1);
273+ };
274+ for (const parent of parents) {
275+ for (const isTest of declarations(read(parent), (found, path) => found === name && path === null)) {
276+ if (!testOnly(parent, isTest)) return false;
277+ }
278+ }
279+ // `#[path = "x.rs"] mod y;` in a file beside it names it directly.
280+ const fileName = baseOf(file);
281+ for (const sibling of list(dir).filter((path) => path.endsWith(".rs") && path !== file)) {
282+ for (const isTest of declarations(read(sibling), (_, path) => path === fileName || path === `./${fileName}`)) {
283+ if (!testOnly(sibling, isTest)) return false;
284+ }
285+ }
286+ return declared;
287+}
288+
205289 /**
206290 * Why a set of changed files (repository-relative, `/`-separated) touches
207291 * a unit: the first file that does, and through what. Null if none does.
292+ * Its crates' tests, benches and examples do not.
208293 */
209294 export function touches(unit, files) {
295+ files = files.filter((file) => !inCrateTests(file, unit.crateDirs));
210296 for (const file of files) {
211297 if (under(file, unit.path)) return { file, via: "its own folder" };
212298 }
221307 /** Whether changed files touch a unit's Containers image. */
222308 export function touchesImage(unit, files) {
223309 if (!unit.image) return false;
224− return files.some((file) => unit.image.files.includes(file) || unit.image.dirs.some((dir) => under(file, dir)));
310+ return files.some(
311+ (file) => unit.image.files.includes(file) || (unit.image.dirs.some((dir) => under(file, dir)) && !inCrateTests(file, unit.image.dirs)),
312+ );
225313 }
226314
227315 /** Whether changed files touch the folder a unit's base image is built from. */
+109−0
1+#!/usr/bin/env bash
2+# sccache for a workflow's Rust builds. Every rustc call that makes a
3+# library is looked up by what it compiles (sources, flags, toolchain,
4+# dependencies) in the repository's Actions cache, the one `actions/cache`
5+# uses, through sccache's GitHub Actions backend. A checkout gives every
6+# source file a new mtime, so Cargo calls rustc again for the workspace's
7+# own crates however much of `target/` was restored; with sccache, the
8+# crates whose inputs did not change come back from the cache instead of
9+# being compiled. Binaries, cdylibs (a Worker's own crate), proc macros and
10+# test harnesses are linked, and sccache compiles those as before.
11+#
12+# bash scripts/sccache.sh install # a step before the first cargo
13+# bash scripts/sccache.sh stats # the last step, with if: always()
14+#
15+# install downloads the pinned release and checks its sha256, starts the
16+# server (which reads and writes a check entry in the cache), and only then
17+# sets RUSTC_WRAPPER for the steps after it. If anything there fails, the
18+# job builds as it did without sccache, and the step says why.
19+# stats prints the server's hits and misses, and adds them to the job's
20+# summary.
21+#
22+# See docs/DEPLOYING.md ("Build speed") and the Actions guide's
23+# "Caching Rust builds".
24+set -euo pipefail
25+
26+VERSION=0.18.0
27+TARGET=x86_64-unknown-linux-musl
28+# sccache-v0.18.0-x86_64-unknown-linux-musl.tar.gz, from the release's
29+# .sha256 file.
30+SHA256=45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89
31+
32+BIN="$HOME/.cargo/bin"
33+SCCACHE="$BIN/sccache"
34+
35+# What sccache and Cargo read, here and in the steps after this one.
36+export SCCACHE_GHA_ENABLED=true
37+# One server for the whole job: its statistics are the job's.
38+export SCCACHE_IDLE_TIMEOUT=0
39+# If the server goes away mid-build, rustc runs without it.
40+export SCCACHE_IGNORE_SERVER_IO_ERROR=1
41+# sccache cannot cache incremental builds; a fresh checkout gains nothing
42+# from them anyway.
43+export CARGO_INCREMENTAL=0
44+
45+warn() { echo "::warning title=sccache::$1, so this job builds without it"; }
46+
47+fetch() {
48+ local name="sccache-v${VERSION}-${TARGET}"
49+ local dir="${RUNNER_TEMP:-/tmp}/sccache-${VERSION}"
50+ if [ -x "$SCCACHE" ] && [ "$("$SCCACHE" --version 2>/dev/null | awk '{print $2}')" = "$VERSION" ]; then
51+ return 0
52+ fi
53+ mkdir -p "$dir" "$BIN"
54+ curl -fsSL --retry 3 -o "$dir/$name.tar.gz" "https://github.com/mozilla/sccache/releases/download/v${VERSION}/${name}.tar.gz" || return 1
55+ echo "${SHA256} $dir/$name.tar.gz" | sha256sum -c --quiet - || return 1
56+ tar -xzf "$dir/$name.tar.gz" -C "$dir" || return 1
57+ install -m 0755 "$dir/$name/sccache" "$SCCACHE"
58+}
59+
60+install_sccache() {
61+ if [ -z "${ACTIONS_RUNTIME_TOKEN:-}" ] || [ -z "${ACTIONS_CACHE_URL:-}${ACTIONS_RESULTS_URL:-}" ]; then
62+ warn "The job has no Actions cache to use (ACTIONS_RUNTIME_TOKEN, ACTIONS_CACHE_URL)"
63+ return 0
64+ fi
65+ if ! fetch; then
66+ warn "sccache ${VERSION} could not be downloaded or did not match its checksum"
67+ return 0
68+ fi
69+ if ! "$SCCACHE" --start-server; then
70+ warn "sccache's server did not start (it could not read the cache)"
71+ return 0
72+ fi
73+ {
74+ echo "RUSTC_WRAPPER=$SCCACHE"
75+ echo "SCCACHE_GHA_ENABLED=$SCCACHE_GHA_ENABLED"
76+ echo "SCCACHE_IDLE_TIMEOUT=$SCCACHE_IDLE_TIMEOUT"
77+ echo "SCCACHE_IGNORE_SERVER_IO_ERROR=$SCCACHE_IGNORE_SERVER_IO_ERROR"
78+ echo "CARGO_INCREMENTAL=$CARGO_INCREMENTAL"
79+ } >> "$GITHUB_ENV"
80+ echo "sccache ${VERSION}: rustc goes through it from here on, cached in this repository's Actions cache."
81+}
82+
83+stats() {
84+ if [ -z "${RUSTC_WRAPPER:-}" ] || [ ! -x "$SCCACHE" ]; then
85+ echo "sccache was not used in this job."
86+ return 0
87+ fi
88+ local out
89+ out="$("$SCCACHE" --show-stats 2>&1)" || true
90+ echo "$out"
91+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
92+ {
93+ echo "### sccache"
94+ echo
95+ echo '```'
96+ echo "$out"
97+ echo '```'
98+ } >> "$GITHUB_STEP_SUMMARY"
99+ fi
100+}
101+
102+case "${1:-}" in
103+ install) install_sccache ;;
104+ stats) stats ;;
105+ *)
106+ echo "usage: bash scripts/sccache.sh install|stats" >&2
107+ exit 2
108+ ;;
109+esac
+33−0
8686 out
8787 }
8888
89+/// Whether a repository holding `total` bytes must evict to stay within
90+/// `quota`: what `to_evict` would take something from.
91+pub(crate) fn over_quota(total: u64, quota: u64) -> bool {
92+ total > quota
93+}
94+
8995 /// A month's GB-months from the bytes held each day so far: each day's
9096 /// bytes over 30 days.
9197 pub(crate) fn gb_months(days: &[u64]) -> f64 {
328334 if ready.is_none() {
329335 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
330336 }
337+ // What the repository holds, summed: only past the quota are its
338+ // entries listed to choose what to evict. A tool that saves an
339+ // entry per compiled file (sccache) commits thousands of small
340+ // entries a build, and listing them all on each was quadratic.
331341 #[derive(Deserialize)]
342+ struct Total {
343+ bytes: Option<f64>,
344+ }
345+ let total = self
346+ .db
347+ .prepare("SELECT SUM(size) AS bytes FROM cache_entries WHERE repo_id = ? AND status = 'ready'")
348+ .bind(&[job.repo_id.as_str().into()])?
349+ .first::<Total>(None)
350+ .await?
351+ .and_then(|t| t.bytes)
352+ .unwrap_or(0.0);
353+ if !over_quota(total as u64, CACHE_REPO_QUOTA_BYTES) {
354+ return Ok(Outcome::Ok(CacheCommitted { evicted: Vec::new() }));
355+ }
356+ #[derive(Deserialize)]
332357 struct Held {
333358 id: String,
334359 object: String,
510535 assert_eq!(to_evict(&held, "new", 7), ["old", "c"]);
511536 // The entry just saved stays, even when it alone is past the quota.
512537 assert_eq!(to_evict(&entries(&[("big", 20), ("a", 1)]), "big", 10), ["a"]);
538+ // Entries are listed only when the sum is over: at or under the
539+ // quota, nothing would be evicted.
540+ for (held, quota) in [(entries(&[("a", 4), ("b", 6)]), 10), (entries(&[("a", 1)]), 12)] {
541+ let total = held.iter().map(|(_, size)| size).sum();
542+ assert!(!over_quota(total, quota));
543+ assert!(to_evict(&held, "a", quota).is_empty());
544+ }
545+ assert!(over_quota(11, 10));
513546 }
514547
515548 #[test]