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

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.

syntaqxcommitted Parent6f03f2cBrowse files
23 files+752−47 files not counted0/23 viewed
+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"),
+33−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
20122044 | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes |
20132045 | `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` |
20142046 | `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) |
20152048
20162049 Secrets and variables have a tool of their own, `secret`:
20172050
+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.

+82−0
12121212 pub id: String,
12131213 }
12141214
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+
12151297 // ── Artifacts (actions/upload-artifact) ───────────────────────────────────
12161298 //
12171299 // Kept in R2 by the API (the ACTIONS_CACHE bucket, under `a/`) and listed
+4−0
11521152 ("get_artifact_retention", Scope::WorkflowsRead),
11531153 ("delete_artifact", Scope::WorkflowsWrite),
11541154 ("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),
11551159 // Checks: statuses, check runs and check suites on commits.
11561160 ("list_commit_statuses", Scope::ChecksRead),
11571161 ("get_combined_status", Scope::ChecksRead),
+54−0
9494 /// Secrets for workflows and deployments, a repository's or a workspace's.
9595 #[command(subcommand)]
9696 Secret(SecretCommand),
97+ /// The repository's actions/cache entries: list, usage, delete.
98+ #[command(subcommand)]
99+ Cache(CacheCommand),
97100 /// Deployments: list and view.
98101 #[command(subcommand)]
99102 Deploy(DeployCommand),
725728 pub scope: SecretScope,
726729 }
727730
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+
728782 // --- deploy -----------------------------------------------------------
729783
730784 #[derive(Debug, Subcommand)]
+177−0
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.