Skip to content
300 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21//! Workflow run artifacts over REST and MCP, in GitHub's shapes: listing a
2//! repository's or a run's, one by id, a link to download it, deleting it,
3//! and how long a repository keeps them.
4//!
5//! The actions service keeps them and decides who may see and change
6//! them (`g1t_contracts::actions`); their bytes are in R2, downloaded
7//! through the toolkit's blob endpoint (toolkit.rs) with a link signed for
8//! a few minutes. `GET …/artifacts/{id}/zip` answers with a redirect to
9//! that link, as GitHub's does.
10
11use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs};
12use g1t_contracts::{FailureCode, Outcome, Viewer};
13use serde_json::{Value, json};
14use worker::Result;
15
16use crate::operations::{Services, repo_path};
17
18/// One operation on artifacts.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum ArtifactsOp {
21 ListArtifacts,
22 ListRunArtifacts,
23 GetArtifact,
24 DownloadArtifact,
25 DeleteArtifact,
26 GetArtifactRetention,
27 SetArtifactRetention,
28}
29
30impl ArtifactsOp {
31 /// Every one: `Op::ALL` lists each as `Op::Artifacts(…)`, which a test
32 /// checks against this.
33 #[cfg(test)]
34 pub const ALL: [ArtifactsOp; 7] = [
35 ArtifactsOp::ListArtifacts,
36 ArtifactsOp::ListRunArtifacts,
37 ArtifactsOp::GetArtifact,
38 ArtifactsOp::DownloadArtifact,
39 ArtifactsOp::DeleteArtifact,
40 ArtifactsOp::GetArtifactRetention,
41 ArtifactsOp::SetArtifactRetention,
42 ];
43
44 pub fn name(self) -> &'static str {
45 match self {
46 ArtifactsOp::ListArtifacts => "list_artifacts",
47 ArtifactsOp::ListRunArtifacts => "list_workflow_run_artifacts",
48 ArtifactsOp::GetArtifact => "get_artifact",
49 ArtifactsOp::DownloadArtifact => "download_artifact",
50 ArtifactsOp::DeleteArtifact => "delete_artifact",
51 ArtifactsOp::GetArtifactRetention => "get_artifact_retention",
52 ArtifactsOp::SetArtifactRetention => "set_artifact_retention",
53 }
54 }
55
56 /// For the API reference.
57 pub fn title(self) -> &'static str {
58 match self {
59 ArtifactsOp::ListArtifacts => "List a repository's artifacts",
60 ArtifactsOp::ListRunArtifacts => "List a workflow run's artifacts",
61 ArtifactsOp::GetArtifact => "Get an artifact",
62 ArtifactsOp::DownloadArtifact => "Download an artifact",
63 ArtifactsOp::DeleteArtifact => "Delete an artifact",
64 ArtifactsOp::GetArtifactRetention => "Get artifact retention",
65 ArtifactsOp::SetArtifactRetention => "Set artifact retention",
66 }
67 }
68
69 pub fn description(self) -> &'static str {
70 match self {
71 ArtifactsOp::ListArtifacts => "List a repository's artifacts that have not expired, newest first: each with its id (a number), name, size_in_bytes, digest (sha256:… of its zip), created_at, expires_at, archive_download_url, and workflow_run (its run's id, head_branch and head_sha). Narrow with name; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.",
72 ArtifactsOp::ListRunArtifacts => "List one workflow run's artifacts that have not expired, oldest first, in the same shape as list_artifacts. Narrow with name. Needs the Read role.",
73 ArtifactsOp::GetArtifact => "Get one artifact by its id: its name, size_in_bytes, digest, when it was made and when it expires, and its run. Needs the Read role.",
74 ArtifactsOp::DownloadArtifact => "A link to download an artifact as a zip file (an artifact an older runner kept is a .tar.gz), good for 10 minutes and needing no token. Over REST, GET …/zip answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with expires_at. Needs the Read role.",
75 ArtifactsOp::DeleteArtifact => "Delete an artifact before it expires: its bytes go at once and its id stops resolving. Needs the Write role.",
76 ArtifactsOp::GetArtifactRetention => "How many days the repository keeps artifacts (days), and the most it may choose (maximum_allowed_days, 90). A workflow's retention-days can ask for fewer days, never more. Needs the Read role.",
77 ArtifactsOp::SetArtifactRetention => "Set how many days the repository keeps artifacts by default, and at most: days, from 1 to 90. Artifacts already uploaded keep the expiry they were given. Needs the Maintain role.",
78 }
79 }
80
81 /// Whether it changes anything.
82 pub fn writes(self) -> bool {
83 matches!(self, ArtifactsOp::DeleteArtifact | ArtifactsOp::SetArtifactRetention)
84 }
85
86 pub fn input(self) -> Value {
87 let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
88 let id = json!({ "type": ["integer", "string"], "description": "The artifact's id, a number." });
89 let (properties, required): (Value, &[&str]) = match self {
90 ArtifactsOp::ListArtifacts => (
91 json!({
92 "repo": repo,
93 "name": { "type": "string", "description": "Only artifacts with exactly this name." },
94 "page": { "type": "integer", "description": "The page, from 1." },
95 "per_page": { "type": "integer", "description": "Artifacts a page: 30 unless you say, at most 100." },
96 }),
97 &["repo"],
98 ),
99 ArtifactsOp::ListRunArtifacts => (
100 json!({
101 "repo": repo,
102 "id": { "type": "string", "description": "The run's id (run_…)." },
103 "name": { "type": "string", "description": "Only the artifact with exactly this name." },
104 }),
105 &["repo", "id"],
106 ),
107 ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact => (json!({ "repo": repo, "id": id }), &["repo", "id"]),
108 ArtifactsOp::GetArtifactRetention => (json!({ "repo": repo }), &["repo"]),
109 ArtifactsOp::SetArtifactRetention => (
110 json!({
111 "repo": repo,
112 "days": { "type": "integer", "minimum": 1, "maximum": 90, "description": "Days to keep artifacts, by default and at most." },
113 }),
114 &["repo", "days"],
115 ),
116 };
117 json!({ "type": "object", "properties": properties, "required": required })
118 }
119}
120
121/// A number from a path segment or a JSON number.
122fn number(input: &Value, key: &str) -> Option<u64> {
123 match &input[key] {
124 Value::Number(n) => n.as_u64(),
125 Value::String(s) => s.trim().parse().ok(),
126 _ => None,
127 }
128}
129
130/// An outcome's value made into what is sent; its failure as it is.
131fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> {
132 match outcome {
133 Outcome::Ok(value) => Outcome::Ok(f(value)),
134 Outcome::Fail(refused) => Outcome::Fail(refused),
135 }
136}
137
138fn text(input: &Value, key: &str) -> Option<String> {
139 input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
140}
141
142/// An artifact as GitHub's REST API shows one.
143pub fn shown(artifact: &Artifact, api: &str, repository: &str) -> Value {
144 let url = format!("{api}/repos/{repository}/actions/artifacts/{}", artifact.id);
145 json!({
146 "id": artifact.id,
147 "node_id": format!("artifact_{}", artifact.id),
148 "name": artifact.name,
149 "size_in_bytes": artifact.size,
150 "url": url,
151 "archive_download_url": format!("{url}/zip"),
152 "expired": artifact.expired,
153 "digest": artifact.digest,
154 "created_at": artifact.created_at,
155 "updated_at": artifact.updated_at,
156 "expires_at": artifact.expires_at,
157 "workflow_run": {
158 "id": artifact.run_id,
159 "repository_id": artifact.repo_id,
160 "head_repository_id": artifact.repo_id,
161 "head_branch": artifact.head_branch,
162 "head_sha": artifact.head_sha,
163 },
164 })
165}
166
167/// Where a signed blob token downloads from.
168pub fn blob_url(api: &str, blob: &str) -> String {
169 format!("{api}/actions/toolkit/blobs/{blob}")
170}
171
172fn list(found: ArtifactList, api: &str, repository: &str) -> Value {
173 json!({
174 "total_count": found.total_count,
175 "artifacts": found.artifacts.iter().map(|a| shown(a, api, repository)).collect::<Vec<_>>(),
176 })
177}
178
179pub async fn run(op: ArtifactsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
180 let Some(repo) = repo_path(input) else {
181 return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
182 };
183 let repository = format!("{}/{}", repo.namespace, repo.name);
184 let api = services.addresses.api.clone();
185 let actions = &services.actions;
186 let id = number(input, "id");
187 let by_id = || ArtifactArgs { repo: repo.clone(), viewer: viewer.clone(), id, run: None, name: None };
188 if matches!(op, ArtifactsOp::GetArtifact | ArtifactsOp::DownloadArtifact | ArtifactsOp::DeleteArtifact) && id.is_none() {
189 return Ok(Outcome::fail(FailureCode::Invalid, "Name the artifact by its id, a number."));
190 }
191 Ok(match op {
192 ArtifactsOp::ListArtifacts | ArtifactsOp::ListRunArtifacts => {
193 let run = (op == ArtifactsOp::ListRunArtifacts).then(|| text(input, "id")).flatten();
194 let args = ArtifactsArgs {
195 repo: repo.clone(),
196 viewer: viewer.clone(),
197 run,
198 name: text(input, "name"),
199 page: number(input, "page").map(|n| n as u32),
200 per_page: number(input, "per_page").map(|n| n as u32),
201 };
202 let found: Outcome<ArtifactList> = g1t_kit::call(actions, "artifacts", &args).await?;
203 mapped(found, |mut found| {
204 // A run's are listed in the order they were made.
205 if op == ArtifactsOp::ListRunArtifacts {
206 found.artifacts.reverse();
207 }
208 list(found, &api, &repository)
209 })
210 }
211 ArtifactsOp::GetArtifact => {
212 let found: Outcome<Artifact> = g1t_kit::call(actions, "artifact", &by_id()).await?;
213 mapped(found, |a| shown(&a, &api, &repository))
214 }
215 ArtifactsOp::DownloadArtifact => {
216 let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "artifact_download", &by_id()).await?;
217 match found {
218 Outcome::Ok(found) if found.blob.is_empty() => Outcome::fail(FailureCode::Invalid, "Download links are not set up on this installation."),
219 Outcome::Ok(found) => Outcome::Ok(json!({
220 "url": blob_url(&api, &found.blob),
221 "expires_at": g1t_contracts::time::rfc3339(g1t_kit::now_ms() + 10 * 60 * 1000),
222 "artifact": shown(&found.artifact, &api, &repository),
223 })),
224 Outcome::Fail(refused) => Outcome::Fail(refused),
225 }
226 }
227 ArtifactsOp::DeleteArtifact => {
228 let Some(actor) = viewer.clone() else {
229 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Deleting an artifact needs a g1t access token."));
230 };
231 let done: Outcome<Artifact> = g1t_kit::call(actions, "delete_artifact", &DeleteArtifactArgs { actor, repo, id: id.unwrap_or_default() }).await?;
232 mapped(done, |a| json!({ "deleted": true, "id": a.id, "name": a.name }))
233 }
234 ArtifactsOp::GetArtifactRetention | ArtifactsOp::SetArtifactRetention => {
235 let days = if op == ArtifactsOp::SetArtifactRetention {
236 match number(input, "days") {
237 Some(days) => Some(days.min(u64::from(u32::MAX)) as u32),
238 None => return Ok(Outcome::fail(FailureCode::Invalid, "Give days, from 1 to 90.")),
239 }
240 } else {
241 None
242 };
243 let found: Outcome<ArtifactRetention> = g1t_kit::call(actions, "artifact_retention", &ArtifactRetentionArgs { repo, viewer: viewer.clone(), days }).await?;
244 mapped(found, |r| json!({ "days": r.days, "maximum_allowed_days": r.maximum_allowed_days }))
245 }
246 })
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn artifact() -> Artifact {
254 Artifact {
255 id: 42,
256 name: "dist".into(),
257 size: 1024,
258 digest: Some(format!("sha256:{}", "a".repeat(64))),
259 format: "zip".into(),
260 run_id: "run_1".into(),
261 job_id: "job_1".into(),
262 repo_id: "repo_1".into(),
263 expired: false,
264 created_at: "2026-10-08T12:00:00.000Z".into(),
265 updated_at: "2026-10-08T12:00:00.000Z".into(),
266 expires_at: "2026-10-22T12:00:00.000Z".into(),
267 head_branch: Some("main".into()),
268 head_sha: Some("abc".into()),
269 }
270 }
271
272 #[test]
273 fn an_artifact_is_shown_as_github_shows_one() {
274 let shown = shown(&artifact(), "https://api.g1t.sh", "acme/web");
275 assert_eq!(shown["id"], 42);
276 assert_eq!(shown["size_in_bytes"], 1024);
277 assert_eq!(shown["url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42");
278 assert_eq!(shown["archive_download_url"], "https://api.g1t.sh/repos/acme/web/actions/artifacts/42/zip");
279 assert_eq!(shown["workflow_run"]["head_branch"], "main");
280 assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty());
281 }
282
283 #[test]
284 fn ids_are_read_from_a_path_or_a_number() {
285 assert_eq!(number(&json!({ "id": "42" }), "id"), Some(42));
286 assert_eq!(number(&json!({ "id": 42 }), "id"), Some(42));
287 assert_eq!(number(&json!({ "id": "run_1" }), "id"), None);
288 }
289
290 #[test]
291 fn each_operation_is_described_with_a_schema() {
292 for op in ArtifactsOp::ALL {
293 assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Artifacts(op)), "{}", op.name());
294 assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
295 assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
296 let level = g1t_contracts::scopes::scope_for(op.name()).unwrap().level();
297 assert_eq!(op.writes(), level == g1t_contracts::scopes::Level::Write, "{}", op.name());
298 }
299 }
300}