| 1 | //! 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 | |
| 11 | use g1t_contracts::actions::{Artifact, ArtifactArgs, ArtifactBlob, ArtifactList, ArtifactRetention, ArtifactRetentionArgs, ArtifactsArgs, DeleteArtifactArgs}; |
| 12 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 13 | use serde_json::{Value, json}; |
| 14 | use worker::Result; |
| 15 | |
| 16 | use crate::operations::{Services, repo_path}; |
| 17 | |
| 18 | /// One operation on artifacts. |
| 19 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 20 | pub enum ArtifactsOp { |
| 21 | ListArtifacts, |
| 22 | ListRunArtifacts, |
| 23 | GetArtifact, |
| 24 | DownloadArtifact, |
| 25 | DeleteArtifact, |
| 26 | GetArtifactRetention, |
| 27 | SetArtifactRetention, |
| 28 | } |
| 29 | |
| 30 | impl 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. |
| 122 | fn 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. |
| 131 | fn 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 | |
| 138 | fn 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. |
| 143 | pub 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. |
| 168 | pub fn blob_url(api: &str, blob: &str) -> String { |
| 169 | format!("{api}/actions/toolkit/blobs/{blob}") |
| 170 | } |
| 171 | |
| 172 | fn 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 | |
| 179 | pub 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)] |
| 250 | mod 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 | } |