Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge branch 'worktree-agent-a3b9b1f730f55b5bf'

syntaqxcommitted Parentsc10ee4b7c6cd10Browse files
32 files+556−1016 files not counted0/32 viewed
+48−0
1515 # desktop-linux the desktop release's Linux build
1616 # desktop-windows its Windows cross build, cargo-xwin, the MSVC CRT and
1717 # apt's downloads for it
18+# cli the CLI release's build of every platform, in the
19+# cargo-zigbuild image
1820 #
1921 # A job runs only when its key is missing (a new lockfile, a new toolchain,
2022 # or an expired entry), and builds what the release builds. Nothing waits for
5153 runner: ${{ steps.missing.outputs.runner }}
5254 desktop-linux: ${{ steps.desktop-linux.outputs.cache-hit != 'true' }}
5355 desktop-windows: ${{ steps.desktop-windows.outputs.cache-hit != 'true' || steps.xwin.outputs.cache-hit != 'true' || steps.apt-windows.outputs.cache-hit != 'true' }}
56+ cli: ${{ steps.cli.outputs.cache-hit != 'true' }}
5457 steps:
5558 - uses: actions/checkout@v5
5659 - name: Cache keys
135138 path: ~/.cache/apt/tauri-windows
136139 key: apt-tauri-windows-${{ runner.os }}-${{ hashFiles('scripts/apt-install.sh') }}
137140 lookup-only: true
141+ - id: cli
142+ uses: actions/cache/restore@v4
143+ with:
144+ path: |
145+ target/zig
146+ !target/zig/**/incremental
147+ key: cli-release-${{ runner.os }}-${{ steps.keys.outputs.zigbuild }}-${{ steps.keys.outputs.cargo }}
148+ lookup-only: true
138149 # The runner's targets to build: those whose build is missing, or all
139150 # of them when zig is (each saves it, the first to finish wins).
140151 - name: The runner's targets to build
352363 # What the CRT and SDK came to, for the cache's 2 GB an entry may hold.
353364 - name: How large the MSVC CRT is
354365 run: du -sh ~/.cache/cargo-xwin || true
366+
367+ # cli-release.yml's build of every platform, in the cargo-zigbuild image
368+ # (scripts/cli-release.mjs). Built whole whenever its key is new (a new
369+ # image or new dependencies), never started from an older entry: what
370+ # zigbuild built cannot be listed by this machine's Cargo to prune it, so
371+ # nothing old is carried in to begin with.
372+ cli:
373+ name: The CLI's release build
374+ needs: look
375+ if: ${{ needs.look.outputs.cli == 'true' }}
376+ # The builder runs in Docker, which g1t's own machines give each job.
377+ runs-on: g1t-4core
378+ timeout-minutes: 60
379+ steps:
380+ - uses: actions/checkout@v5
381+ - name: Cache keys
382+ id: keys
383+ run: node scripts/deploy/ci-cache.mjs keys
384+ - name: Cache crates
385+ uses: actions/cache/restore@v4
386+ with:
387+ path: |
388+ ~/.cargo/registry/index
389+ ~/.cargo/registry/cache
390+ key: cargo-crates-${{ runner.os }}-${{ steps.keys.outputs.cargo }}
391+ restore-keys: cargo-crates-${{ runner.os }}-
392+ - name: Cache the CLI's build
393+ uses: actions/cache@v4
394+ with:
395+ path: |
396+ target/zig
397+ !target/zig/**/incremental
398+ key: cli-release-${{ runner.os }}-${{ steps.keys.outputs.zigbuild }}-${{ steps.keys.outputs.cargo }}
399+ - name: Build
400+ run: node scripts/cli-release.mjs build
401+ - name: How large the build is
402+ run: du -sh target/zig || true
+33−4
88 # work, and its header says what a release holds. It is signed with the
99 # runner's release key (RUNNER_RELEASE_KEY, RUNNER_RELEASE_PUBLIC_KEY).
1010 #
11−# Every platform builds in one job, in the rust-cross/cargo-zigbuild image:
12−# the macOS builds link Apple's Security framework (the keychain), and that
13−# image carries its SDK. It runs on g1t's own machines (g1t-4core), whose
14−# jobs have a Docker Engine of their own.
11+# Every platform builds in one job, in the rust-cross/cargo-zigbuild image,
12+# pinned by digest in scripts/cli-release.mjs: the macOS builds link Apple's
13+# Security framework (the keychain), and that image carries its SDK. It
14+# runs on g1t's own machines (g1t-4core), whose jobs have a Docker Engine of
15+# their own.
1516 name: CLI release
1617
1718 on:
4243 run: |
4344 version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/g1t/Cargo.toml | head -1)"
4445 [ "cli-v$version" = "${GITHUB_REF_NAME}" ] || { echo "::error::The tag is ${GITHUB_REF_NAME}, but crates/g1t is $version"; exit 1; }
46+ # zigbuild: the builder image's digest, whose Rust the build uses;
47+ # cargo: Cargo.lock's crates.io and git packages, not the workspace's
48+ # own versions (scripts/deploy/ci-cache.mjs).
49+ - name: Cache keys
50+ id: keys
51+ run: node scripts/deploy/ci-cache.mjs keys
52+ # Every crate the lockfile names, as CI's Rust job saves them on main.
53+ # The builder reads them from this job's ~/.cargo/registry.
54+ - name: Cache crates
55+ uses: actions/cache/restore@v4
56+ with:
57+ path: |
58+ ~/.cargo/registry/index
59+ ~/.cargo/registry/cache
60+ key: cargo-crates-${{ runner.os }}-${{ steps.keys.outputs.cargo }}
61+ restore-keys: cargo-crates-${{ runner.os }}-
62+ # Every platform's build. A tag's run restores only from its own tag
63+ # and from main, so the Caches workflow (caches.yml) builds and saves
64+ # this on main whenever the key is new; a release compiles the CLI's
65+ # own crates again, and saves nothing.
66+ - name: Cache the CLI's build
67+ uses: actions/cache/restore@v4
68+ with:
69+ path: |
70+ target/zig
71+ !target/zig/**/incremental
72+ key: cli-release-${{ runner.os }}-${{ steps.keys.outputs.zigbuild }}-${{ steps.keys.outputs.cargo }}
73+ restore-keys: cli-release-${{ runner.os }}-${{ steps.keys.outputs.zigbuild }}-
4574 - name: Build
4675 run: node scripts/cli-release.mjs build
4776 - uses: actions/upload-artifact@v4
+11−0
189189 - **One set of paths per entry.** An entry's version is the hash of its
190190 paths, so every step that names it, in every workflow, names the same
191191 ones. `crates/actions/tests/repository_workflows.rs` holds all of this.
192+- **sccache only reads on a tag** (`scripts/sccache.sh` sets
193+ `SCCACHE_GHA_RW_MODE=READ_ONLY` when `GITHUB_REF_TYPE` is `tag`): a
194+ release's run finds what `main`'s runs compiled, and saves nothing only
195+ its own tag could read.
196+- **Images a build runs in are pinned by digest.** The CLI's release
197+ builds in `cargo-zigbuild` (`BUILDER` in `scripts/cli-release.mjs`), and
198+ its cache is keyed by that digest (`zigbuild` from `ci-cache.mjs keys`)
199+ rather than by this machine's `rustc`.
192200
193201 | Entry | Key | Saved by | Restored by |
194202 | --- | --- | --- | --- |
203211 | apt for Tauri | `apt-tauri-<os>-<scripts/apt-install.sh>` | CI desktop | the desktop release |
204212 | The runner release | `runner-release-<os>-<target>-<rust>-<deps>`, zig | Caches (`runner`) | the runner release |
205213 | The desktop release | `desktop-release-linux-…`, `desktop-release-windows-…`, `xwin-…`, `apt-tauri-windows-…` | Caches (`desktop-linux`, `desktop-windows`) | the desktop release |
214+| The CLI release | `cli-release-<os>-<zigbuild>-<deps>` (`target/zig`, every platform) | Caches (`cli`), built whole | the CLI release |
206215
207216 To see whether a change helped, read a run's cache steps: `Cache restored
208217 from key` names the entry (the key itself is a hit; another is a start),
209218 `Cache Size` and the step's time say what it cost, and the post step says
210219 whether it saved. `sccache's hits and misses` is on each Rust job's summary.
220+`g1t cache usage` says what the repository's entries hold against its
221+10 GiB, and `g1t cache list --sort size-in-bytes` which take the most.
211222
212223 ## Speed
213224
+209−0
1+//! A repository's `actions/cache` entries over REST and MCP, in GitHub's
2+//! shapes: listing them, what they hold together against the repository's
3+//! quota, and deleting them by key or by id.
4+//!
5+//! The actions service keeps them and decides who may see and delete them
6+//! (services/actions/src/cache.rs); their bytes are in R2, which jobs reach
7+//! through `/actions/jobs/{job}/cache` and the toolkit's protocols, never
8+//! through these.
9+
10+use g1t_contracts::actions::{CacheList, CacheUsage, CacheUsageArgs, CachesArgs, DeleteCachesArgs};
11+use g1t_contracts::{FailureCode, Outcome, Viewer};
12+use serde_json::{Value, json};
13+use worker::Result;
14+
15+use crate::operations::{Services, repo_path};
16+
17+/// One operation on the cache.
18+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19+pub enum CachesOp {
20+ ListCaches,
21+ GetCacheUsage,
22+ DeleteCachesByKey,
23+ DeleteCache,
24+}
25+
26+impl CachesOp {
27+ /// Every one: `Op::ALL` lists each as `Op::Caches(…)`, which a test
28+ /// checks against this.
29+ #[cfg(test)]
30+ pub const ALL: [CachesOp; 4] = [CachesOp::ListCaches, CachesOp::GetCacheUsage, CachesOp::DeleteCachesByKey, CachesOp::DeleteCache];
31+
32+ pub fn name(self) -> &'static str {
33+ match self {
34+ CachesOp::ListCaches => "list_caches",
35+ CachesOp::GetCacheUsage => "get_cache_usage",
36+ CachesOp::DeleteCachesByKey => "delete_caches_by_key",
37+ CachesOp::DeleteCache => "delete_cache",
38+ }
39+ }
40+
41+ /// For the API reference.
42+ pub fn title(self) -> &'static str {
43+ match self {
44+ CachesOp::ListCaches => "List a repository's caches",
45+ CachesOp::GetCacheUsage => "Get a repository's cache usage",
46+ CachesOp::DeleteCachesByKey => "Delete caches by key",
47+ CachesOp::DeleteCache => "Delete a cache by id",
48+ }
49+ }
50+
51+ pub fn description(self) -> &'static str {
52+ match self {
53+ CachesOp::ListCaches => "List the entries actions/cache has saved in a repository (total_count, actions_caches): each with its id (a number), key, ref (the ref whose run saved it; a pull request from outside saves under untrusted:<ref>), version (the hash of its paths; empty for one g1t's own actions/cache saved), size_in_bytes, created_at and last_accessed_at (its last restore or lookup). Narrow with key (entries whose key starts with it) and ref; order with sort (last_accessed_at, created_at or size_in_bytes; last_accessed_at by default) and direction (asc or desc; desc by default); page with page and per_page (30 by default, at most 100). Needs the Read role.",
54+ CachesOp::GetCacheUsage => "What a repository's saved cache entries hold together: active_caches_size_in_bytes and active_caches_count, and quota_bytes, the most they may hold (10 GiB). Saving past it evicts the entries restored longest ago. Needs the Read role.",
55+ CachesOp::DeleteCachesByKey => "Delete every cache entry whose key is exactly key, or only ref's when you give ref. Answers with the entries deleted, in list_caches' shape. Their bytes go at once, and a run that restores the key finds nothing until one saves it again. Needs the Write role.",
56+ CachesOp::DeleteCache => "Delete one cache entry by its id, as list_caches gives it. Over REST it answers 204 with no body, as GitHub does. Needs the Write role.",
57+ }
58+ }
59+
60+ /// Whether it changes anything: what its scope says, which a test holds
61+ /// it to.
62+ #[cfg(test)]
63+ pub fn writes(self) -> bool {
64+ matches!(self, CachesOp::DeleteCachesByKey | CachesOp::DeleteCache)
65+ }
66+
67+ pub fn input(self) -> Value {
68+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
69+ let git_ref = json!({ "type": "string", "description": "A full ref, e.g. refs/heads/main or refs/pull/7/merge." });
70+ let (properties, required): (Value, &[&str]) = match self {
71+ CachesOp::ListCaches => (
72+ json!({
73+ "repo": repo,
74+ "key": { "type": "string", "description": "Only entries whose key starts with this." },
75+ "ref": git_ref,
76+ "sort": { "type": "string", "enum": ["last_accessed_at", "created_at", "size_in_bytes"], "description": "What to order by: last_accessed_at unless you say." },
77+ "direction": { "type": "string", "enum": ["asc", "desc"], "description": "desc unless you say." },
78+ "page": { "type": "integer", "description": "The page, from 1." },
79+ "per_page": { "type": "integer", "description": "Entries a page: 30 unless you say, at most 100." },
80+ }),
81+ &["repo"],
82+ ),
83+ CachesOp::GetCacheUsage => (json!({ "repo": repo }), &["repo"]),
84+ CachesOp::DeleteCachesByKey => (
85+ json!({
86+ "repo": repo,
87+ "key": { "type": "string", "description": "The key, exactly." },
88+ "ref": git_ref,
89+ }),
90+ &["repo", "key"],
91+ ),
92+ CachesOp::DeleteCache => (
93+ json!({ "repo": repo, "id": { "type": ["integer", "string"], "description": "The entry's id, a number." } }),
94+ &["repo", "id"],
95+ ),
96+ };
97+ json!({ "type": "object", "properties": properties, "required": required })
98+ }
99+}
100+
101+/// A number from a path segment, a query value or a JSON number.
102+fn number(input: &Value, key: &str) -> Option<u64> {
103+ match &input[key] {
104+ Value::Number(n) => n.as_u64(),
105+ Value::String(s) => s.trim().parse().ok(),
106+ _ => None,
107+ }
108+}
109+
110+fn text(input: &Value, key: &str) -> Option<String> {
111+ input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
112+}
113+
114+fn shown<T: serde::Serialize>(outcome: Outcome<T>) -> Outcome<Value> {
115+ match outcome {
116+ Outcome::Ok(value) => Outcome::Ok(serde_json::to_value(value).unwrap_or(Value::Null)),
117+ Outcome::Fail(refused) => Outcome::Fail(refused),
118+ }
119+}
120+
121+pub async fn run(op: CachesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
122+ let Some(repo) = repo_path(input) else {
123+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
124+ };
125+ let actions = &services.actions;
126+ Ok(match op {
127+ CachesOp::ListCaches => {
128+ let args = CachesArgs {
129+ repo,
130+ viewer: viewer.clone(),
131+ key: text(input, "key"),
132+ git_ref: text(input, "ref"),
133+ sort: text(input, "sort"),
134+ direction: text(input, "direction"),
135+ page: number(input, "page").map(|n| n.min(u64::from(u32::MAX)) as u32),
136+ per_page: number(input, "per_page").map(|n| n.min(100) as u32),
137+ };
138+ let found: Outcome<CacheList> = g1t_kit::call(actions, "caches", &args).await?;
139+ shown(found)
140+ }
141+ CachesOp::GetCacheUsage => {
142+ let found: Outcome<CacheUsage> = g1t_kit::call(actions, "cache_usage", &CacheUsageArgs { repo: repo.clone(), viewer: viewer.clone() }).await?;
143+ match found {
144+ Outcome::Ok(usage) => Outcome::Ok(json!({
145+ "full_name": format!("{}/{}", repo.namespace, repo.name),
146+ "active_caches_size_in_bytes": usage.active_caches_size_in_bytes,
147+ "active_caches_count": usage.active_caches_count,
148+ "quota_bytes": usage.quota_bytes,
149+ })),
150+ Outcome::Fail(refused) => Outcome::Fail(refused),
151+ }
152+ }
153+ CachesOp::DeleteCachesByKey | CachesOp::DeleteCache => {
154+ let Some(actor) = viewer.clone() else {
155+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting cache entries needs a g1t access token."));
156+ };
157+ let args = if op == CachesOp::DeleteCache {
158+ let Some(id) = number(input, "id") else {
159+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the cache entry by its id, a number."));
160+ };
161+ DeleteCachesArgs { actor, repo, id: Some(id), key: None, git_ref: None }
162+ } else {
163+ let Some(key) = text(input, "key") else {
164+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the key of the entries to delete."));
165+ };
166+ DeleteCachesArgs { actor, repo, id: None, key: Some(key), git_ref: text(input, "ref") }
167+ };
168+ let done: Outcome<CacheList> = g1t_kit::call(actions, "delete_caches", &args).await?;
169+ shown(done)
170+ }
171+ })
172+}
173+
174+#[cfg(test)]
175+mod tests {
176+ use super::*;
177+ use g1t_contracts::actions::CacheEntry;
178+
179+ #[test]
180+ fn a_list_is_in_github_s_shape() {
181+ let list = CacheList {
182+ total_count: 1,
183+ actions_caches: vec![CacheEntry {
184+ id: 7,
185+ key: "npm-full-Linux-abc".into(),
186+ git_ref: "refs/heads/main".into(),
187+ version: String::new(),
188+ size_in_bytes: 1024,
189+ created_at: "2026-10-12T10:00:00.000Z".into(),
190+ last_accessed_at: "2026-10-12T11:00:00.000Z".into(),
191+ }],
192+ };
193+ let Outcome::Ok(sent) = shown(Outcome::Ok(list)) else { panic!() };
194+ assert_eq!(sent["actions_caches"][0]["ref"], "refs/heads/main");
195+ assert_eq!(sent["actions_caches"][0]["id"], 7);
196+ assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
197+ }
198+
199+ #[test]
200+ fn each_operation_is_described_with_a_schema() {
201+ for op in CachesOp::ALL {
202+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Caches(op)), "{}", op.name());
203+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
204+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
205+ let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level();
206+ assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name());
207+ }
208+ }
209+}
+1−0
66
77 mod about;
88 mod run_artifacts;
9+mod caches;
910 mod archives;
1011 mod addresses;
1112 mod alerts;
+7−1
99
1010 use crate::about::AboutOp;
1111 use crate::run_artifacts::ArtifactsOp;
12+use crate::caches::CachesOp;
1213 use crate::archives::ArchiveOp;
1314 use crate::deploy_keys::DeployKeysOp;
1415 use crate::deployments::DeploymentsOp;
414415 ),
415416 (
416417 "Actions",
417− "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.",
418+ "GitHub Actions workflows in .g1t/workflows, their runs, their jobs' logs, their artifacts, and the repository's actions/cache entries.",
418419 &[
419420 Op::ListWorkflows,
420421 Op::ListWorkflowRuns,
431432 Op::Artifacts(ArtifactsOp::DeleteArtifact),
432433 Op::Artifacts(ArtifactsOp::GetArtifactRetention),
433434 Op::Artifacts(ArtifactsOp::SetArtifactRetention),
435+ Op::Caches(CachesOp::ListCaches),
436+ Op::Caches(CachesOp::GetCacheUsage),
437+ Op::Caches(CachesOp::DeleteCachesByKey),
438+ Op::Caches(CachesOp::DeleteCache),
434439 ],
435440 ),
436441 (
797802 Op::Protection(op) => op.title(),
798803 Op::Tokens(op) => op.title(),
799804 Op::Artifacts(op) => op.title(),
805+ Op::Caches(op) => op.title(),
800806 Op::Archives(op) => op.title(),
801807 Op::DeployKeys(op) => op.title(),
802808 Op::Packages(op) => op.title(),
+12−1
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
3131 use crate::run_artifacts::ArtifactsOp;
32+use crate::caches::CachesOp;
3233 use crate::archives::ArchiveOp;
3334 use crate::deploy_keys::DeployKeysOp;
3435 use crate::deployments::DeploymentsOp;
311312 Tokens(TokenOp),
312313 /// Workflow run artifacts, and how long they are kept: run_artifacts.rs.
313314 Artifacts(ArtifactsOp),
315+ /// A repository's actions/cache entries: caches.rs.
316+ Caches(CachesOp),
314317 /// A branch, tag or commit as a tarball or zipball: archives.rs.
315318 Archives(ArchiveOp),
316319 /// A repository's deploy keys: deploy_keys.rs.
693696 }
694697
695698 impl Op {
696− pub const ALL: [Op; 367] = [
699+ pub const ALL: [Op; 371] = [
697700 Op::Whoami,
698701 Op::GetWorkspace,
699702 Op::CreateWorkspace,
986989 Op::Artifacts(ArtifactsOp::DeleteArtifact),
987990 Op::Artifacts(ArtifactsOp::GetArtifactRetention),
988991 Op::Artifacts(ArtifactsOp::SetArtifactRetention),
992+ Op::Caches(CachesOp::ListCaches),
993+ Op::Caches(CachesOp::GetCacheUsage),
994+ Op::Caches(CachesOp::DeleteCachesByKey),
995+ Op::Caches(CachesOp::DeleteCache),
989996 Op::Archives(ArchiveOp::DownloadTarball),
990997 Op::Archives(ArchiveOp::DownloadZipball),
991998 Op::DeployKeys(DeployKeysOp::ListDeployKeys),
12581265 Op::Protection(op) => op.name(),
12591266 Op::Tokens(op) => op.name(),
12601267 Op::Artifacts(op) => op.name(),
1268+ Op::Caches(op) => op.name(),
12611269 Op::Archives(op) => op.name(),
12621270 Op::DeployKeys(op) => op.name(),
12631271 Op::Packages(op) => op.name(),
17781786 Op::Protection(op) => op.description(),
17791787 Op::Tokens(op) => op.description(),
17801788 Op::Artifacts(op) => op.description(),
1789+ Op::Caches(op) => op.description(),
17811790 Op::Archives(op) => op.description(),
17821791 Op::DeployKeys(op) => op.description(),
17831792 Op::Packages(op) => op.description(),
31553164 Op::Protection(op) => op.input(),
31563165 Op::Tokens(op) => op.input(),
31573166 Op::Artifacts(op) => op.input(),
3167+ Op::Caches(op) => op.input(),
31583168 Op::Archives(op) => op.input(),
31593169 Op::DeployKeys(op) => op.input(),
31603170 Op::Packages(op) => op.input(),
53865396 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
53875397 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
53885398 Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await,
5399+ Op::Caches(op) => crate::caches::run(op, services, viewer, input).await,
53895400 Op::Archives(op) => crate::archives::run(op, services, viewer, input).await,
53905401 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
53915402 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
+81−0
1212412124 },
1212512125 "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more."
1212612126 },
12127+ "list_caches": {
12128+ "params": {
12129+ "owner": "flagon-io",
12130+ "name": "g1t"
12131+ },
12132+ "query": {
12133+ "key": "cargo-test-",
12134+ "sort": "size_in_bytes",
12135+ "per_page": "2"
12136+ },
12137+ "response": {
12138+ "total_count": 3,
12139+ "actions_caches": [
12140+ {
12141+ "id": 9184,
12142+ "key": "cargo-test-Linux-rustc-1.99.0-5f1c3a9e7b2d",
12143+ "ref": "refs/heads/main",
12144+ "version": "",
12145+ "size_in_bytes": 1687203840,
12146+ "created_at": "2026-10-12T09:14:02.511Z",
12147+ "last_accessed_at": "2026-10-12T15:40:19.087Z"
12148+ },
12149+ {
12150+ "id": 9102,
12151+ "key": "cargo-test-Linux-rustc-1.99.0-5f1c3a9e7b2d",
12152+ "ref": "refs/pull/412/merge",
12153+ "version": "",
12154+ "size_in_bytes": 702545920,
12155+ "created_at": "2026-10-12T11:02:44.930Z",
12156+ "last_accessed_at": "2026-10-12T11:02:44.930Z"
12157+ }
12158+ ]
12159+ },
12160+ "notes": "`key` matches keys that start with it. Entries past their 28 days, or unused for 7, are swept within the hour and are not listed once they are."
12161+ },
12162+ "get_cache_usage": {
12163+ "params": {
12164+ "owner": "flagon-io",
12165+ "name": "g1t"
12166+ },
12167+ "response": {
12168+ "full_name": "flagon-io/g1t",
12169+ "active_caches_size_in_bytes": 8813275136,
12170+ "active_caches_count": 41,
12171+ "quota_bytes": 10737418240
12172+ },
12173+ "notes": "Saving past `quota_bytes` evicts the entries restored longest ago."
12174+ },
12175+ "delete_caches_by_key": {
12176+ "params": {
12177+ "owner": "flagon-io",
12178+ "name": "g1t"
12179+ },
12180+ "query": {
12181+ "key": "cargo-test-Linux-rustc-1.99.0-5f1c3a9e7b2d",
12182+ "ref": "refs/pull/412/merge"
12183+ },
12184+ "response": {
12185+ "total_count": 1,
12186+ "actions_caches": [
12187+ {
12188+ "id": 9102,
12189+ "key": "cargo-test-Linux-rustc-1.99.0-5f1c3a9e7b2d",
12190+ "ref": "refs/pull/412/merge",
12191+ "version": "",
12192+ "size_in_bytes": 702545920,
12193+ "created_at": "2026-10-12T11:02:44.930Z",
12194+ "last_accessed_at": "2026-10-12T11:02:44.930Z"
12195+ }
12196+ ]
12197+ },
12198+ "notes": "The key is matched exactly. Without `ref`, the key's entries on every ref are deleted."
12199+ },
12200+ "delete_cache": {
12201+ "params": {
12202+ "owner": "flagon-io",
12203+ "name": "g1t",
12204+ "id": "9102"
12205+ },
12206+ "notes": "Answers `204` with no body."
12207+ },
1212712208 "list_packages": {
1212812209 "params": {
1212912210 "workspace": "acme"
+2−0
122122 Op::Telemetry(_) => return as_is,
123123 // Artifacts are shaped by the API itself, in `snake_case`.
124124 Op::Artifacts(_) => return as_is,
125+ // The cache's entries travel in `snake_case` from the actions service.
126+ Op::Caches(_) => return as_is,
125127 // So are archive links.
126128 Op::Archives(_) => return as_is,
127129 // Deploy keys travel in `snake_case` from identity.
+33−1
44
55 use crate::about::AboutOp;
66 use crate::run_artifacts::ArtifactsOp;
7+use crate::caches::CachesOp;
78 use crate::archives::ArchiveOp;
89 use crate::deploy_keys::DeployKeysOp;
910 use crate::deployments::DeploymentsOp;
861862 route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]),
862863 route(
863864 "GET",
865+ "/repos/:owner/:name/actions/caches",
866+ Op::Caches(CachesOp::ListCaches),
867+ &[("key", "key"), ("ref", "ref"), ("sort", "sort"), ("direction", "direction"), ("page", "page"), ("per_page", "per_page")],
868+ ),
869+ route("GET", "/repos/:owner/:name/actions/cache/usage", Op::Caches(CachesOp::GetCacheUsage), &[]),
870+ route("DELETE", "/repos/:owner/:name/actions/caches", Op::Caches(CachesOp::DeleteCachesByKey), &[("key", "key"), ("ref", "ref")]),
871+ route("DELETE", "/repos/:owner/:name/actions/caches/:id", Op::Caches(CachesOp::DeleteCache), &[]),
872+ route(
873+ "GET",
864874 "/repos/:owner/:name/actions/secrets",
865875 Op::ListActionsSecrets,
866876 &[],
10791089 pub fn no_content(&self) -> bool {
10801090 matches!(
10811091 self.op,
1082− Op::DeleteComment
1092+ Op::DeleteComment | Op::Caches(CachesOp::DeleteCache)
10831093 )
10841094 }
10851095
15381548 }
15391549
15401550 #[test]
1551+ fn caches_are_at_githubs_addresses() {
1552+ let query = [("key".to_owned(), "cargo-".to_owned()), ("ref".to_owned(), "refs/heads/main".to_owned()), ("sort".to_owned(), "size_in_bytes".to_owned())];
1553+ let (route, input) = resolve("GET", "/repos/acme/web/actions/caches", &query, Value::Null).unwrap();
1554+ assert_eq!(route.op, Op::Caches(CachesOp::ListCaches));
1555+ assert_eq!(input, json!({ "key": "cargo-", "ref": "refs/heads/main", "sort": "size_in_bytes", "repo": "acme/web" }));
1556+ let (route, _) = resolve("GET", "/repos/acme/web/actions/cache/usage", &[], Value::Null).unwrap();
1557+ assert_eq!(route.op, Op::Caches(CachesOp::GetCacheUsage));
1558+ // Deleting by key takes it from the query, as GitHub's does.
1559+ let query = [("key".to_owned(), "npm-full-Linux-abc".to_owned())];
1560+ let (route, input) = resolve("DELETE", "/repos/acme/web/actions/caches", &query, Value::Null).unwrap();
1561+ assert_eq!(route.op, Op::Caches(CachesOp::DeleteCachesByKey));
1562+ assert_eq!(input, json!({ "key": "npm-full-Linux-abc", "repo": "acme/web" }));
1563+ assert!(!route.no_content());
1564+ let (route, input) = resolve("DELETE", "/repos/acme/web/actions/caches/42", &[], Value::Null).unwrap();
1565+ assert_eq!(route.op, Op::Caches(CachesOp::DeleteCache));
1566+ assert_eq!(input, json!({ "id": "42", "repo": "acme/web" }));
1567+ assert!(route.no_content());
1568+ // Only someone signed in sees a repository's cache keys.
1569+ assert!(Op::Caches(CachesOp::ListCaches).needs_user());
1570+ }
1571+
1572+ #[test]
15411573 fn query_parameters_are_renamed() {
15421574 let query = [
15431575 ("q".to_owned(), "parser".to_owned()),
+6−1
2020
2121 use crate::about::AboutOp;
2222 use crate::run_artifacts::ArtifactsOp;
23+use crate::caches::CachesOp;
2324 use crate::archives::ArchiveOp;
2425 use crate::deploy_keys::DeployKeysOp;
2526 use crate::deployments::DeploymentsOp;
213214 Tool {
214215 name: "workflow",
215216 title: "Workflows",
216− description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions, repository dispatch, and the workflows a workspace runs in its repositories without a file in them. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), and their groups.",
217+ description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. The repository's actions/cache entries: listing them, what they hold against its 10 GiB, and deleting them. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions, repository dispatch, and the workflows a workspace runs in its repositories without a file in them. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), and their groups.",
217218 default_action: None,
218219 actions: &[
219220 a("list", Op::ListWorkflows, "Workflows on the default branch"),
231232 a("delete_artifact", Op::Artifacts(ArtifactsOp::DeleteArtifact), "Delete an artifact before it expires"),
232233 a("artifact_retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), "Days the repository keeps artifacts"),
233234 a("set_artifact_retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), "Change the days the repository keeps artifacts"),
235+ a("list_caches", Op::Caches(CachesOp::ListCaches), "The repository's actions/cache entries: by key prefix or ref, largest or least used first"),
236+ a("cache_usage", Op::Caches(CachesOp::GetCacheUsage), "What the repository's cache entries hold, against its 10 GiB"),
237+ a("delete_caches", Op::Caches(CachesOp::DeleteCachesByKey), "Delete the cache entries with a key, on every ref or one"),
238+ a("delete_cache", Op::Caches(CachesOp::DeleteCache), "Delete one cache entry by its id"),
234239 a("combined_status", Op::Checks(ChecksOp::GetCombinedStatus), "A commit's statuses and the state they add up to"),
235240 a("list_statuses", Op::Checks(ChecksOp::ListCommitStatuses), "A commit's statuses, newest first"),
236241 a("set_status", Op::Checks(ChecksOp::CreateCommitStatus), "Set a status on a commit"),
+34−0
825825 at R2's price plus the margin; see
826826 [usage and billing](/guides/usage-and-billing/#actions-cache).
827827
828+### Seeing and deleting entries
829+
830+To see what a repository's cache holds against its 10 GiB, and which
831+entries take the most:
832+
833+```sh
834+g1t cache usage
835+g1t cache list --sort size-in-bytes --limit 10
836+g1t cache list --key cargo- --ref refs/heads/main
837+```
838+
839+Each entry shows its id, key, the ref whose run saved it (a pull request
840+from outside the repository saves under `untrusted:<ref>`), its size, and
841+when it was saved and last restored. To delete one, or every entry with a
842+key (on one ref with `--ref`):
843+
844+```sh
845+g1t cache delete 9184
846+g1t cache delete cargo-test-Linux-1.99.0-5f1c3a9e7b2d --ref refs/pull/412/merge
847+```
848+
849+A deleted entry's bytes go at once, and a run that restores its key finds
850+nothing until one saves it again. Listing needs the Read role, deleting
851+the Write role.
852+
853+| `workflow` action | Route |
854+| --- | --- |
855+| `list_caches` | `GET /repos/{owner}/{repo}/actions/caches`, with `key` (a prefix), `ref`, `sort` (`last_accessed_at`, `created_at` or `size_in_bytes`), `direction`, `page`, `per_page` |
856+| `cache_usage` | `GET /repos/{owner}/{repo}/actions/cache/usage`: `active_caches_size_in_bytes`, `active_caches_count`, and `quota_bytes` |
857+| `delete_caches` | `DELETE /repos/{owner}/{repo}/actions/caches?key=…`, with `ref`: answers with the entries deleted |
858+| `delete_cache` | `DELETE /repos/{owner}/{repo}/actions/caches/{id}`: `204` |
859+
828860 ## Artifacts
829861
830862 `actions/upload-artifact` keeps files a job made with its run, for later
10221054 | What is compiled each time | What rustc links: binaries, `cdylib` crates, proc macros, build scripts and test harnesses. |
10231055 | 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. |
10241056 | 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. |
1057+| Releases from tags | A tag's run reads the default branch's entries, but what it saves only that tag would ever read. Set `SCCACHE_GHA_RW_MODE: READ_ONLY` on a tag's run (`if [ "$GITHUB_REF_TYPE" = tag ]`) so it saves nothing: it still looks every crate up. |
10251058 | Starting over | Set `SCCACHE_GHA_VERSION` to any new value. A new sccache release starts over by itself. |
10261059 | If the cache cannot be reached | sccache refuses to start. Set `SCCACHE_IGNORE_SERVER_IO_ERROR: "1"` to have rustc run without it instead. |
10271060
20122045 | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes |
20132046 | `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` |
20142047 | `artifact_retention`, `set_artifact_retention` | `GET` and `PUT …/actions/permissions/artifact-and-log-retention` with `days` (it sets artifacts' days only; logs are kept for 90 days) |
2048+| `list_caches`, `cache_usage`, `delete_caches`, `delete_cache` | `…/actions/caches` and `…/actions/cache/usage`; see [Seeing and deleting entries](#seeing-and-deleting-entries) |
20152049
20162050 Secrets and variables have a tool of their own, `secret`:
20172051
+47−0
674674
675675 Calls [`delete_actions_secret`](/reference/api/secrets-and-variables/delete-actions-secret/), [`delete_actions_secret_for_workspace`](/reference/api/secrets-and-variables/delete-actions-secret-for-workspace/).
676676
677+## g1t cache
678+
679+The repository's actions/cache entries: list, usage, delete.
680+
681+### g1t cache list
682+
683+List the repository's cache entries, least recently used last.
684+
685+```text
686+g1t cache list [OPTIONS]
687+```
688+
689+| Argument or flag | |
690+| --- | --- |
691+| `-k, --key <KEY>` | Only entries whose key starts with this. |
692+| `-g, --ref <REF>` | Only entries saved by this ref's runs, e.g. refs/heads/main. |
693+| `-S, --sort <SORT>` | One of `last-accessed-at`, `created-at`, `size-in-bytes`. Default: `last-accessed-at`. |
694+| `-O, --order <ORDER>` | One of `asc`, `desc`. Default: `desc`. |
695+| `-L, --limit <LIMIT>` | Default: `30`. |
696+
697+Calls [`list_caches`](/reference/api/actions/list-caches/).
698+
699+### g1t cache usage
700+
701+What the repository's cache entries hold, against its quota.
702+
703+```text
704+g1t cache usage [OPTIONS]
705+```
706+
707+Calls [`get_cache_usage`](/reference/api/actions/get-cache-usage/).
708+
709+### g1t cache delete
710+
711+Delete cache entries: one by its id, or every entry with a key.
712+
713+```text
714+g1t cache delete [OPTIONS] <ENTRY>
715+```
716+
717+| Argument or flag | |
718+| --- | --- |
719+| `<ENTRY>` | An entry's id (a number, from `g1t cache list`), or a key: every entry with exactly that key. |
720+| `-g, --ref <REF>` | With a key, only the entries this ref's runs saved. |
721+
722+Calls [`delete_cache`](/reference/api/actions/delete-cache/), [`delete_caches_by_key`](/reference/api/actions/delete-caches-by-key/).
723+
677724 ## g1t deploy
678725
679726 Deployments: list and view.
+4−0
432432 | [`delete_artifact`](/reference/api/actions/delete-artifact/) | Delete it before it expires. Write role. | `repo`, `id` | `workflows:write` |
433433 | [`artifact_retention`](/reference/api/actions/get-artifact-retention/) | The days the repository keeps artifacts, and the most it may. | `repo` | `workflows:read` |
434434 | [`set_artifact_retention`](/reference/api/actions/set-artifact-retention/) | Set those days, 1 to 90. Maintain role. | `repo`, `days` | `workflows:write` |
435+| [`list_caches`](/reference/api/actions/list-caches/) | The repository's `actions/cache` entries: id, key, ref, size, when saved and last restored; `key` (a prefix), `ref`, `sort` (`last_accessed_at`, `created_at`, `size_in_bytes`), `direction`, `page`, `per_page`. | `repo` | `workflows:read` |
436+| [`cache_usage`](/reference/api/actions/get-cache-usage/) | What the entries hold together, their count, and the repository's 10 GiB (`quota_bytes`). | `repo` | `workflows:read` |
437+| [`delete_caches`](/reference/api/actions/delete-caches-by-key/) | Delete the entries whose key is exactly `key`; only `ref`'s with `ref`. Write role. | `repo`, `key` | `workflows:write` |
438+| [`delete_cache`](/reference/api/actions/delete-cache/) | Delete one entry by its id. Write role. | `repo`, `id` | `workflows:write` |
435439 | [`combined_status`](/reference/api/checks/get-combined-status/) | A commit's statuses, one per context, and the `state` they add up to. `ref` is a SHA, branch or tag. | `repo`, `ref` | `checks:read` |
436440 | [`list_statuses`](/reference/api/checks/list-commit-statuses/) | A commit's statuses, newest first. | `repo`, `ref` | `checks:read` |
437441 | [`set_status`](/reference/api/checks/create-commit-status/) | Set a status: `state`, `context`, `description`, `target_url`. Write role. | `repo`, `sha`, `state` | `checks:write` |
+0−0

Binary or large file; its contents are not shown.

+28−2
344344 "apt-tauri-windows-",
345345 "xwin-",
346346 "zig-0.13.0-zigbuild-",
347+ "cli-release-",
347348 ] {
348349 assert!(seen.contains_key(family), "{family}: {:?}", seen.keys());
349350 }
403404 );
404405 let target = paths(&step).iter().any(|p| p.contains("target"));
405406 if target {
406− assert!(key.contains("steps.keys.outputs.rust"), "{file} {}: {key}", job.id);
407+ // The toolchain that built it: this machine's rustc, or for
408+ // the CLI's release, the cargo-zigbuild image it builds in.
409+ let toolchain = if paths(&step).iter().any(|p| p.starts_with("target/zig")) {
410+ "steps.keys.outputs.zigbuild"
411+ } else {
412+ "steps.keys.outputs.rust"
413+ };
414+ assert!(key.contains(toolchain), "{file} {}: {key}", job.id);
407415 // A restore key never reaches another toolchain's entries.
408416 for prefix in step.raw["with"]["restore-keys"]
409417 .as_str()
411419 .lines()
412420 .filter(|l| !l.trim().is_empty())
413421 {
414− assert!(prefix.contains("steps.keys.outputs.rust"), "{file} {}: {prefix}", job.id);
422+ assert!(prefix.contains(toolchain), "{file} {}: {prefix}", job.id);
415423 }
416424 }
417425 if key.contains("steps.keys.outputs.") {
463471 "{file} {id}"
464472 );
465473 }
474+ // The CLI's build is made in the builder image, whose Cargo the host
475+ // cannot ask what it built: so it is never started from an older entry,
476+ // and has nothing old to prune.
477+ let caches = read("caches.yml");
478+ let cli = caches.jobs.iter().find(|j| j.id == "cli").unwrap();
479+ let build = cli
480+ .steps
481+ .iter()
482+ .find(|s| s.raw["with"]["key"].as_str().is_some_and(|k| k.starts_with("cli-release-")))
483+ .unwrap();
484+ assert!(saves(build));
485+ assert!(build.raw["with"].get("restore-keys").is_none());
466486 let ci = read("ci.yml");
467487 let rust = ci.jobs.iter().find(|j| j.id == "rust").unwrap();
468488 assert_eq!(
976996 assert!(script.contains("sha256sum -c"));
977997 assert!(script.contains("RUSTC_WRAPPER="));
978998 assert!(script.contains("GITHUB_STEP_SUMMARY"));
999+ // A tag's run reads main's entries and saves none, which only its own
1000+ // tag could read; the mode reaches the steps after it too.
1001+ assert!(script.contains("if [ \"${GITHUB_REF_TYPE:-}\" = tag ]; then\n export SCCACHE_GHA_RW_MODE=READ_ONLY"));
1002+ assert!(script.contains("echo \"SCCACHE_GHA_RW_MODE=$SCCACHE_GHA_RW_MODE\""));
1003+ let tagged = read("runner-release.yml");
1004+ assert!(tagged.trigger("push").unwrap().tags.allows("runner-v0.4.0"));
9791005 }
9801006
9811007 #[test]
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…