A repository's actions/cache entries can be listed, measured and deleted, in GitHub's shapes, so what the cache holds can be checked against its 10 GiB: GET /repos/{owner}/{repo}/actions/caches lists the entries saved (id, key, ref, version, size_in_bytes, created_at, last_accessed_at), narrowed by a key prefix and a ref, ordered by last use, saving or size in either direction, a page at a time; GET …/actions/cache/usage gives active_caches_size_in_bytes, active_caches_count and quota_bytes; DELETE …/actions/caches?key= deletes a key's entries (one ref's with ref) and answers with them, and DELETE …/actions/caches/{id} deletes one and answers 204. Listing needs the Read role and workflows:read, deleting the Write role and workflows:write; a deleted entry's objects go at once, or at the next sweep if the bucket refuses. The actions service answers caches, cache_usage and delete_caches; the workflow MCP tool has list_caches, cache_usage, delete_caches and delete_cache; and `g1t cache list|usage|delete` calls them (list pages past 100 up to --limit; delete takes an id or a key). The Actions guide says how to see and delete entries, and the OpenAPI document, the CLI reference and the MCP reference are regenerated.
23 files+752−47 files not counted0/23 viewed
| 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 | + | } |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | 8 | mod run_artifacts; | |
| 9 | + | mod caches; | |
| 9 | 10 | mod archives; | |
| 10 | 11 | mod addresses; | |
| 11 | 12 | mod alerts; |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::run_artifacts::ArtifactsOp; | |
| 12 | + | use crate::caches::CachesOp; | |
| 12 | 13 | use crate::archives::ArchiveOp; | |
| 13 | 14 | use crate::deploy_keys::DeployKeysOp; | |
| 14 | 15 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 414 | 415 | ), | |
| 415 | 416 | ( | |
| 416 | 417 | "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.", | |
| 418 | 419 | &[ | |
| 419 | 420 | Op::ListWorkflows, | |
| 420 | 421 | Op::ListWorkflowRuns, | |
| ⋯ | |||
| 431 | 432 | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 432 | 433 | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 433 | 434 | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 435 | + | Op::Caches(CachesOp::ListCaches), | |
| 436 | + | Op::Caches(CachesOp::GetCacheUsage), | |
| 437 | + | Op::Caches(CachesOp::DeleteCachesByKey), | |
| 438 | + | Op::Caches(CachesOp::DeleteCache), | |
| 434 | 439 | ], | |
| 435 | 440 | ), | |
| 436 | 441 | ( | |
| ⋯ | |||
| 797 | 802 | Op::Protection(op) => op.title(), | |
| 798 | 803 | Op::Tokens(op) => op.title(), | |
| 799 | 804 | Op::Artifacts(op) => op.title(), | |
| 805 | + | Op::Caches(op) => op.title(), | |
| 800 | 806 | Op::Archives(op) => op.title(), | |
| 801 | 807 | Op::DeployKeys(op) => op.title(), | |
| 802 | 808 | Op::Packages(op) => op.title(), | |
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::run_artifacts::ArtifactsOp; | |
| 32 | + | use crate::caches::CachesOp; | |
| 32 | 33 | use crate::archives::ArchiveOp; | |
| 33 | 34 | use crate::deploy_keys::DeployKeysOp; | |
| 34 | 35 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 311 | 312 | Tokens(TokenOp), | |
| 312 | 313 | /// Workflow run artifacts, and how long they are kept: run_artifacts.rs. | |
| 313 | 314 | Artifacts(ArtifactsOp), | |
| 315 | + | /// A repository's actions/cache entries: caches.rs. | |
| 316 | + | Caches(CachesOp), | |
| 314 | 317 | /// A branch, tag or commit as a tarball or zipball: archives.rs. | |
| 315 | 318 | Archives(ArchiveOp), | |
| 316 | 319 | /// A repository's deploy keys: deploy_keys.rs. | |
| ⋯ | |||
| 693 | 696 | } | |
| 694 | 697 | ||
| 695 | 698 | impl Op { | |
| 696 | − | pub const ALL: [Op; 367] = [ | |
| 699 | + | pub const ALL: [Op; 371] = [ | |
| 697 | 700 | Op::Whoami, | |
| 698 | 701 | Op::GetWorkspace, | |
| 699 | 702 | Op::CreateWorkspace, | |
| ⋯ | |||
| 986 | 989 | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 987 | 990 | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 988 | 991 | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 992 | + | Op::Caches(CachesOp::ListCaches), | |
| 993 | + | Op::Caches(CachesOp::GetCacheUsage), | |
| 994 | + | Op::Caches(CachesOp::DeleteCachesByKey), | |
| 995 | + | Op::Caches(CachesOp::DeleteCache), | |
| 989 | 996 | Op::Archives(ArchiveOp::DownloadTarball), | |
| 990 | 997 | Op::Archives(ArchiveOp::DownloadZipball), | |
| 991 | 998 | Op::DeployKeys(DeployKeysOp::ListDeployKeys), | |
| ⋯ | |||
| 1258 | 1265 | Op::Protection(op) => op.name(), | |
| 1259 | 1266 | Op::Tokens(op) => op.name(), | |
| 1260 | 1267 | Op::Artifacts(op) => op.name(), | |
| 1268 | + | Op::Caches(op) => op.name(), | |
| 1261 | 1269 | Op::Archives(op) => op.name(), | |
| 1262 | 1270 | Op::DeployKeys(op) => op.name(), | |
| 1263 | 1271 | Op::Packages(op) => op.name(), | |
| ⋯ | |||
| 1778 | 1786 | Op::Protection(op) => op.description(), | |
| 1779 | 1787 | Op::Tokens(op) => op.description(), | |
| 1780 | 1788 | Op::Artifacts(op) => op.description(), | |
| 1789 | + | Op::Caches(op) => op.description(), | |
| 1781 | 1790 | Op::Archives(op) => op.description(), | |
| 1782 | 1791 | Op::DeployKeys(op) => op.description(), | |
| 1783 | 1792 | Op::Packages(op) => op.description(), | |
| ⋯ | |||
| 3155 | 3164 | Op::Protection(op) => op.input(), | |
| 3156 | 3165 | Op::Tokens(op) => op.input(), | |
| 3157 | 3166 | Op::Artifacts(op) => op.input(), | |
| 3167 | + | Op::Caches(op) => op.input(), | |
| 3158 | 3168 | Op::Archives(op) => op.input(), | |
| 3159 | 3169 | Op::DeployKeys(op) => op.input(), | |
| 3160 | 3170 | Op::Packages(op) => op.input(), | |
| ⋯ | |||
| 5386 | 5396 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5387 | 5397 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5388 | 5398 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 5399 | + | Op::Caches(op) => crate::caches::run(op, services, viewer, input).await, | |
| 5389 | 5400 | Op::Archives(op) => crate::archives::run(op, services, viewer, input).await, | |
| 5390 | 5401 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5391 | 5402 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 12124 | 12124 | }, | |
| 12125 | 12125 | "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more." | |
| 12126 | 12126 | }, | |
| 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 | + | }, | |
| 12127 | 12208 | "list_packages": { | |
| 12128 | 12209 | "params": { | |
| 12129 | 12210 | "workspace": "acme" |
| 122 | 122 | Op::Telemetry(_) => return as_is, | |
| 123 | 123 | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 124 | 124 | Op::Artifacts(_) => return as_is, | |
| 125 | + | // The cache's entries travel in `snake_case` from the actions service. | |
| 126 | + | Op::Caches(_) => return as_is, | |
| 125 | 127 | // So are archive links. | |
| 126 | 128 | Op::Archives(_) => return as_is, | |
| 127 | 129 | // Deploy keys travel in `snake_case` from identity. |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::run_artifacts::ArtifactsOp; | |
| 7 | + | use crate::caches::CachesOp; | |
| 7 | 8 | use crate::archives::ArchiveOp; | |
| 8 | 9 | use crate::deploy_keys::DeployKeysOp; | |
| 9 | 10 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 861 | 862 | route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]), | |
| 862 | 863 | route( | |
| 863 | 864 | "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", | |
| 864 | 874 | "/repos/:owner/:name/actions/secrets", | |
| 865 | 875 | Op::ListActionsSecrets, | |
| 866 | 876 | &[], | |
| ⋯ | |||
| 1079 | 1089 | pub fn no_content(&self) -> bool { | |
| 1080 | 1090 | matches!( | |
| 1081 | 1091 | self.op, | |
| 1082 | − | Op::DeleteComment | |
| 1092 | + | Op::DeleteComment | Op::Caches(CachesOp::DeleteCache) | |
| 1083 | 1093 | ) | |
| 1084 | 1094 | } | |
| 1085 | 1095 | ||
| ⋯ | |||
| 1538 | 1548 | } | |
| 1539 | 1549 | ||
| 1540 | 1550 | #[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] | |
| 1541 | 1573 | fn query_parameters_are_renamed() { | |
| 1542 | 1574 | let query = [ | |
| 1543 | 1575 | ("q".to_owned(), "parser".to_owned()), | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::run_artifacts::ArtifactsOp; | |
| 23 | + | use crate::caches::CachesOp; | |
| 23 | 24 | use crate::archives::ArchiveOp; | |
| 24 | 25 | use crate::deploy_keys::DeployKeysOp; | |
| 25 | 26 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 213 | 214 | Tool { | |
| 214 | 215 | name: "workflow", | |
| 215 | 216 | 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.", | |
| 217 | 218 | default_action: None, | |
| 218 | 219 | actions: &[ | |
| 219 | 220 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 231 | 232 | a("delete_artifact", Op::Artifacts(ArtifactsOp::DeleteArtifact), "Delete an artifact before it expires"), | |
| 232 | 233 | a("artifact_retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), "Days the repository keeps artifacts"), | |
| 233 | 234 | 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"), | |
| 234 | 239 | a("combined_status", Op::Checks(ChecksOp::GetCombinedStatus), "A commit's statuses and the state they add up to"), | |
| 235 | 240 | a("list_statuses", Op::Checks(ChecksOp::ListCommitStatuses), "A commit's statuses, newest first"), | |
| 236 | 241 | a("set_status", Op::Checks(ChecksOp::CreateCommitStatus), "Set a status on a commit"), | |
| 825 | 825 | at R2's price plus the margin; see | |
| 826 | 826 | [usage and billing](/guides/usage-and-billing/#actions-cache). | |
| 827 | 827 | ||
| 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 | + | ||
| 828 | 860 | ## Artifacts | |
| 829 | 861 | ||
| 830 | 862 | `actions/upload-artifact` keeps files a job made with its run, for later | |
| ⋯ | |||
| 2012 | 2044 | | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes | | |
| 2013 | 2045 | | `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` | | |
| 2014 | 2046 | | `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) | | |
| 2047 | + | | `list_caches`, `cache_usage`, `delete_caches`, `delete_cache` | `…/actions/caches` and `…/actions/cache/usage`; see [Seeing and deleting entries](#seeing-and-deleting-entries) | | |
| 2015 | 2048 | ||
| 2016 | 2049 | Secrets and variables have a tool of their own, `secret`: | |
| 2017 | 2050 | ||
| 674 | 674 | ||
| 675 | 675 | 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/). | |
| 676 | 676 | ||
| 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 | + | ||
| 677 | 724 | ## g1t deploy | |
| 678 | 725 | ||
| 679 | 726 | Deployments: list and view. |
| 432 | 432 | | [`delete_artifact`](/reference/api/actions/delete-artifact/) | Delete it before it expires. Write role. | `repo`, `id` | `workflows:write` | | |
| 433 | 433 | | [`artifact_retention`](/reference/api/actions/get-artifact-retention/) | The days the repository keeps artifacts, and the most it may. | `repo` | `workflows:read` | | |
| 434 | 434 | | [`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` | | |
| 435 | 439 | | [`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` | | |
| 436 | 440 | | [`list_statuses`](/reference/api/checks/list-commit-statuses/) | A commit's statuses, newest first. | `repo`, `ref` | `checks:read` | | |
| 437 | 441 | | [`set_status`](/reference/api/checks/create-commit-status/) | Set a status: `state`, `context`, `description`, `target_url`. Write role. | `repo`, `sha`, `state` | `checks:write` | |
Binary or large file; its contents are not shown.
| 1212 | 1212 | pub id: String, | |
| 1213 | 1213 | } | |
| 1214 | 1214 | ||
| 1215 | + | /// A cache entry as people see it, in GitHub's shape: `id` is a number, | |
| 1216 | + | /// `git_ref` the ref whose run saved it (sent as `ref`; a pull request from | |
| 1217 | + | /// outside the repository saves under `untrusted:<ref>`), `version` the | |
| 1218 | + | /// hash of its paths and compression (empty for one g1t's own | |
| 1219 | + | /// `actions/cache` saved). | |
| 1220 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 1221 | + | pub struct CacheEntry { | |
| 1222 | + | pub id: u64, | |
| 1223 | + | pub key: String, | |
| 1224 | + | #[serde(rename = "ref")] | |
| 1225 | + | pub git_ref: String, | |
| 1226 | + | pub version: String, | |
| 1227 | + | pub size_in_bytes: u64, | |
| 1228 | + | pub created_at: String, | |
| 1229 | + | pub last_accessed_at: String, | |
| 1230 | + | } | |
| 1231 | + | ||
| 1232 | + | /// A page of cache entries, in GitHub's shape. | |
| 1233 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 1234 | + | pub struct CacheList { | |
| 1235 | + | pub total_count: u64, | |
| 1236 | + | pub actions_caches: Vec<CacheEntry>, | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | /// How a list of cache entries is ordered: GitHub's `sort` names. | |
| 1240 | + | pub const CACHE_SORTS: [&str; 3] = ["last_accessed_at", "created_at", "size_in_bytes"]; | |
| 1241 | + | ||
| 1242 | + | /// `caches`: a repository's saved cache entries, narrowed to keys starting | |
| 1243 | + | /// with `key` and to one `git_ref`, ordered by `sort` (one of | |
| 1244 | + | /// `CACHE_SORTS`, `last_accessed_at` by default) in `direction` (`asc` or | |
| 1245 | + | /// `desc`, the default), a page at a time (30 by default, at most 100). | |
| 1246 | + | /// Anyone who can see the repository. Returns `Outcome<CacheList>`. | |
| 1247 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1248 | + | pub struct CachesArgs { | |
| 1249 | + | pub repo: RepoPath, | |
| 1250 | + | pub viewer: Viewer, | |
| 1251 | + | #[serde(default)] | |
| 1252 | + | pub key: Option<String>, | |
| 1253 | + | #[serde(default)] | |
| 1254 | + | pub git_ref: Option<String>, | |
| 1255 | + | #[serde(default)] | |
| 1256 | + | pub sort: Option<String>, | |
| 1257 | + | #[serde(default)] | |
| 1258 | + | pub direction: Option<String>, | |
| 1259 | + | #[serde(default)] | |
| 1260 | + | pub page: Option<u32>, | |
| 1261 | + | #[serde(default)] | |
| 1262 | + | pub per_page: Option<u32>, | |
| 1263 | + | } | |
| 1264 | + | ||
| 1265 | + | /// `cache_usage`: what a repository's saved entries hold together, against | |
| 1266 | + | /// `CACHE_REPO_QUOTA_BYTES`. Anyone who can see the repository. Returns | |
| 1267 | + | /// `Outcome<CacheUsage>`. | |
| 1268 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1269 | + | pub struct CacheUsageArgs { | |
| 1270 | + | pub repo: RepoPath, | |
| 1271 | + | pub viewer: Viewer, | |
| 1272 | + | } | |
| 1273 | + | ||
| 1274 | + | /// GitHub's shape, with the most the repository may hold. | |
| 1275 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 1276 | + | pub struct CacheUsage { | |
| 1277 | + | pub active_caches_size_in_bytes: u64, | |
| 1278 | + | pub active_caches_count: u64, | |
| 1279 | + | pub quota_bytes: u64, | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | /// `delete_caches`: deletes one entry by `id`, or every entry whose key is | |
| 1283 | + | /// exactly `key` (only `git_ref`'s, when given). Needs the Write role. | |
| 1284 | + | /// Returns the entries deleted: `Outcome<CacheList>`. | |
| 1285 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1286 | + | pub struct DeleteCachesArgs { | |
| 1287 | + | pub actor: User, | |
| 1288 | + | pub repo: RepoPath, | |
| 1289 | + | #[serde(default)] | |
| 1290 | + | pub id: Option<u64>, | |
| 1291 | + | #[serde(default)] | |
| 1292 | + | pub key: Option<String>, | |
| 1293 | + | #[serde(default)] | |
| 1294 | + | pub git_ref: Option<String>, | |
| 1295 | + | } | |
| 1296 | + | ||
| 1215 | 1297 | // ── Artifacts (actions/upload-artifact) ─────────────────────────────────── | |
| 1216 | 1298 | // | |
| 1217 | 1299 | // Kept in R2 by the API (the ACTIONS_CACHE bucket, under `a/`) and listed |
| 1152 | 1152 | ("get_artifact_retention", Scope::WorkflowsRead), | |
| 1153 | 1153 | ("delete_artifact", Scope::WorkflowsWrite), | |
| 1154 | 1154 | ("set_artifact_retention", Scope::WorkflowsWrite), | |
| 1155 | + | ("list_caches", Scope::WorkflowsRead), | |
| 1156 | + | ("get_cache_usage", Scope::WorkflowsRead), | |
| 1157 | + | ("delete_caches_by_key", Scope::WorkflowsWrite), | |
| 1158 | + | ("delete_cache", Scope::WorkflowsWrite), | |
| 1155 | 1159 | // Checks: statuses, check runs and check suites on commits. | |
| 1156 | 1160 | ("list_commit_statuses", Scope::ChecksRead), | |
| 1157 | 1161 | ("get_combined_status", Scope::ChecksRead), |
| 94 | 94 | /// Secrets for workflows and deployments, a repository's or a workspace's. | |
| 95 | 95 | #[command(subcommand)] | |
| 96 | 96 | Secret(SecretCommand), | |
| 97 | + | /// The repository's actions/cache entries: list, usage, delete. | |
| 98 | + | #[command(subcommand)] | |
| 99 | + | Cache(CacheCommand), | |
| 97 | 100 | /// Deployments: list and view. | |
| 98 | 101 | #[command(subcommand)] | |
| 99 | 102 | Deploy(DeployCommand), | |
| ⋯ | |||
| 725 | 728 | pub scope: SecretScope, | |
| 726 | 729 | } | |
| 727 | 730 | ||
| 731 | + | // --- cache ------------------------------------------------------------ | |
| 732 | + | ||
| 733 | + | #[derive(Debug, Subcommand)] | |
| 734 | + | pub enum CacheCommand { | |
| 735 | + | /// List the repository's cache entries, least recently used last. | |
| 736 | + | List(CacheListArgs), | |
| 737 | + | /// What the repository's cache entries hold, against its quota. | |
| 738 | + | Usage, | |
| 739 | + | /// Delete cache entries: one by its id, or every entry with a key. | |
| 740 | + | Delete(CacheDeleteArgs), | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 744 | + | pub enum CacheSort { | |
| 745 | + | /// When it was last restored or looked up. | |
| 746 | + | LastAccessedAt, | |
| 747 | + | CreatedAt, | |
| 748 | + | SizeInBytes, | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] | |
| 752 | + | pub enum SortOrder { | |
| 753 | + | Asc, | |
| 754 | + | Desc, | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | #[derive(Debug, Args)] | |
| 758 | + | pub struct CacheListArgs { | |
| 759 | + | /// Only entries whose key starts with this. | |
| 760 | + | #[arg(short, long)] | |
| 761 | + | pub key: Option<String>, | |
| 762 | + | /// Only entries saved by this ref's runs, e.g. refs/heads/main. | |
| 763 | + | #[arg(short, long = "ref", value_name = "REF")] | |
| 764 | + | pub git_ref: Option<String>, | |
| 765 | + | #[arg(short = 'S', long, value_enum, default_value_t = CacheSort::LastAccessedAt)] | |
| 766 | + | pub sort: CacheSort, | |
| 767 | + | #[arg(short = 'O', long, value_enum, default_value_t = SortOrder::Desc)] | |
| 768 | + | pub order: SortOrder, | |
| 769 | + | #[arg(short = 'L', long, default_value_t = 30)] | |
| 770 | + | pub limit: usize, | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | #[derive(Debug, Args)] | |
| 774 | + | pub struct CacheDeleteArgs { | |
| 775 | + | /// An entry's id (a number, from `g1t cache list`), or a key: every entry with exactly that key. | |
| 776 | + | pub entry: String, | |
| 777 | + | /// With a key, only the entries this ref's runs saved. | |
| 778 | + | #[arg(short, long = "ref", value_name = "REF")] | |
| 779 | + | pub git_ref: Option<String>, | |
| 780 | + | } | |
| 781 | + | ||
| 728 | 782 | // --- deploy ----------------------------------------------------------- | |
| 729 | 783 | ||
| 730 | 784 | #[derive(Debug, Subcommand)] | |
| 1 | + | //! `g1t cache`: the repository's actions/cache entries, what they hold | |
| 2 | + | //! against its quota, and deleting them. | |
| 3 | + | ||
| 4 | + | use serde_json::{Value, json}; | |
| 5 | + | ||
| 6 | + | use crate::chunk::human; | |
| 7 | + | use crate::cli::{CacheCommand, CacheDeleteArgs, CacheListArgs, CacheSort, SortOrder}; | |
| 8 | + | use crate::context::Ctx; | |
| 9 | + | use crate::error::Result; | |
| 10 | + | use crate::output::{Cell, ago, s}; | |
| 11 | + | ||
| 12 | + | pub fn run(ctx: &Ctx, command: CacheCommand) -> Result<()> { | |
| 13 | + | match command { | |
| 14 | + | CacheCommand::List(args) => list(ctx, args), | |
| 15 | + | CacheCommand::Usage => usage(ctx), | |
| 16 | + | CacheCommand::Delete(args) => delete(ctx, args), | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | fn sort_name(sort: CacheSort) -> &'static str { | |
| 21 | + | match sort { | |
| 22 | + | CacheSort::LastAccessedAt => "last_accessed_at", | |
| 23 | + | CacheSort::CreatedAt => "created_at", | |
| 24 | + | CacheSort::SizeInBytes => "size_in_bytes", | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | fn bytes(row: &Value, key: &str) -> u64 { | |
| 29 | + | row[key].as_u64().unwrap_or(0) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | fn list(ctx: &Ctx, args: CacheListArgs) -> Result<()> { | |
| 33 | + | let (repo, client) = ctx.repo_client()?; | |
| 34 | + | let limit = args.limit.max(1); | |
| 35 | + | let mut caches = Vec::new(); | |
| 36 | + | let mut total = 0; | |
| 37 | + | // Pages of at most 100, until the limit or the end. | |
| 38 | + | for page in 1.. { | |
| 39 | + | let found = client | |
| 40 | + | .op("list_caches") | |
| 41 | + | .repo(&repo) | |
| 42 | + | .query_opt("key", args.key.clone()) | |
| 43 | + | .query_opt("ref", args.git_ref.clone()) | |
| 44 | + | .query("sort", sort_name(args.sort)) | |
| 45 | + | .query( | |
| 46 | + | "direction", | |
| 47 | + | if args.order == SortOrder::Asc { | |
| 48 | + | "asc" | |
| 49 | + | } else { | |
| 50 | + | "desc" | |
| 51 | + | }, | |
| 52 | + | ) | |
| 53 | + | .query("per_page", limit.min(100)) | |
| 54 | + | .query("page", page) | |
| 55 | + | .send()?; | |
| 56 | + | total = found["total_count"].as_u64().unwrap_or(0); | |
| 57 | + | let rows = super::items(found, "actions_caches"); | |
| 58 | + | let last = rows.len() < limit.min(100); | |
| 59 | + | caches.extend(rows); | |
| 60 | + | if last || caches.len() >= limit { | |
| 61 | + | break; | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | caches.truncate(limit); | |
| 65 | + | if ctx.out.structured() { | |
| 66 | + | return ctx.out.emit(&Value::Array(caches)); | |
| 67 | + | } | |
| 68 | + | if caches.is_empty() { | |
| 69 | + | eprintln!("No cache entries in {repo}."); | |
| 70 | + | return Ok(()); | |
| 71 | + | } | |
| 72 | + | let out = &ctx.out; | |
| 73 | + | let rows: Vec<Vec<Cell>> = caches | |
| 74 | + | .iter() | |
| 75 | + | .map(|c| { | |
| 76 | + | let id = c["id"].to_string(); | |
| 77 | + | vec![ | |
| 78 | + | Cell::styled(id.clone(), out.dim(&id)), | |
| 79 | + | Cell::new(s(c, "key")), | |
| 80 | + | Cell::styled(s(c, "ref"), out.cyan(s(c, "ref"))), | |
| 81 | + | Cell::new(human(bytes(c, "size_in_bytes"))), | |
| 82 | + | Cell::styled(ago(s(c, "created_at")), out.dim(&ago(s(c, "created_at")))), | |
| 83 | + | Cell::styled( | |
| 84 | + | ago(s(c, "last_accessed_at")), | |
| 85 | + | out.dim(&ago(s(c, "last_accessed_at"))), | |
| 86 | + | ), | |
| 87 | + | ] | |
| 88 | + | }) | |
| 89 | + | .collect(); | |
| 90 | + | out.table(&["id", "key", "ref", "size", "saved", "last used"], &rows)?; | |
| 91 | + | if (caches.len() as u64) < total { | |
| 92 | + | eprintln!("{} of {total} entries; --limit shows more.", caches.len()); | |
| 93 | + | } | |
| 94 | + | Ok(()) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | fn usage(ctx: &Ctx) -> Result<()> { | |
| 98 | + | let (repo, client) = ctx.repo_client()?; | |
| 99 | + | let found = client.op("get_cache_usage").repo(&repo).send()?; | |
| 100 | + | if ctx.out.structured() { | |
| 101 | + | return ctx.out.emit(&found); | |
| 102 | + | } | |
| 103 | + | let held = bytes(&found, "active_caches_size_in_bytes"); | |
| 104 | + | let quota = bytes(&found, "quota_bytes"); | |
| 105 | + | let count = bytes(&found, "active_caches_count"); | |
| 106 | + | let share = if quota > 0 { | |
| 107 | + | format!(" ({:.0}%)", held as f64 * 100.0 / quota as f64) | |
| 108 | + | } else { | |
| 109 | + | String::new() | |
| 110 | + | }; | |
| 111 | + | println!( | |
| 112 | + | "{repo}: {count} {} holding {} of {}{share}", | |
| 113 | + | if count == 1 { "entry" } else { "entries" }, | |
| 114 | + | human(held), | |
| 115 | + | human(quota), | |
| 116 | + | ); | |
| 117 | + | Ok(()) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// An id is all digits; anything else is a key. | |
| 121 | + | fn is_id(entry: &str) -> bool { | |
| 122 | + | !entry.is_empty() && entry.bytes().all(|b| b.is_ascii_digit()) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | fn delete(ctx: &Ctx, args: CacheDeleteArgs) -> Result<()> { | |
| 126 | + | let (repo, client) = ctx.repo_client()?; | |
| 127 | + | let deleted = if is_id(&args.entry) { | |
| 128 | + | client | |
| 129 | + | .op("delete_cache") | |
| 130 | + | .repo(&repo) | |
| 131 | + | .arg("id", &args.entry) | |
| 132 | + | .send()?; | |
| 133 | + | vec![json!({ "id": args.entry.parse::<u64>().unwrap_or_default() })] | |
| 134 | + | } else { | |
| 135 | + | let found = client | |
| 136 | + | .op("delete_caches_by_key") | |
| 137 | + | .repo(&repo) | |
| 138 | + | .query("key", &args.entry) | |
| 139 | + | .query_opt("ref", args.git_ref.clone()) | |
| 140 | + | .send()?; | |
| 141 | + | super::items(found, "actions_caches") | |
| 142 | + | }; | |
| 143 | + | if ctx.out.structured() { | |
| 144 | + | return ctx.out.emit(&Value::Array(deleted)); | |
| 145 | + | } | |
| 146 | + | let what = if is_id(&args.entry) { | |
| 147 | + | format!("cache entry {}", args.entry) | |
| 148 | + | } else { | |
| 149 | + | let n = deleted.len(); | |
| 150 | + | format!( | |
| 151 | + | "{n} cache {} with the key {}", | |
| 152 | + | if n == 1 { "entry" } else { "entries" }, | |
| 153 | + | args.entry | |
| 154 | + | ) | |
| 155 | + | }; | |
| 156 | + | eprintln!("{} Deleted {what} from {repo}.", ctx.out.green("✓")); | |
| 157 | + | Ok(()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[cfg(test)] | |
| 161 | + | mod tests { | |
| 162 | + | use super::*; | |
| 163 | + | ||
| 164 | + | #[test] | |
| 165 | + | fn a_number_is_an_id_and_anything_else_a_key() { | |
| 166 | + | assert!(is_id("9184")); | |
| 167 | + | assert!(!is_id("cargo-test-Linux-1.99.0-abc")); | |
| 168 | + | assert!(!is_id("12a")); | |
| 169 | + | assert!(!is_id("")); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn sorts_are_github_s_names() { | |
| 174 | + | assert_eq!(sort_name(CacheSort::LastAccessedAt), "last_accessed_at"); | |
| 175 | + | assert_eq!(sort_name(CacheSort::SizeInBytes), "size_in_bytes"); | |
| 176 | + | } | |
| 177 | + | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.