Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2
Jobs with id-token: write get OIDC tokens from g1t's issuer (the API, /actions/oidc) with GitHub's claims; every job gets the toolkit's runtime token and addresses, so actions built on @actions/cache work unmodified; artifacts move to R2 with retention, overwrite, compression, patterns, merging, cross-run downloads, the REST artifacts API, MCP actions, and a list with size, expiry, download and delete on the run's page.
| 923 | 923 | name = "g1t-actions-service" | |
| 924 | 924 | version = "0.1.0" | |
| 925 | 925 | dependencies = [ | |
| 926 | + | "base64 0.22.1", | |
| 926 | 927 | "g1t-actions", | |
| 927 | 928 | "g1t-contracts", | |
| 928 | 929 | "g1t-kit", | |
| 929 | 930 | "g1t-secrets", | |
| 931 | + | "hex", | |
| 930 | 932 | "serde", | |
| 931 | 933 | "serde_json", | |
| 932 | 934 | "worker", | |
| 942 | 944 | "g1t-kit", | |
| 943 | 945 | "serde", | |
| 944 | 946 | "serde_json", | |
| 947 | + | "sha2 0.10.9", | |
| 945 | 948 | "worker", | |
| 946 | 949 | ] | |
| 947 | 950 | ||
| 1094 | 1097 | dependencies = [ | |
| 1095 | 1098 | "anyhow", | |
| 1096 | 1099 | "base64 0.22.1", | |
| 1100 | + | "crc32fast", | |
| 1097 | 1101 | "ed25519-dalek", | |
| 1102 | + | "flate2", | |
| 1098 | 1103 | "g1t-actions", | |
| 1099 | 1104 | "g1t-scan", | |
| 1100 | 1105 | "hex", |
| 15 | 15 | serde_json = { workspace = true, features = ["preserve_order"] } | |
| 16 | 16 | worker.workspace = true | |
| 17 | 17 | base64 = "0.22" | |
| 18 | + | sha2 = "0.10" | |
| 18 | 19 | form_urlencoded = "1" | |
| 19 | 20 | ||
| 20 | 21 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the |
| 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 | + | } |
| 1 | − | //! Artifacts and the cache of GitHub Actions jobs. | |
| 1 | + | //! Artifacts and the cache of GitHub Actions jobs, as g1t's own runner | |
| 2 | + | //! reaches them. (Actions built on GitHub's toolkit reach the same entries | |
| 3 | + | //! through toolkit.rs.) | |
| 2 | 4 | //! | |
| 3 | − | //! Artifacts are kept in Workers KV in chunks, with KV's own expiry: 14 | |
| 4 | − | //! days with their run. Cache entries are kept in R2 (ACTIONS_CACHE), up | |
| 5 | − | //! to 2 GB each, uploaded in parts; the actions service lists them and | |
| 6 | − | //! decides what is found, what fits and what is evicted | |
| 7 | − | //! (services/actions/src/cache.rs). Entries saved in KV before the cache | |
| 8 | − | //! moved are still found there until they expire. | |
| 5 | + | //! Artifacts are kept in R2 (ACTIONS_CACHE, under `a/`), uploaded in | |
| 6 | + | //! parts; the actions service lists them and decides names, sizes and how | |
| 7 | + | //! long each is kept (services/actions/src/artifacts.rs). Artifacts older | |
| 8 | + | //! runners kept in Workers KV are still listed and found there until KV | |
| 9 | + | //! expires them. Cache entries are kept in R2 too, up to 2 GB each, | |
| 10 | + | //! uploaded in parts; the actions service decides what is found, what | |
| 11 | + | //! fits and what is evicted (services/actions/src/cache.rs). | |
| 9 | 12 | //! | |
| 10 | 13 | //! A sandbox reaches these with its job's token: | |
| 11 | 14 | //! | |
| 12 | − | //! - `GET /actions/jobs/{job}/artifacts`, `PUT|GET .../artifacts/{name}` | |
| 15 | + | //! - `GET /actions/jobs/{job}/artifacts[?run_id=]`: its run's (or another run's) | |
| 16 | + | //! - `POST .../artifacts/uploads?name=&size=&retention_days=&overwrite=&format=`: | |
| 17 | + | //! `{ id, upload, part_bytes, retention_days, expires_at }` | |
| 18 | + | //! - `PUT .../artifacts/uploads/{id}/{part}?upload=`, `POST …/complete` with | |
| 19 | + | //! `{ size, parts, digest }`, `DELETE .../artifacts/uploads/{id}?upload=` | |
| 20 | + | //! - `GET .../artifacts/{id}/download[?run_id=]`, `DELETE .../artifacts/{id}` | |
| 21 | + | //! - `PUT|GET .../artifacts/{name}`: a whole artifact by name (older runners) | |
| 13 | 22 | //! - `GET .../cache?key=&restore=`: the entry, streamed, its key in `x-g1t-key` | |
| 14 | 23 | //! - `POST .../cache/uploads?key=&size=`: `{ id, upload, part_bytes }` | |
| 15 | 24 | //! - `PUT .../cache/uploads/{id}/{part}?upload=`: one part, `{ part, etag }` | |
| 17 | 26 | //! - `DELETE .../cache/uploads/{id}?upload=`: gives the upload up | |
| 18 | 27 | //! - `PUT .../cache?key=`: a whole entry of at most 60 MB at once (older runners) | |
| 19 | 28 | //! | |
| 20 | − | //! People download an artifact at | |
| 21 | − | //! `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 29 | + | //! People download an artifact through the REST API (artifacts.rs), or by | |
| 30 | + | //! name at `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 22 | 31 | ||
| 23 | 32 | use serde::{Deserialize, Serialize}; | |
| 24 | 33 | use serde_json::{Value, json}; | |
| 25 | 34 | use worker::kv::KvStore; | |
| 26 | − | use worker::{Bucket, Env, Request, Response, Result, UploadedPart}; | |
| 35 | + | use worker::{Bucket, Env, Request, Response, Result, UploadedPart, Url}; | |
| 27 | 36 | ||
| 28 | 37 | use g1t_contracts::actions::{ | |
| 29 | − | CACHE_PART_BYTES, CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, | |
| 38 | + | ARTIFACT_PART_BYTES, Artifact, ArtifactArgs, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, CACHE_PART_BYTES, | |
| 39 | + | CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, JobArtifactsArgs, | |
| 30 | 40 | }; | |
| 31 | 41 | use g1t_contracts::{FailureCode, Outcome}; | |
| 32 | 42 | ||
| 33 | 43 | use crate::operations::Services; | |
| 34 | 44 | ||
| 35 | − | /// KV's largest value is 25 MiB; chunks stay under it. | |
| 36 | − | const CHUNK: usize = 20 * 1024 * 1024; | |
| 37 | − | /// The largest artifact or cache entry, kept within a Worker's memory. | |
| 45 | + | /// The largest artifact or cache entry an older runner sends at once, | |
| 46 | + | /// held in a Worker's memory. | |
| 38 | 47 | const MAX_BYTES: usize = 60 * 1024 * 1024; | |
| 39 | − | const ARTIFACT_TTL: u64 = 14 * 24 * 60 * 60; | |
| 40 | 48 | ||
| 41 | 49 | #[derive(Serialize, Deserialize)] | |
| 42 | 50 | struct Meta { | |
| 52 | 60 | env.kv("BLOBS") | |
| 53 | 61 | } | |
| 54 | 62 | ||
| 55 | − | async fn put(kv: &KvStore, base: &str, name: &str, bytes: &[u8], ttl: u64) -> Result<()> { | |
| 56 | − | let chunks: Vec<&[u8]> = if bytes.is_empty() { vec![&[][..]] } else { bytes.chunks(CHUNK).collect() }; | |
| 57 | − | for (index, chunk) in chunks.iter().enumerate() { | |
| 58 | − | kv.put_bytes(&format!("{base}#{index}"), chunk)?.expiration_ttl(ttl).execute().await?; | |
| 59 | − | } | |
| 60 | − | let meta = Meta { size: bytes.len(), chunks: chunks.len(), at: g1t_kit::now_ms(), name: name.to_owned() }; | |
| 61 | − | // The metadata travels with the key in listings, so the newest entry | |
| 62 | − | // can be found without reading each. | |
| 63 | − | kv.put(base, serde_json::to_string(&meta)?)? | |
| 64 | − | .metadata(&meta)? | |
| 65 | − | .expiration_ttl(ttl) | |
| 66 | − | .execute() | |
| 67 | − | .await?; | |
| 68 | − | Ok(()) | |
| 69 | − | } | |
| 70 | − | ||
| 71 | 63 | async fn get(kv: &KvStore, base: &str) -> Result<Option<Vec<u8>>> { | |
| 72 | 64 | let Some(meta) = kv.get(base).json::<Meta>().await? else { return Ok(None) }; | |
| 73 | 65 | let mut out = Vec::with_capacity(meta.size); | |
| 110 | 102 | ||
| 111 | 103 | fn error(status: u16, message: &str) -> Result<Response> { | |
| 112 | 104 | Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status)) | |
| 113 | − | } | |
| 114 | − | ||
| 115 | − | fn valid_name(name: &str) -> bool { | |
| 116 | − | !name.is_empty() && name.len() <= 200 && !name.starts_with('.') && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) | |
| 117 | 105 | } | |
| 118 | 106 | ||
| 119 | 107 | fn decode(text: &str) -> String { | |
| 154 | 142 | ||
| 155 | 143 | /// A sandbox storing or fetching an artifact or cache entry. `rest` is | |
| 156 | 144 | /// the path after `/actions/jobs/`. | |
| 157 | − | pub async fn for_job(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 145 | + | pub async fn for_job(request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 158 | 146 | let (job, what) = rest.split_once('/').unwrap_or((rest, "")); | |
| 159 | 147 | let token = request | |
| 160 | 148 | .headers() | |
| 170 | 158 | let repo = owner["repoId"].as_str().unwrap_or_default().to_owned(); | |
| 171 | 159 | let kv = store(env)?; | |
| 172 | 160 | match (method, what) { | |
| 173 | − | ("GET", "artifacts") => { | |
| 174 | − | let listed: Vec<Value> = list(&kv, &format!("a/{run}/")) | |
| 175 | − | .await? | |
| 176 | − | .into_iter() | |
| 177 | − | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size })) | |
| 178 | − | .collect(); | |
| 179 | − | crate::reply(&listed) | |
| 180 | − | } | |
| 181 | − | (_, what) if what.starts_with("artifacts/") => { | |
| 182 | − | let name = decode(&what["artifacts/".len()..]); | |
| 183 | − | if !valid_name(&name) { | |
| 184 | − | return error(400, "That is not an artifact name."); | |
| 185 | − | } | |
| 186 | − | let base = format!("a/{run}/{name}"); | |
| 187 | − | if method == "PUT" { | |
| 188 | − | let bytes = request.bytes().await?; | |
| 189 | − | if bytes.len() > MAX_BYTES { | |
| 190 | − | return error(413, "Artifacts are at most 60 MB."); | |
| 191 | − | } | |
| 192 | − | put(&kv, &base, &name, &bytes, ARTIFACT_TTL).await?; | |
| 193 | − | return crate::reply(&json!({ "name": name, "size": bytes.len() })); | |
| 194 | − | } | |
| 195 | − | match get(&kv, &base).await? { | |
| 196 | − | Some(bytes) => Response::from_bytes(bytes), | |
| 197 | − | None => error(404, "No such artifact."), | |
| 198 | − | } | |
| 161 | + | (_, what) if what == "artifacts" || what.starts_with("artifacts/") => { | |
| 162 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 163 | + | artifacts(request, &kv, &bucket, services, method, job, &token, &run, &repo, what).await | |
| 199 | 164 | } | |
| 200 | 165 | (_, what) if what == "cache" || what.starts_with("cache/") => { | |
| 201 | 166 | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 258 | 223 | let found: Outcome<Option<CacheHit>> = g1t_kit::call( | |
| 259 | 224 | &services.actions, | |
| 260 | 225 | "cache_lookup", | |
| 261 | − | &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone() }, | |
| 226 | + | &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone(), version: None }, | |
| 262 | 227 | ) | |
| 263 | 228 | .await?; | |
| 264 | 229 | let found = match refused(found) { | |
| 289 | 254 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 290 | 255 | &services.actions, | |
| 291 | 256 | "cache_reserve", | |
| 292 | − | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64 }, | |
| 257 | + | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: None }, | |
| 293 | 258 | ) | |
| 294 | 259 | .await?; | |
| 295 | 260 | let reserved = match reserved { | |
| 312 | 277 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 313 | 278 | &services.actions, | |
| 314 | 279 | "cache_reserve", | |
| 315 | − | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size }, | |
| 280 | + | &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: None }, | |
| 316 | 281 | ) | |
| 317 | 282 | .await?; | |
| 318 | 283 | let reserved = match refused(reserved) { | |
| 369 | 334 | format!("c/{repo}/{id}") | |
| 370 | 335 | } | |
| 371 | 336 | ||
| 337 | + | /// Where an artifact is in R2, as the actions service names it. | |
| 338 | + | fn artifact_object(repo: &str, id: u64) -> String { | |
| 339 | + | format!("a/{repo}/{id}") | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | /// An artifact as a job's runner lists it. | |
| 343 | + | fn for_runner(artifact: &Artifact) -> Value { | |
| 344 | + | json!({ | |
| 345 | + | "id": artifact.id, | |
| 346 | + | "name": artifact.name, | |
| 347 | + | "size": artifact.size, | |
| 348 | + | "digest": artifact.digest, | |
| 349 | + | "format": artifact.format, | |
| 350 | + | "created_at": artifact.created_at, | |
| 351 | + | "expires_at": artifact.expires_at, | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// An R2 object streamed back, with what it is. | |
| 356 | + | async fn stream(bucket: &Bucket, object: &str, format: &str) -> Result<Response> { | |
| 357 | + | let Some(found) = bucket.get(object).execute().await? else { return error(404, "That artifact is gone: it expired or was deleted.") }; | |
| 358 | + | let size = found.size(); | |
| 359 | + | let Some(body) = found.body() else { return error(404, "That artifact is gone: it expired or was deleted.") }; | |
| 360 | + | let mut response = Response::from_body(body.response_body()?)?; | |
| 361 | + | let headers = response.headers_mut(); | |
| 362 | + | headers.set("content-length", &size.to_string())?; | |
| 363 | + | headers.set("content-type", if format == "tgz" { "application/gzip" } else { "application/zip" })?; | |
| 364 | + | headers.set("x-g1t-format", format)?; | |
| 365 | + | Ok(response) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /// A job's artifacts: listing its run's (or another run's of its | |
| 369 | + | /// repository), uploading in parts, downloading, deleting; and the whole | |
| 370 | + | /// uploads and downloads by name of older runners. | |
| 371 | + | #[allow(clippy::too_many_arguments)] | |
| 372 | + | async fn artifacts( | |
| 373 | + | mut request: Request, | |
| 374 | + | kv: &KvStore, | |
| 375 | + | bucket: &Bucket, | |
| 376 | + | services: &Services, | |
| 377 | + | method: &str, | |
| 378 | + | job: &str, | |
| 379 | + | token: &str, | |
| 380 | + | run: &str, | |
| 381 | + | repo: &str, | |
| 382 | + | what: &str, | |
| 383 | + | ) -> Result<Response> { | |
| 384 | + | let parts: Vec<&str> = what.split('/').collect(); | |
| 385 | + | let upload_id = query(&request, "upload").unwrap_or_default(); | |
| 386 | + | let credential = |id: Option<u64>, name: Option<String>, run_id: Option<String>| JobArtifactsArgs { | |
| 387 | + | job: job.to_owned(), | |
| 388 | + | token: token.to_owned(), | |
| 389 | + | run_id, | |
| 390 | + | name, | |
| 391 | + | id, | |
| 392 | + | }; | |
| 393 | + | let actions = &services.actions; | |
| 394 | + | match (method, parts.as_slice()) { | |
| 395 | + | ("GET", ["artifacts"]) => { | |
| 396 | + | let run_id = query(&request, "run_id").filter(|r| !r.is_empty()); | |
| 397 | + | let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &credential(None, None, run_id.clone())).await?; | |
| 398 | + | match refused(found) { | |
| 399 | + | Ok(found) => { | |
| 400 | + | let mut listed: Vec<Value> = found.iter().map(for_runner).collect(); | |
| 401 | + | // Artifacts older runners kept in KV, for the days they | |
| 402 | + | // are still there. | |
| 403 | + | let legacy_run = run_id.as_deref().unwrap_or(run); | |
| 404 | + | for (_, meta) in list(kv, &format!("a/{legacy_run}/")).await? { | |
| 405 | + | if !listed.iter().any(|a| a["name"] == meta.name.as_str()) { | |
| 406 | + | listed.push(json!({ "name": meta.name, "size": meta.size, "format": "tgz" })); | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | crate::reply(&listed) | |
| 410 | + | } | |
| 411 | + | Err(reply) => reply, | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ("POST", ["artifacts", "uploads"]) => { | |
| 415 | + | let flag = |name: &str| query(&request, name).is_some_and(|v| v == "true"); | |
| 416 | + | let args = ArtifactReserveArgs { | |
| 417 | + | job: job.to_owned(), | |
| 418 | + | token: token.to_owned(), | |
| 419 | + | name: query(&request, "name").unwrap_or_default(), | |
| 420 | + | size: query(&request, "size").and_then(|s| s.parse().ok()).unwrap_or(0), | |
| 421 | + | retention_days: query(&request, "retention_days").and_then(|d| d.parse().ok()).unwrap_or(0), | |
| 422 | + | expires_at: None, | |
| 423 | + | overwrite: flag("overwrite"), | |
| 424 | + | format: query(&request, "format"), | |
| 425 | + | }; | |
| 426 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 427 | + | let reserved = match refused(reserved) { | |
| 428 | + | Ok(reserved) => reserved, | |
| 429 | + | Err(reply) => return reply, | |
| 430 | + | }; | |
| 431 | + | let upload = bucket.create_multipart_upload(&reserved.object).execute().await?; | |
| 432 | + | crate::reply(&json!({ | |
| 433 | + | "id": reserved.id, | |
| 434 | + | "upload": upload.upload_id().await, | |
| 435 | + | "part_bytes": ARTIFACT_PART_BYTES, | |
| 436 | + | "retention_days": reserved.retention_days, | |
| 437 | + | "expires_at": reserved.expires_at, | |
| 438 | + | })) | |
| 439 | + | } | |
| 440 | + | ("PUT", ["artifacts", "uploads", id, part]) => { | |
| 441 | + | let (Ok(id), Ok(part)) = (id.parse::<u64>(), part.parse::<u16>()) else { | |
| 442 | + | return error(400, "A part is numbered from 1, of an artifact named by its number."); | |
| 443 | + | }; | |
| 444 | + | if part == 0 || upload_id.is_empty() { | |
| 445 | + | return error(400, "A part is numbered from 1, and names its upload."); | |
| 446 | + | } | |
| 447 | + | let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0); | |
| 448 | + | if length == 0 || length > ARTIFACT_PART_BYTES { | |
| 449 | + | return error(413, &format!("A part is 1 to {} MB, with its length.", ARTIFACT_PART_BYTES / 1_048_576)); | |
| 450 | + | } | |
| 451 | + | let Some(body) = request.inner().body() else { return error(400, "The part is empty.") }; | |
| 452 | + | let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?; | |
| 453 | + | let uploaded = upload.upload_part(part, body).await?; | |
| 454 | + | crate::reply(&json!({ "part": uploaded.part_number(), "etag": uploaded.etag() })) | |
| 455 | + | } | |
| 456 | + | ("POST", ["artifacts", "uploads", id, "complete"]) => { | |
| 457 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") }; | |
| 458 | + | let done: Value = request.json().await.unwrap_or(Value::Null); | |
| 459 | + | let mut parts: Vec<Part> = serde_json::from_value(done["parts"].clone()).unwrap_or_default(); | |
| 460 | + | if parts.is_empty() { | |
| 461 | + | return error(400, "Send { size, parts: [{ part, etag }], digest }."); | |
| 462 | + | } | |
| 463 | + | parts.sort_by_key(|p| p.part); | |
| 464 | + | let upload = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id)?; | |
| 465 | + | let object = match upload.complete(parts.into_iter().map(|p| UploadedPart::new(p.part, p.etag))).await { | |
| 466 | + | Ok(object) => object, | |
| 467 | + | Err(problem) => { | |
| 468 | + | let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?; | |
| 469 | + | return error(400, &format!("The upload could not be completed: {problem}")); | |
| 470 | + | } | |
| 471 | + | }; | |
| 472 | + | let args = ArtifactCommitArgs { | |
| 473 | + | job: job.to_owned(), | |
| 474 | + | token: token.to_owned(), | |
| 475 | + | id: Some(id), | |
| 476 | + | name: None, | |
| 477 | + | // What R2 holds, not what the runner says. | |
| 478 | + | size: object.size(), | |
| 479 | + | digest: done["digest"].as_str().map(str::to_owned), | |
| 480 | + | }; | |
| 481 | + | let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 482 | + | match refused(committed) { | |
| 483 | + | Ok(artifact) => crate::reply(&for_runner(&artifact)), | |
| 484 | + | Err(reply) => reply, | |
| 485 | + | } | |
| 486 | + | } | |
| 487 | + | ("DELETE", ["artifacts", "uploads", id]) => { | |
| 488 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such upload.") }; | |
| 489 | + | if let Ok(upload) = bucket.resume_multipart_upload(artifact_object(repo, id), &upload_id) { | |
| 490 | + | let _ = upload.abort().await; | |
| 491 | + | } | |
| 492 | + | let _: Outcome<bool> = g1t_kit::call(actions, "artifact_abort", &credential(Some(id), None, None)).await?; | |
| 493 | + | crate::reply(&json!({ "aborted": true })) | |
| 494 | + | } | |
| 495 | + | ("GET", ["artifacts", id, "download"]) => { | |
| 496 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") }; | |
| 497 | + | // Any run of the job's repository: the service checks. | |
| 498 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(Some(id), None, query(&request, "run_id"))).await?; | |
| 499 | + | match found { | |
| 500 | + | Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await, | |
| 501 | + | Outcome::Fail(failure) => error(failure.code.http_status(), &failure.message), | |
| 502 | + | } | |
| 503 | + | } | |
| 504 | + | ("DELETE", ["artifacts", id]) => { | |
| 505 | + | let Ok(id) = id.parse::<u64>() else { return error(404, "No such artifact.") }; | |
| 506 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &credential(Some(id), None, None)).await?; | |
| 507 | + | match refused(done) { | |
| 508 | + | Ok(artifact) => crate::reply(&for_runner(&artifact)), | |
| 509 | + | Err(reply) => reply, | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | // Older runners: a whole artifact of at most 60 MB, by name. | |
| 513 | + | ("PUT", ["artifacts", name]) => { | |
| 514 | + | let name = decode(name); | |
| 515 | + | let bytes = request.bytes().await?; | |
| 516 | + | if bytes.len() > MAX_BYTES { | |
| 517 | + | return error(413, "An artifact sent at once is at most 60 MB; newer runners upload it in parts."); | |
| 518 | + | } | |
| 519 | + | let args = ArtifactReserveArgs { | |
| 520 | + | job: job.to_owned(), | |
| 521 | + | token: token.to_owned(), | |
| 522 | + | name: name.clone(), | |
| 523 | + | size: bytes.len() as u64, | |
| 524 | + | format: Some("tgz".to_owned()), | |
| 525 | + | ..ArtifactReserveArgs::default() | |
| 526 | + | }; | |
| 527 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 528 | + | let reserved = match refused(reserved) { | |
| 529 | + | Ok(reserved) => reserved, | |
| 530 | + | Err(reply) => return reply, | |
| 531 | + | }; | |
| 532 | + | let size = bytes.len() as u64; | |
| 533 | + | bucket.put(&reserved.object, bytes).execute().await?; | |
| 534 | + | let args = ArtifactCommitArgs { job: job.to_owned(), token: token.to_owned(), id: Some(reserved.id), name: None, size, digest: None }; | |
| 535 | + | let committed: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 536 | + | match refused(committed) { | |
| 537 | + | Ok(_) => crate::reply(&json!({ "name": name, "size": size })), | |
| 538 | + | Err(reply) => reply, | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | ("GET", ["artifacts", name]) => { | |
| 542 | + | let name = decode(name); | |
| 543 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &credential(None, Some(name.clone()), None)).await?; | |
| 544 | + | match found { | |
| 545 | + | Outcome::Ok(found) => stream(bucket, &found.object, &found.artifact.format).await, | |
| 546 | + | // One an older runner kept in KV. | |
| 547 | + | Outcome::Fail(_) => match get(kv, &format!("a/{run}/{name}")).await? { | |
| 548 | + | Some(bytes) => Response::from_bytes(bytes), | |
| 549 | + | None => error(404, "No such artifact."), | |
| 550 | + | }, | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | _ => error(404, "No such endpoint."), | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 372 | 557 | /// Marks an uploaded entry ready, and deletes what that evicted. | |
| 373 | 558 | async fn commit(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<()> { | |
| 374 | 559 | let committed: Outcome<CacheCommitted> = g1t_kit::call( | |
| 428 | 613 | return error(status, &refused.message); | |
| 429 | 614 | } | |
| 430 | 615 | let name = decode(name); | |
| 616 | + | // Kept in R2: a redirect to a link signed for a few minutes. | |
| 617 | + | let args = ArtifactArgs { | |
| 618 | + | repo: g1t_contracts::repos::RepoPath { namespace: owner.to_owned(), name: repo.to_owned() }, | |
| 619 | + | viewer: viewer.clone(), | |
| 620 | + | id: None, | |
| 621 | + | run: Some(run.to_owned()), | |
| 622 | + | name: Some(name.clone()), | |
| 623 | + | }; | |
| 624 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(&services.actions, "artifact_download", &args).await?; | |
| 625 | + | if let Outcome::Ok(found) = found | |
| 626 | + | && !found.blob.is_empty() | |
| 627 | + | { | |
| 628 | + | return Response::redirect_with_status(Url::parse(&crate::artifacts::blob_url(&services.addresses.api, &found.blob))?, 302); | |
| 629 | + | } | |
| 630 | + | // Kept in KV by an older runner. | |
| 431 | 631 | match get(&store(env)?, &format!("a/{run}/{name}")).await? { | |
| 432 | 632 | Some(bytes) => { | |
| 433 | 633 | let mut response = Response::from_bytes(bytes)?; | |
| 440 | 640 | } | |
| 441 | 641 | } | |
| 442 | 642 | ||
| 443 | − | /// A run's artifacts, for its page. | |
| 444 | − | pub async fn of_run(env: &Env, run: &str) -> Result<Vec<Value>> { | |
| 445 | − | Ok(list(&store(env)?, &format!("a/{run}/")) | |
| 446 | − | .await? | |
| 447 | − | .into_iter() | |
| 448 | − | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size, "at": meta.at })) | |
| 449 | − | .collect()) | |
| 450 | − | } |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | + | mod artifacts; | |
| 8 | 9 | mod addresses; | |
| 9 | 10 | mod alerts; | |
| 10 | 11 | mod audit; | |
| 15 | 16 | mod mcp; | |
| 16 | 17 | mod notifications; | |
| 17 | 18 | mod oauth; | |
| 19 | + | mod oidc; | |
| 18 | 20 | mod openapi; | |
| 19 | 21 | mod pins; | |
| 20 | 22 | mod projects; | |
| 27 | 29 | mod runners; | |
| 28 | 30 | mod security; | |
| 29 | 31 | mod tools; | |
| 32 | + | mod toolkit; | |
| 30 | 33 | ||
| 31 | 34 | use g1t_contracts::billing::{FinishRunArgs, RunTokens}; | |
| 32 | 35 | use g1t_contracts::identity::{ | |
| 72 | 75 | "spec", "workflow", "github", "event", "contexts", "checkout", | |
| 73 | 76 | ]; | |
| 74 | 77 | ||
| 78 | + | /// Puts the toolkit's variables in a job's spec: its runtime token, where | |
| 79 | + | /// the toolkit's services are, and where to ask for an OIDC token when the | |
| 80 | + | /// job may have one and this installation issues them. The `runtime` the | |
| 81 | + | /// actions service sent goes no further. | |
| 82 | + | fn with_runtime(spec: &mut Value, api: &str, oidc: bool) { | |
| 83 | + | let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return }; | |
| 84 | + | let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return }; | |
| 85 | + | let id_token = oidc && runtime["id_token"].as_bool() == Some(true); | |
| 86 | + | let vars = toolkit::runtime_variables(api, token, id_token); | |
| 87 | + | if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) { | |
| 88 | + | variables.extend(vars); | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 75 | 92 | /// An error in the shape every endpoint uses. | |
| 76 | 93 | fn failure(failure: &Failure) -> Result<Response> { | |
| 77 | 94 | Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status())) | |
| 546 | 563 | })); | |
| 547 | 564 | } | |
| 548 | 565 | ||
| 566 | + | // The services GitHub's toolkit calls from inside a job, with its | |
| 567 | + | // runtime token, and the links they hand out (toolkit.rs). | |
| 568 | + | if !on_mcp && method == "POST" | |
| 569 | + | && let Some(rest) = path.strip_prefix("/twirp/") | |
| 570 | + | { | |
| 571 | + | let (service, rpc) = rest.split_once('/').unwrap_or((rest, "")); | |
| 572 | + | let (service, rpc) = (service.to_owned(), rpc.to_owned()); | |
| 573 | + | return toolkit::twirp(request, env, &services, &service, &rpc).await; | |
| 574 | + | } | |
| 575 | + | if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") { | |
| 576 | + | let rest = rest.to_owned(); | |
| 577 | + | return toolkit::cache_v1(request, env, &services, method, &rest).await; | |
| 578 | + | } | |
| 579 | + | if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") { | |
| 580 | + | let token = token.to_owned(); | |
| 581 | + | return toolkit::blob(request, env, &services, method, &token).await; | |
| 582 | + | } | |
| 583 | + | // g1t as an OIDC issuer for workflow jobs (oidc.rs). | |
| 584 | + | if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") { | |
| 585 | + | return oidc::handle(&request, env, &services, &path).await; | |
| 586 | + | } | |
| 587 | + | ||
| 549 | 588 | // A sandbox's artifacts and cache, with its job's token, which is not a | |
| 550 | 589 | // g1t token either. | |
| 551 | 590 | if !on_mcp | |
| 602 | 641 | match (method, path.trim_end_matches('/')) { | |
| 603 | 642 | ("GET", "") => return reply(&index(&services.addresses)), | |
| 604 | 643 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 605 | − | // A run's artifacts: listed, or one downloaded. | |
| 606 | − | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => { | |
| 644 | + | // One of a run's artifacts downloaded by name (the run's artifacts | |
| 645 | + | // are listed by the REST route in rest.rs). | |
| 646 | + | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => { | |
| 607 | 647 | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); | |
| 608 | − | if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() { | |
| 609 | − | return match rest { | |
| 610 | − | [] => { | |
| 611 | − | let seen: Outcome<Value> = g1t_kit::call( | |
| 612 | − | &services.actions, | |
| 613 | − | "run", | |
| 614 | − | &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }), | |
| 615 | − | ) | |
| 616 | − | .await?; | |
| 617 | − | match seen { | |
| 618 | − | Outcome::Ok(_) => reply(&blobs::of_run(env, run).await?), | |
| 619 | − | Outcome::Fail(refused) => failure(&refused), | |
| 620 | − | } | |
| 621 | − | } | |
| 622 | − | [name] => blobs::download(env, &services, &viewer, owner, repo, run, name).await, | |
| 623 | − | _ => fail(FailureCode::NotFound, "No such endpoint."), | |
| 624 | − | }; | |
| 648 | + | if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() { | |
| 649 | + | return blobs::download(env, &services, &viewer, owner, repo, run, name).await; | |
| 625 | 650 | } | |
| 626 | 651 | } | |
| 627 | 652 | ("POST", "/device/code") => return device_code(&mut request, &services).await, | |
| 670 | 695 | return match answered { | |
| 671 | 696 | // A job's spec is the workflow and its contexts as GitHub | |
| 672 | 697 | // has them; only g1t's own keys around them are converted. | |
| 673 | − | Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN)), | |
| 698 | + | Outcome::Ok(value) => { | |
| 699 | + | let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN); | |
| 700 | + | with_runtime(&mut spec, &services.addresses.api, oidc::configured(env)); | |
| 701 | + | Response::from_json(&spec) | |
| 702 | + | } | |
| 674 | 703 | Outcome::Fail(refused) => failure(&refused), | |
| 675 | 704 | }; | |
| 676 | 705 | } | |
| 758 | 787 | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 759 | 788 | }; | |
| 760 | 789 | match audit::run(route.op, &services, &viewer, &input).await? { | |
| 790 | + | // A download is a redirect to its signed link, as GitHub's is. | |
| 791 | + | Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => { | |
| 792 | + | match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) { | |
| 793 | + | Some(url) => Response::redirect_with_status(url, 302), | |
| 794 | + | None => reply(&value), | |
| 795 | + | } | |
| 796 | + | } | |
| 761 | 797 | Outcome::Ok(value) => reply(&value), | |
| 762 | 798 | // A token without the scope a call needs is told which one. | |
| 763 | 799 | Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) { | |
| 806 | 842 | use serde_json::json; | |
| 807 | 843 | ||
| 808 | 844 | #[test] | |
| 845 | + | fn a_job_spec_gets_the_toolkits_variables() { | |
| 846 | + | // As the actions service sends it, converted as the API does. | |
| 847 | + | let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } }); | |
| 848 | + | let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN); | |
| 849 | + | super::with_runtime(&mut spec, "https://api.g1t.sh", true); | |
| 850 | + | assert!(spec.get("runtime").is_none(), "the runner never sees it"); | |
| 851 | + | let vars = &spec["variables"]; | |
| 852 | + | assert_eq!(vars["GITHUB_SHA"], "abc"); | |
| 853 | + | assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s"); | |
| 854 | + | assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/"); | |
| 855 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s"); | |
| 856 | + | // No OIDC key here: no OIDC variables, whatever the job may do. | |
| 857 | + | let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN); | |
| 858 | + | super::with_runtime(&mut spec, "https://api.g1t.sh", false); | |
| 859 | + | assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none()); | |
| 860 | + | assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/"); | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | #[test] | |
| 809 | 864 | fn camel_case_keys_are_accepted() { | |
| 810 | 865 | assert_eq!( | |
| 811 | 866 | snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })), |
| 1 | + | //! OIDC tokens for workflow jobs: g1t as an OpenID Connect issuer, so a | |
| 2 | + | //! job with `permissions: id-token: write` can trade a short-lived token | |
| 3 | + | //! for a cloud provider's credentials instead of keeping a long-lived key | |
| 4 | + | //! in a secret. | |
| 5 | + | //! | |
| 6 | + | //! - The issuer is `{API}/actions/oidc` (`https://api.g1t.sh/actions/oidc` | |
| 7 | + | //! hosted): its discovery document at | |
| 8 | + | //! `/.well-known/openid-configuration` under it, and its keys at | |
| 9 | + | //! `/.well-known/jwks`. No host of its own: the API's. | |
| 10 | + | //! - A job asks `GET {issuer}/token?api-version=2.0&audience=…` with its | |
| 11 | + | //! runtime token (`ACTIONS_ID_TOKEN_REQUEST_URL` and `…_TOKEN`, as the | |
| 12 | + | //! toolkit's `core.getIDToken` reads them) and gets `{ "value": jwt }`. | |
| 13 | + | //! - Tokens are RS256, good for five minutes, with GitHub's claims (the | |
| 14 | + | //! actions service decides them and whether the job may have one). | |
| 15 | + | //! - The signing key is the Worker secret `ACTIONS_OIDC_KEY`, an RSA | |
| 16 | + | //! private key in PEM (PKCS#8 or PKCS#1). `ACTIONS_OIDC_KEY_PREVIOUS`, | |
| 17 | + | //! while it is set, is published too, so tokens it signed still verify | |
| 18 | + | //! while the new key takes over. Each key's `kid` is its RFC 7638 | |
| 19 | + | //! thumbprint. docs/DEPLOYING.md says how to make and rotate them. | |
| 20 | + | ||
| 21 | + | use base64::Engine; | |
| 22 | + | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 23 | + | use g1t_contracts::actions::RuntimeAuthArgs; | |
| 24 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 25 | + | use g1t_kit::js; | |
| 26 | + | use serde_json::{Value, json}; | |
| 27 | + | use sha2::{Digest, Sha256}; | |
| 28 | + | use worker::js_sys::{self, Uint8Array}; | |
| 29 | + | use worker::{Env, Error, Request, Response, Result}; | |
| 30 | + | ||
| 31 | + | use crate::operations::Services; | |
| 32 | + | ||
| 33 | + | /// How long a token is good for. | |
| 34 | + | const LIFETIME_SECONDS: u64 = 5 * 60; | |
| 35 | + | pub const KEY_SECRET: &str = "ACTIONS_OIDC_KEY"; | |
| 36 | + | pub const PREVIOUS_KEY_SECRET: &str = "ACTIONS_OIDC_KEY_PREVIOUS"; | |
| 37 | + | ||
| 38 | + | /// The issuer, under the API's address. | |
| 39 | + | pub fn issuer(api: &str) -> String { | |
| 40 | + | format!("{api}/actions/oidc") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// The OpenID Provider configuration, as relying parties fetch it. | |
| 44 | + | pub fn discovery(api: &str) -> Value { | |
| 45 | + | let issuer = issuer(api); | |
| 46 | + | json!({ | |
| 47 | + | "issuer": issuer, | |
| 48 | + | "jwks_uri": format!("{issuer}/.well-known/jwks"), | |
| 49 | + | "subject_types_supported": ["public", "pairwise"], | |
| 50 | + | "response_types_supported": ["id_token"], | |
| 51 | + | "claims_supported": [ | |
| 52 | + | "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "ref", "sha", "repository", "repository_id", "repository_owner", | |
| 53 | + | "repository_owner_id", "repository_visibility", "run_id", "run_number", "run_attempt", "actor", "actor_id", "workflow", | |
| 54 | + | "workflow_ref", "workflow_sha", "job_workflow_ref", "job_workflow_sha", "head_ref", "base_ref", "event_name", "ref_type", | |
| 55 | + | "ref_protected", "environment", "runner_environment" | |
| 56 | + | ], | |
| 57 | + | "id_token_signing_alg_values_supported": ["RS256"], | |
| 58 | + | "scopes_supported": ["openid"], | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // ── Keys ──────────────────────────────────────────────────────────────────── | |
| 63 | + | ||
| 64 | + | /// A DER element: its tag, and its contents; and what follows it. | |
| 65 | + | fn der(input: &[u8]) -> Option<(u8, &[u8], &[u8])> { | |
| 66 | + | let (&tag, rest) = input.split_first()?; | |
| 67 | + | let (&first, rest) = rest.split_first()?; | |
| 68 | + | let (length, rest) = if first < 0x80 { | |
| 69 | + | (first as usize, rest) | |
| 70 | + | } else { | |
| 71 | + | let count = (first & 0x7f) as usize; | |
| 72 | + | if count == 0 || count > 4 || rest.len() < count { | |
| 73 | + | return None; | |
| 74 | + | } | |
| 75 | + | let length = rest[..count].iter().fold(0usize, |n, b| (n << 8) | *b as usize); | |
| 76 | + | (length, &rest[count..]) | |
| 77 | + | }; | |
| 78 | + | if rest.len() < length { | |
| 79 | + | return None; | |
| 80 | + | } | |
| 81 | + | Some((tag, &rest[..length], &rest[length..])) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// An INTEGER's magnitude, without the sign byte DER may put in front. | |
| 85 | + | fn unsigned(bytes: &[u8]) -> &[u8] { | |
| 86 | + | let mut bytes = bytes; | |
| 87 | + | while bytes.len() > 1 && bytes[0] == 0 { | |
| 88 | + | bytes = &bytes[1..]; | |
| 89 | + | } | |
| 90 | + | bytes | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// The modulus and public exponent of an RSA private key: PKCS#1 | |
| 94 | + | /// `RSAPrivateKey`, or PKCS#8 `PrivateKeyInfo` holding one. | |
| 95 | + | pub fn public_numbers(key: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> { | |
| 96 | + | let (0x30, body, _) = der(key)? else { return None }; | |
| 97 | + | let (0x02, _version, rest) = der(body)? else { return None }; | |
| 98 | + | let rsa = match der(rest)? { | |
| 99 | + | // PKCS#8: the algorithm, then the key in an OCTET STRING. | |
| 100 | + | (0x30, _algorithm, after) => { | |
| 101 | + | let (0x04, inner, _) = der(after)? else { return None }; | |
| 102 | + | let (0x30, rsa, _) = der(inner)? else { return None }; | |
| 103 | + | let (0x02, _version, rsa) = der(rsa)? else { return None }; | |
| 104 | + | rsa | |
| 105 | + | } | |
| 106 | + | // PKCS#1: the modulus is next. | |
| 107 | + | (0x02, _, _) => rest, | |
| 108 | + | _ => return None, | |
| 109 | + | }; | |
| 110 | + | let (0x02, n, rsa) = der(rsa)? else { return None }; | |
| 111 | + | let (0x02, e, _) = der(rsa)? else { return None }; | |
| 112 | + | Some((unsigned(n).to_vec(), unsigned(e).to_vec())) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A DER length. | |
| 116 | + | fn der_length(length: usize) -> Vec<u8> { | |
| 117 | + | if length < 0x80 { | |
| 118 | + | return vec![length as u8]; | |
| 119 | + | } | |
| 120 | + | let bytes: Vec<u8> = length.to_be_bytes().into_iter().skip_while(|byte| *byte == 0).collect(); | |
| 121 | + | let mut out = vec![0x80 | bytes.len() as u8]; | |
| 122 | + | out.extend(bytes); | |
| 123 | + | out | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Wraps a PKCS#1 key in PKCS#8, which is all WebCrypto imports. | |
| 127 | + | fn pkcs1_to_pkcs8(pkcs1: &[u8]) -> Vec<u8> { | |
| 128 | + | const RSA_ALGORITHM: [u8; 15] = [0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]; | |
| 129 | + | let mut octets = vec![0x04]; | |
| 130 | + | octets.extend(der_length(pkcs1.len())); | |
| 131 | + | octets.extend_from_slice(pkcs1); | |
| 132 | + | let mut body = vec![0x02, 0x01, 0x00]; | |
| 133 | + | body.extend_from_slice(&RSA_ALGORITHM); | |
| 134 | + | body.extend(octets); | |
| 135 | + | let mut out = vec![0x30]; | |
| 136 | + | out.extend(der_length(body.len())); | |
| 137 | + | out.extend(body); | |
| 138 | + | out | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// A signing key: its PKCS#8 DER, and its public half as a JWK. | |
| 142 | + | pub struct SigningKey { | |
| 143 | + | pub pkcs8: Vec<u8>, | |
| 144 | + | pub jwk: Value, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | impl SigningKey { | |
| 148 | + | /// From PEM, either form; line breaks pasted as `\n` are read too. | |
| 149 | + | pub fn from_pem(pem: &str) -> std::result::Result<SigningKey, String> { | |
| 150 | + | let pem = pem.replace("\\n", "\n"); | |
| 151 | + | let pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY"); | |
| 152 | + | if !pkcs1 && !pem.contains("BEGIN PRIVATE KEY") { | |
| 153 | + | return Err(format!("{KEY_SECRET} is not a PEM RSA private key.")); | |
| 154 | + | } | |
| 155 | + | let body: String = pem.lines().filter(|l| !l.starts_with("-----")).flat_map(str::chars).filter(|c| !c.is_whitespace()).collect(); | |
| 156 | + | let der = STANDARD.decode(body).map_err(|_| format!("{KEY_SECRET} is not valid base64."))?; | |
| 157 | + | let (n, e) = public_numbers(&der).ok_or_else(|| format!("{KEY_SECRET} is not an RSA key."))?; | |
| 158 | + | let pkcs8 = if pkcs1 { pkcs1_to_pkcs8(&der) } else { der }; | |
| 159 | + | Ok(SigningKey { pkcs8, jwk: jwk(&n, &e) }) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | pub fn kid(&self) -> String { | |
| 163 | + | self.jwk["kid"].as_str().unwrap_or_default().to_owned() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The public JWK of a key, its `kid` the RFC 7638 thumbprint. | |
| 168 | + | pub fn jwk(n: &[u8], e: &[u8]) -> Value { | |
| 169 | + | let (n, e) = (URL_SAFE_NO_PAD.encode(n), URL_SAFE_NO_PAD.encode(e)); | |
| 170 | + | // RFC 7638: the required members, in lexicographic order, no spaces. | |
| 171 | + | let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#); | |
| 172 | + | let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())); | |
| 173 | + | json!({ "kty": "RSA", "alg": "RS256", "use": "sig", "kid": kid, "n": n, "e": e }) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// The keys configured: the current one first. | |
| 177 | + | pub fn keys(env: &Env) -> (Option<std::result::Result<SigningKey, String>>, Option<SigningKey>) { | |
| 178 | + | let read = |name: &str| env.secret(name).ok().map(|s| s.to_string()).filter(|s| !s.trim().is_empty()); | |
| 179 | + | let current = read(KEY_SECRET).map(|pem| SigningKey::from_pem(&pem)); | |
| 180 | + | let previous = read(PREVIOUS_KEY_SECRET).and_then(|pem| SigningKey::from_pem(&pem).ok()); | |
| 181 | + | (current, previous) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Whether this installation can issue OIDC tokens. | |
| 185 | + | pub fn configured(env: &Env) -> bool { | |
| 186 | + | matches!(keys(env).0, Some(Ok(_))) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | pub fn jwks(current: Option<&SigningKey>, previous: Option<&SigningKey>) -> Value { | |
| 190 | + | json!({ "keys": current.into_iter().chain(previous).map(|k| k.jwk.clone()).collect::<Vec<_>>() }) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // ── Tokens ────────────────────────────────────────────────────────────────── | |
| 194 | + | ||
| 195 | + | /// The token's claims: what the actions service said about the job, and | |
| 196 | + | /// who issued it, for whom and when. | |
| 197 | + | pub fn full_claims(mut claims: Value, issuer: &str, audience: &str, jti: &str, now: u64) -> Value { | |
| 198 | + | claims["iss"] = json!(issuer); | |
| 199 | + | claims["aud"] = json!(audience); | |
| 200 | + | claims["jti"] = json!(jti); | |
| 201 | + | claims["iat"] = json!(now); | |
| 202 | + | claims["nbf"] = json!(now.saturating_sub(60)); | |
| 203 | + | claims["exp"] = json!(now + LIFETIME_SECONDS); | |
| 204 | + | claims | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// The header and claims, base64url-encoded and joined: what is signed. | |
| 208 | + | pub fn signing_input(kid: &str, claims: &Value) -> String { | |
| 209 | + | let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid }); | |
| 210 | + | format!("{}.{}", URL_SAFE_NO_PAD.encode(header.to_string()), URL_SAFE_NO_PAD.encode(claims.to_string())) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// RSASSA-PKCS1-v1_5 with SHA-256, by WebCrypto. | |
| 214 | + | async fn sign_rs256(pkcs8: &[u8], data: &[u8]) -> Result<Vec<u8>> { | |
| 215 | + | let subtle = js::get(&js::get(&js_sys::global(), "crypto"), "subtle"); | |
| 216 | + | let algorithm = js::to_js(&json!({ "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }))?; | |
| 217 | + | let usages = js::to_js(&json!(["sign"]))?; | |
| 218 | + | let key = js::call(&subtle, "importKey", &["pkcs8".into(), Uint8Array::from(pkcs8).into(), algorithm.clone(), false.into(), usages]) | |
| 219 | + | .await | |
| 220 | + | .map_err(|thrown| Error::RustError(format!("{KEY_SECRET} could not be used: {thrown}")))?; | |
| 221 | + | let signature = js::call(&subtle, "sign", &[algorithm, key, Uint8Array::from(data).into()]).await?; | |
| 222 | + | Ok(Uint8Array::new(&signature).to_vec()) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | fn error(status: u16, message: &str) -> Result<Response> { | |
| 226 | + | Ok(crate::reply(&json!({ "error": { "message": message } }))?.with_status(status)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// `/actions/oidc/…`: discovery, keys, and a job's token. | |
| 230 | + | pub async fn handle(request: &Request, env: &Env, services: &Services, path: &str) -> Result<Response> { | |
| 231 | + | let api = services.addresses.api.clone(); | |
| 232 | + | let (current, previous) = keys(env); | |
| 233 | + | let current = match current { | |
| 234 | + | Some(Ok(key)) => key, | |
| 235 | + | Some(Err(problem)) => { | |
| 236 | + | worker::console_error!("oidc: {problem}"); | |
| 237 | + | return error(503, "OIDC tokens are not set up on this installation."); | |
| 238 | + | } | |
| 239 | + | None => return error(404, "OIDC tokens are not set up on this installation."), | |
| 240 | + | }; | |
| 241 | + | let cached = |value: &Value| -> Result<Response> { | |
| 242 | + | let mut response = Response::from_json(value)?; | |
| 243 | + | response.headers_mut().set("cache-control", "public, max-age=300")?; | |
| 244 | + | Ok(response) | |
| 245 | + | }; | |
| 246 | + | match path { | |
| 247 | + | "/actions/oidc/.well-known/openid-configuration" => cached(&discovery(&api)), | |
| 248 | + | "/actions/oidc/.well-known/jwks" => cached(&jwks(Some(¤t), previous.as_ref())), | |
| 249 | + | "/actions/oidc/token" => { | |
| 250 | + | let token = crate::toolkit::bearer(request); | |
| 251 | + | let Some(job) = crate::toolkit::runtime_job(&token) else { | |
| 252 | + | return error(401, "Send the job's ACTIONS_ID_TOKEN_REQUEST_TOKEN as a bearer token."); | |
| 253 | + | }; | |
| 254 | + | let claims: Outcome<Value> = g1t_kit::call(&services.actions, "oidc_claims", &RuntimeAuthArgs { job, token }).await?; | |
| 255 | + | let claims = match claims { | |
| 256 | + | Outcome::Ok(claims) => claims, | |
| 257 | + | Outcome::Fail(refused) => { | |
| 258 | + | let status = match refused.code { | |
| 259 | + | FailureCode::Unauthenticated => 401, | |
| 260 | + | FailureCode::Forbidden => 403, | |
| 261 | + | _ => 404, | |
| 262 | + | }; | |
| 263 | + | return error(status, &refused.message); | |
| 264 | + | } | |
| 265 | + | }; | |
| 266 | + | let url = request.url()?; | |
| 267 | + | let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned(); | |
| 268 | + | let audience = url | |
| 269 | + | .query_pairs() | |
| 270 | + | .find(|(k, _)| k == "audience") | |
| 271 | + | .map(|(_, v)| v.into_owned()) | |
| 272 | + | .filter(|a| !a.trim().is_empty()) | |
| 273 | + | // GitHub's default: the owner's address. | |
| 274 | + | .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site)); | |
| 275 | + | let now = g1t_kit::now_ms() / 1000; | |
| 276 | + | let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms()); | |
| 277 | + | let claims = full_claims(claims, &issuer(&api), &audience, &jti, now); | |
| 278 | + | let input = signing_input(¤t.kid(), &claims); | |
| 279 | + | let signature = sign_rs256(¤t.pkcs8, input.as_bytes()).await?; | |
| 280 | + | let value = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)); | |
| 281 | + | Response::from_json(&json!({ "count": value.len(), "value": value })) | |
| 282 | + | } | |
| 283 | + | _ => error(404, "No such endpoint."), | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | #[cfg(test)] | |
| 288 | + | mod tests { | |
| 289 | + | use super::*; | |
| 290 | + | use std::process::Command; | |
| 291 | + | ||
| 292 | + | #[test] | |
| 293 | + | fn discovery_names_the_issuer_and_its_keys() { | |
| 294 | + | let doc = discovery("https://api.g1t.sh"); | |
| 295 | + | assert_eq!(doc["issuer"], "https://api.g1t.sh/actions/oidc"); | |
| 296 | + | assert_eq!(doc["jwks_uri"], "https://api.g1t.sh/actions/oidc/.well-known/jwks"); | |
| 297 | + | assert_eq!(doc["id_token_signing_alg_values_supported"], json!(["RS256"])); | |
| 298 | + | assert!(doc["claims_supported"].as_array().unwrap().contains(&json!("job_workflow_ref"))); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn a_thumbprint_is_rfc_7638s() { | |
| 303 | + | // RFC 7638, section 3.1: the example key and its thumbprint. | |
| 304 | + | let n = URL_SAFE_NO_PAD | |
| 305 | + | .decode("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw") | |
| 306 | + | .unwrap(); | |
| 307 | + | let key = jwk(&n, &[1, 0, 1]); | |
| 308 | + | assert_eq!(key["e"], "AQAB"); | |
| 309 | + | assert_eq!(key["kid"], "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn claims_get_who_issued_them_and_a_short_life() { | |
| 314 | + | let claims = full_claims(json!({ "sub": "repo:acme/web:ref:refs/heads/main" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "oidc_1", 1_700_000_000); | |
| 315 | + | assert_eq!(claims["iss"], "https://api.g1t.sh/actions/oidc"); | |
| 316 | + | assert_eq!(claims["aud"], "sts.amazonaws.com"); | |
| 317 | + | assert_eq!(claims["exp"].as_u64().unwrap() - claims["iat"].as_u64().unwrap(), LIFETIME_SECONDS); | |
| 318 | + | assert!(claims["nbf"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap()); | |
| 319 | + | let input = signing_input("kid1", &claims); | |
| 320 | + | let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(input.split('.').next().unwrap()).unwrap()).unwrap(); | |
| 321 | + | assert_eq!((header["alg"].as_str(), header["kid"].as_str()), (Some("RS256"), Some("kid1"))); | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | #[test] | |
| 325 | + | fn a_key_that_is_not_one_is_refused() { | |
| 326 | + | assert!(SigningKey::from_pem("hello").is_err()); | |
| 327 | + | let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----")); | |
| 328 | + | assert!(SigningKey::from_pem(&pem).is_err()); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | fn openssl(args: &[&str]) -> Option<std::process::Output> { | |
| 332 | + | Command::new("openssl").args(args).output().ok().filter(|o| o.status.success()) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// With openssl on the machine: a key made here, read in both PEM | |
| 336 | + | /// forms, gives the modulus openssl gives, and a token signed with it | |
| 337 | + | /// (by openssl, as WebCrypto is not here) verifies against the public | |
| 338 | + | /// key built from the JWKS. | |
| 339 | + | #[test] | |
| 340 | + | fn a_real_key_signs_tokens_its_jwks_verifies() { | |
| 341 | + | let dir = std::env::temp_dir().join(format!("g1t-oidc-test-{}", std::process::id())); | |
| 342 | + | let _ = std::fs::create_dir_all(&dir); | |
| 343 | + | let path = |name: &str| dir.join(name).display().to_string(); | |
| 344 | + | if openssl(&["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", &path("key.pem")]).is_none() { | |
| 345 | + | eprintln!("openssl is not here; skipped"); | |
| 346 | + | return; | |
| 347 | + | } | |
| 348 | + | let pem = std::fs::read_to_string(path("key.pem")).unwrap(); | |
| 349 | + | let key = SigningKey::from_pem(&pem).unwrap(); | |
| 350 | + | // The modulus is openssl's. | |
| 351 | + | let modulus = openssl(&["rsa", "-in", &path("key.pem"), "-noout", "-modulus"]).unwrap(); | |
| 352 | + | let modulus = String::from_utf8_lossy(&modulus.stdout).trim().trim_start_matches("Modulus=").to_lowercase(); | |
| 353 | + | let n = URL_SAFE_NO_PAD.decode(key.jwk["n"].as_str().unwrap()).unwrap(); | |
| 354 | + | assert_eq!(n.iter().map(|b| format!("{b:02x}")).collect::<String>(), modulus); | |
| 355 | + | assert_eq!(key.jwk["e"], "AQAB"); | |
| 356 | + | // The traditional form reads to the same key. | |
| 357 | + | if openssl(&["rsa", "-in", &path("key.pem"), "-traditional", "-out", &path("key1.pem")]).is_some() { | |
| 358 | + | let pkcs1 = std::fs::read_to_string(path("key1.pem")).unwrap(); | |
| 359 | + | if pkcs1.contains("BEGIN RSA PRIVATE KEY") { | |
| 360 | + | let again = SigningKey::from_pem(&pkcs1).unwrap(); | |
| 361 | + | assert_eq!(again.kid(), key.kid()); | |
| 362 | + | assert_eq!(again.pkcs8, key.pkcs8, "PKCS#1 is wrapped as openssl writes PKCS#8"); | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | // Sign the token's input with openssl, verify with the JWKS's key. | |
| 366 | + | let claims = full_claims(json!({ "sub": "repo:acme/web:environment:prod" }), "https://api.g1t.sh/actions/oidc", "sts.amazonaws.com", "j", 1_700_000_000); | |
| 367 | + | let input = signing_input(&key.kid(), &claims); | |
| 368 | + | std::fs::write(path("input"), &input).unwrap(); | |
| 369 | + | openssl(&["dgst", "-sha256", "-sign", &path("key.pem"), "-out", &path("sig"), &path("input")]).unwrap(); | |
| 370 | + | // The public key from n and e alone: RSAPublicKey DER. | |
| 371 | + | let integer = |bytes: &[u8]| { | |
| 372 | + | let mut value = bytes.to_vec(); | |
| 373 | + | if value[0] & 0x80 != 0 { | |
| 374 | + | value.insert(0, 0); | |
| 375 | + | } | |
| 376 | + | let mut out = vec![0x02]; | |
| 377 | + | out.extend(der_length(value.len())); | |
| 378 | + | out.extend(value); | |
| 379 | + | out | |
| 380 | + | }; | |
| 381 | + | let mut body = integer(&n); | |
| 382 | + | body.extend(integer(&URL_SAFE_NO_PAD.decode(key.jwk["e"].as_str().unwrap()).unwrap())); | |
| 383 | + | let mut public = vec![0x30]; | |
| 384 | + | public.extend(der_length(body.len())); | |
| 385 | + | public.extend(body); | |
| 386 | + | std::fs::write(path("public.der"), &public).unwrap(); | |
| 387 | + | let checked = openssl(&["rsa", "-RSAPublicKey_in", "-inform", "DER", "-in", &path("public.der"), "-pubout", "-out", &path("public.pem")]); | |
| 388 | + | assert!(checked.is_some(), "openssl reads the public key built from the JWKS"); | |
| 389 | + | let verified = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]); | |
| 390 | + | assert!(verified.is_some(), "the JWKS key verifies the token's signature"); | |
| 391 | + | // And not a token whose claims were changed. | |
| 392 | + | std::fs::write(path("input"), format!("{input}A")).unwrap(); | |
| 393 | + | let forged = openssl(&["dgst", "-sha256", "-verify", &path("public.pem"), "-signature", &path("sig"), &path("input")]); | |
| 394 | + | assert!(forged.is_none()); | |
| 395 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 396 | + | } | |
| 397 | + | } |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | + | use crate::artifacts::ArtifactsOp; | |
| 11 | 12 | use crate::deployments::DeploymentsOp; | |
| 12 | 13 | use crate::operations::Op; | |
| 13 | 14 | use crate::checks::ChecksOp; | |
| 366 | 367 | Op::CancelWorkflowRun, | |
| 367 | 368 | Op::RerunWorkflowRun, | |
| 368 | 369 | Op::UpdateWorkflow, | |
| 370 | + | Op::Artifacts(ArtifactsOp::ListArtifacts), | |
| 371 | + | Op::Artifacts(ArtifactsOp::ListRunArtifacts), | |
| 372 | + | Op::Artifacts(ArtifactsOp::GetArtifact), | |
| 373 | + | Op::Artifacts(ArtifactsOp::DownloadArtifact), | |
| 374 | + | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 375 | + | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 376 | + | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 369 | 377 | ], | |
| 370 | 378 | ), | |
| 371 | 379 | ( | |
| 633 | 641 | Op::Checks(op) => op.title(), | |
| 634 | 642 | Op::About(op) => op.title(), | |
| 635 | 643 | Op::Deployments(op) => op.title(), | |
| 644 | + | Op::Artifacts(op) => op.title(), | |
| 636 | 645 | } | |
| 637 | 646 | } | |
| 638 | 647 |
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | + | use crate::artifacts::ArtifactsOp; | |
| 31 | 32 | use crate::deployments::DeploymentsOp; | |
| 32 | 33 | use crate::rules::RulesOp; | |
| 33 | 34 | use crate::security::SecurityOp; | |
| 283 | 284 | About(AboutOp), | |
| 284 | 285 | /// Deployments wherever they run, and environments: deployments.rs. | |
| 285 | 286 | Deployments(DeploymentsOp), | |
| 287 | + | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 288 | + | Artifacts(ArtifactsOp), | |
| 286 | 289 | } | |
| 287 | 290 | ||
| 288 | 291 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 645 | 648 | } | |
| 646 | 649 | ||
| 647 | 650 | impl Op { | |
| 648 | − | pub const ALL: [Op; 257] = [ | |
| 651 | + | pub const ALL: [Op; 264] = [ | |
| 649 | 652 | Op::Whoami, | |
| 650 | 653 | Op::GetWorkspace, | |
| 651 | 654 | Op::CreateWorkspace, | |
| 903 | 906 | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 904 | 907 | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 905 | 908 | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 909 | + | Op::Artifacts(ArtifactsOp::ListArtifacts), | |
| 910 | + | Op::Artifacts(ArtifactsOp::ListRunArtifacts), | |
| 911 | + | Op::Artifacts(ArtifactsOp::GetArtifact), | |
| 912 | + | Op::Artifacts(ArtifactsOp::DownloadArtifact), | |
| 913 | + | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 914 | + | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 915 | + | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 906 | 916 | ]; | |
| 907 | 917 | ||
| 908 | 918 | pub fn by_name(name: &str) -> Option<Op> { | |
| 1096 | 1106 | Op::Checks(op) => op.name(), | |
| 1097 | 1107 | Op::About(op) => op.name(), | |
| 1098 | 1108 | Op::Deployments(op) => op.name(), | |
| 1109 | + | Op::Artifacts(op) => op.name(), | |
| 1099 | 1110 | } | |
| 1100 | 1111 | } | |
| 1101 | 1112 | ||
| 1609 | 1620 | Op::Checks(op) => op.description(), | |
| 1610 | 1621 | Op::About(op) => op.description(), | |
| 1611 | 1622 | Op::Deployments(op) => op.description(), | |
| 1623 | + | Op::Artifacts(op) => op.description(), | |
| 1612 | 1624 | } | |
| 1613 | 1625 | } | |
| 1614 | 1626 | ||
| 2980 | 2992 | Op::Checks(op) => op.input(), | |
| 2981 | 2993 | Op::About(op) => op.input(), | |
| 2982 | 2994 | Op::Deployments(op) => op.input(), | |
| 2995 | + | Op::Artifacts(op) => op.input(), | |
| 2983 | 2996 | } | |
| 2984 | 2997 | } | |
| 2985 | 2998 | ||
| 2992 | 3005 | if let Op::About(op) = self { | |
| 2993 | 3006 | return !op.anonymous(); | |
| 2994 | 3007 | } | |
| 3008 | + | // A public repository's artifacts are anyone's to read. | |
| 3009 | + | if let Op::Artifacts(op) = self { | |
| 3010 | + | return op.writes(); | |
| 3011 | + | } | |
| 2995 | 3012 | !matches!( | |
| 2996 | 3013 | self, | |
| 2997 | 3014 | Op::ListRepos | |
| 5039 | 5056 | Op::Checks(op) => crate::checks::run(op, services, viewer, input).await, | |
| 5040 | 5057 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5041 | 5058 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5059 | + | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5042 | 5060 | Op::ReopenSecurityAlert => { | |
| 5043 | 5061 | let changed: Outcome<AlertChange> = call( | |
| 5044 | 5062 | &services.security, |
| 10625 | 10625 | "response": { | |
| 10626 | 10626 | "rerequested": true | |
| 10627 | 10627 | } | |
| 10628 | + | }, | |
| 10629 | + | "list_artifacts": { | |
| 10630 | + | "params": { | |
| 10631 | + | "owner": "flagon-io", | |
| 10632 | + | "name": "g1t" | |
| 10633 | + | }, | |
| 10634 | + | "query": { | |
| 10635 | + | "name": "web-dist", | |
| 10636 | + | "per_page": "1" | |
| 10637 | + | }, | |
| 10638 | + | "response": { | |
| 10639 | + | "total_count": 9, | |
| 10640 | + | "artifacts": [ | |
| 10641 | + | { | |
| 10642 | + | "id": 4182, | |
| 10643 | + | "node_id": "artifact_4182", | |
| 10644 | + | "name": "web-dist", | |
| 10645 | + | "size_in_bytes": 18734120, | |
| 10646 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10647 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10648 | + | "expired": false, | |
| 10649 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10650 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10651 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10652 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10653 | + | "workflow_run": { | |
| 10654 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10655 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10656 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10657 | + | "head_branch": "main", | |
| 10658 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10659 | + | } | |
| 10660 | + | } | |
| 10661 | + | ] | |
| 10662 | + | }, | |
| 10663 | + | "notes": "Expired and deleted artifacts are not listed. `workflow_run.id` is the run's id, which `get_workflow_run` takes." | |
| 10664 | + | }, | |
| 10665 | + | "list_workflow_run_artifacts": { | |
| 10666 | + | "params": { | |
| 10667 | + | "owner": "flagon-io", | |
| 10668 | + | "name": "g1t", | |
| 10669 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z" | |
| 10670 | + | }, | |
| 10671 | + | "response": { | |
| 10672 | + | "total_count": 2, | |
| 10673 | + | "artifacts": [ | |
| 10674 | + | { | |
| 10675 | + | "id": 4181, | |
| 10676 | + | "node_id": "artifact_4181", | |
| 10677 | + | "name": "coverage", | |
| 10678 | + | "size_in_bytes": 412870, | |
| 10679 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181", | |
| 10680 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4181/zip", | |
| 10681 | + | "expired": false, | |
| 10682 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10683 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10684 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10685 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10686 | + | "workflow_run": { | |
| 10687 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10688 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10689 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10690 | + | "head_branch": "main", | |
| 10691 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10692 | + | } | |
| 10693 | + | }, | |
| 10694 | + | { | |
| 10695 | + | "id": 4182, | |
| 10696 | + | "node_id": "artifact_4182", | |
| 10697 | + | "name": "web-dist", | |
| 10698 | + | "size_in_bytes": 18734120, | |
| 10699 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10700 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10701 | + | "expired": false, | |
| 10702 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10703 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10704 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10705 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10706 | + | "workflow_run": { | |
| 10707 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10708 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10709 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10710 | + | "head_branch": "main", | |
| 10711 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10712 | + | } | |
| 10713 | + | } | |
| 10714 | + | ] | |
| 10715 | + | } | |
| 10716 | + | }, | |
| 10717 | + | "get_artifact": { | |
| 10718 | + | "params": { | |
| 10719 | + | "owner": "flagon-io", | |
| 10720 | + | "name": "g1t", | |
| 10721 | + | "id": "4182" | |
| 10722 | + | }, | |
| 10723 | + | "response": { | |
| 10724 | + | "id": 4182, | |
| 10725 | + | "node_id": "artifact_4182", | |
| 10726 | + | "name": "web-dist", | |
| 10727 | + | "size_in_bytes": 18734120, | |
| 10728 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10729 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10730 | + | "expired": false, | |
| 10731 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10732 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10733 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10734 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10735 | + | "workflow_run": { | |
| 10736 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10737 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10738 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10739 | + | "head_branch": "main", | |
| 10740 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10741 | + | } | |
| 10742 | + | } | |
| 10743 | + | }, | |
| 10744 | + | "download_artifact": { | |
| 10745 | + | "params": { | |
| 10746 | + | "owner": "flagon-io", | |
| 10747 | + | "name": "g1t", | |
| 10748 | + | "id": "4182" | |
| 10749 | + | }, | |
| 10750 | + | "response": { | |
| 10751 | + | "url": "https://api.g1t.sh/actions/toolkit/blobs/eyJrIjoiYXJ0aWZhY3QiLCJpIjoiNDE4MiJ9.q3VbS1x9", | |
| 10752 | + | "expires_at": "2026-10-08T15:13:00.000Z", | |
| 10753 | + | "artifact": { | |
| 10754 | + | "id": 4182, | |
| 10755 | + | "node_id": "artifact_4182", | |
| 10756 | + | "name": "web-dist", | |
| 10757 | + | "size_in_bytes": 18734120, | |
| 10758 | + | "url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182", | |
| 10759 | + | "archive_download_url": "https://api.g1t.sh/repos/flagon-io/g1t/actions/artifacts/4182/zip", | |
| 10760 | + | "expired": false, | |
| 10761 | + | "digest": "sha256:5f1c3a9e7b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a", | |
| 10762 | + | "created_at": "2026-10-08T14:03:51.204Z", | |
| 10763 | + | "updated_at": "2026-10-08T14:03:52.880Z", | |
| 10764 | + | "expires_at": "2026-10-22T14:03:51.204Z", | |
| 10765 | + | "workflow_run": { | |
| 10766 | + | "id": "run_01kq9c4e6g8j0m2p4r6t8v0x2z", | |
| 10767 | + | "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10768 | + | "head_repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0", | |
| 10769 | + | "head_branch": "main", | |
| 10770 | + | "head_sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7" | |
| 10771 | + | } | |
| 10772 | + | } | |
| 10773 | + | }, | |
| 10774 | + | "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -o web-dist.zip …/zip` saves the file; the body above is what the MCP `workflow` tool's `download_artifact` returns. The link needs no token and stops working after 10 minutes." | |
| 10775 | + | }, | |
| 10776 | + | "delete_artifact": { | |
| 10777 | + | "params": { | |
| 10778 | + | "owner": "flagon-io", | |
| 10779 | + | "name": "g1t", | |
| 10780 | + | "id": "4181" | |
| 10781 | + | }, | |
| 10782 | + | "response": { | |
| 10783 | + | "deleted": true, | |
| 10784 | + | "id": 4181, | |
| 10785 | + | "name": "coverage" | |
| 10786 | + | } | |
| 10787 | + | }, | |
| 10788 | + | "get_artifact_retention": { | |
| 10789 | + | "params": { | |
| 10790 | + | "owner": "flagon-io", | |
| 10791 | + | "name": "g1t" | |
| 10792 | + | }, | |
| 10793 | + | "response": { | |
| 10794 | + | "days": 14, | |
| 10795 | + | "maximum_allowed_days": 90 | |
| 10796 | + | } | |
| 10797 | + | }, | |
| 10798 | + | "set_artifact_retention": { | |
| 10799 | + | "params": { | |
| 10800 | + | "owner": "flagon-io", | |
| 10801 | + | "name": "g1t" | |
| 10802 | + | }, | |
| 10803 | + | "request": { | |
| 10804 | + | "days": 30 | |
| 10805 | + | }, | |
| 10806 | + | "response": { | |
| 10807 | + | "days": 30, | |
| 10808 | + | "maximum_allowed_days": 90 | |
| 10809 | + | }, | |
| 10810 | + | "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more." | |
| 10628 | 10811 | } | |
| 10629 | 10812 | } |
| 112 | 112 | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is, | |
| 113 | 113 | // Deployments travel in `snake_case` between services too. | |
| 114 | 114 | Op::Deployments(_) => return as_is, | |
| 115 | + | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 116 | + | Op::Artifacts(_) => return as_is, | |
| 115 | 117 | // Built by the API itself, in `snake_case`. | |
| 116 | 118 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 117 | 119 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { |
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | + | use crate::artifacts::ArtifactsOp; | |
| 6 | 7 | use crate::deployments::DeploymentsOp; | |
| 7 | 8 | use crate::operations::Op; | |
| 8 | 9 | use crate::checks::ChecksOp; | |
| 635 | 636 | Op::GetJobLogs, | |
| 636 | 637 | &[("after", "after")], | |
| 637 | 638 | ), | |
| 639 | + | // Artifacts, at GitHub's addresses. `…/zip` answers with a redirect to | |
| 640 | + | // a signed link (lib.rs). | |
| 641 | + | route("GET", "/repos/:owner/:name/actions/artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), &[("name", "name"), ("page", "page"), ("per_page", "per_page")]), | |
| 642 | + | route("GET", "/repos/:owner/:name/actions/runs/:id/artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), &[("name", "name")]), | |
| 643 | + | route("GET", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::GetArtifact), &[]), | |
| 644 | + | route("GET", "/repos/:owner/:name/actions/artifacts/:id/zip", Op::Artifacts(ArtifactsOp::DownloadArtifact), &[]), | |
| 645 | + | route("DELETE", "/repos/:owner/:name/actions/artifacts/:id", Op::Artifacts(ArtifactsOp::DeleteArtifact), &[]), | |
| 646 | + | route("GET", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), &[]), | |
| 647 | + | route("PUT", "/repos/:owner/:name/actions/permissions/artifact-and-log-retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), &[]), | |
| 638 | 648 | route( | |
| 639 | 649 | "GET", | |
| 640 | 650 | "/repos/:owner/:name/actions/secrets", |
| 1 | + | //! The services GitHub's Actions toolkit calls from inside a job, so that | |
| 2 | + | //! actions built on `@actions/cache` and `@actions/artifact` (such as | |
| 3 | + | //! `actions/setup-node` with `cache:`, or `Swatinem/rust-cache`) work on | |
| 4 | + | //! g1t unchanged. A job is told where they are in its variables | |
| 5 | + | //! (`ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, | |
| 6 | + | //! `ACTIONS_CACHE_SERVICE_V2`; see `runtime_variables`). | |
| 7 | + | //! | |
| 8 | + | //! - Twirp, at `/twirp/github.actions.results.api.v1.CacheService/…` and | |
| 9 | + | //! `…ArtifactService/…`: the cache's newer protocol (`CreateCacheEntry`, | |
| 10 | + | //! `FinalizeCacheEntryUpload`, `GetCacheEntryDownloadURL`) and the | |
| 11 | + | //! artifacts' (`CreateArtifact`, `FinalizeArtifact`, `ListArtifacts`, | |
| 12 | + | //! `GetSignedArtifactURL`, `DeleteArtifact`). JSON, the toolkit's field | |
| 13 | + | //! names. | |
| 14 | + | //! - The cache's older protocol, at `{ACTIONS_CACHE_URL}_apis/artifactcache/…`, | |
| 15 | + | //! which the toolkit's client uses whenever the server it runs against is | |
| 16 | + | //! not github.com: on g1t, that is the one it uses. | |
| 17 | + | //! - Blobs, at `/actions/toolkit/blobs/{token}`: the signed links those | |
| 18 | + | //! hand out. Downloads are a plain GET. Uploads speak the part of Azure | |
| 19 | + | //! Blob Storage's protocol the toolkit's client uses (Put Blob, Put | |
| 20 | + | //! Block, Put Block List), mapped onto an R2 multipart upload: a block's | |
| 21 | + | //! id ends in its index, which is its part's number. | |
| 22 | + | //! | |
| 23 | + | //! Every call carries the job's runtime token; the actions service checks | |
| 24 | + | //! it and keeps the entries (cache.rs, artifacts.rs, runtime.rs there). | |
| 25 | + | ||
| 26 | + | use base64::Engine; | |
| 27 | + | use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; | |
| 28 | + | use g1t_contracts::actions::{ | |
| 29 | + | ARTIFACT_MAX_BYTES, Artifact, ArtifactBlob, ArtifactCommitArgs, ArtifactReservation, ArtifactReserveArgs, BlobArgs, BlobGrant, BlobPart, | |
| 30 | + | BlobSignArgs, CACHE_MAX_ENTRY_BYTES, CACHE_PART_BYTES, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, | |
| 31 | + | CacheReserveArgs, CacheUploadArgs, JobArtifactsArgs, | |
| 32 | + | }; | |
| 33 | + | use g1t_contracts::{Failure, FailureCode, Outcome}; | |
| 34 | + | use serde_json::{Map, Value, json}; | |
| 35 | + | use worker::{Bucket, Env, Request, Response, Result, UploadedPart}; | |
| 36 | + | ||
| 37 | + | use crate::artifacts::blob_url; | |
| 38 | + | use crate::operations::Services; | |
| 39 | + | ||
| 40 | + | /// The Twirp services, by name. | |
| 41 | + | pub const CACHE_SERVICE: &str = "github.actions.results.api.v1.CacheService"; | |
| 42 | + | pub const ARTIFACT_SERVICE: &str = "github.actions.results.api.v1.ArtifactService"; | |
| 43 | + | /// Where the cache's older protocol is: `ACTIONS_CACHE_URL`. | |
| 44 | + | pub const CACHE_PATH: &str = "/actions/toolkit/"; | |
| 45 | + | /// The largest single block or blob a request may carry. | |
| 46 | + | const MAX_BLOCK_BYTES: u64 = 256 * 1024 * 1024; | |
| 47 | + | ||
| 48 | + | /// The bearer token of a request. | |
| 49 | + | pub fn bearer(request: &Request) -> String { | |
| 50 | + | request | |
| 51 | + | .headers() | |
| 52 | + | .get("authorization") | |
| 53 | + | .ok() | |
| 54 | + | .flatten() | |
| 55 | + | .and_then(|h| h.split_once(' ').map(|(_, t)| t.trim().to_owned())) | |
| 56 | + | .unwrap_or_default() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn claims(token: &str) -> Option<Value> { | |
| 60 | + | serde_json::from_slice(&URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?).ok() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The job a runtime token names, unchecked: the actions service checks it. | |
| 64 | + | pub fn runtime_job(token: &str) -> Option<String> { | |
| 65 | + | claims(token)?["job"].as_str().map(str::to_owned) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The variables a job gets for the toolkit: its runtime token and where | |
| 69 | + | /// the services are, and where to ask for an OIDC token when it may. | |
| 70 | + | pub fn runtime_variables(api: &str, token: &str, id_token: bool) -> Map<String, Value> { | |
| 71 | + | let mut vars = Map::new(); | |
| 72 | + | let mut set = |k: &str, v: String| { | |
| 73 | + | vars.insert(k.to_owned(), Value::String(v)); | |
| 74 | + | }; | |
| 75 | + | set("ACTIONS_RUNTIME_TOKEN", token.to_owned()); | |
| 76 | + | set("ACTIONS_RESULTS_URL", format!("{api}/")); | |
| 77 | + | set("ACTIONS_CACHE_URL", format!("{api}{CACHE_PATH}")); | |
| 78 | + | set("ACTIONS_CACHE_SERVICE_V2", "True".to_owned()); | |
| 79 | + | if id_token { | |
| 80 | + | set("ACTIONS_ID_TOKEN_REQUEST_URL", format!("{}/token?api-version=2.0", crate::oidc::issuer(api))); | |
| 81 | + | set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", token.to_owned()); | |
| 82 | + | } | |
| 83 | + | vars | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // ── Twirp ─────────────────────────────────────────────────────────────────── | |
| 87 | + | ||
| 88 | + | /// A Twirp error: its code and message, at the status Twirp gives it. | |
| 89 | + | fn twirp_error(code: &str, message: &str) -> Result<Response> { | |
| 90 | + | let status = match code { | |
| 91 | + | "unauthenticated" => 401, | |
| 92 | + | "permission_denied" => 403, | |
| 93 | + | "not_found" => 404, | |
| 94 | + | "already_exists" => 409, | |
| 95 | + | "invalid_argument" => 400, | |
| 96 | + | "failed_precondition" => 412, | |
| 97 | + | "resource_exhausted" => 429, | |
| 98 | + | _ => 500, | |
| 99 | + | }; | |
| 100 | + | Ok(Response::from_json(&json!({ "code": code, "msg": message }))?.with_status(status)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | fn twirp_failure(failure: &Failure) -> Result<Response> { | |
| 104 | + | let code = match failure.code { | |
| 105 | + | FailureCode::Unauthenticated => "unauthenticated", | |
| 106 | + | FailureCode::Forbidden => "permission_denied", | |
| 107 | + | FailureCode::NotFound => "not_found", | |
| 108 | + | FailureCode::Conflict => "already_exists", | |
| 109 | + | FailureCode::Invalid => "invalid_argument", | |
| 110 | + | _ => "failed_precondition", | |
| 111 | + | }; | |
| 112 | + | twirp_error(code, &failure.message) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// A field in the toolkit's spelling (`snake_case`), or its JSON name. | |
| 116 | + | fn field<'a>(body: &'a Value, name: &str) -> &'a Value { | |
| 117 | + | if !body[name].is_null() { | |
| 118 | + | return &body[name]; | |
| 119 | + | } | |
| 120 | + | let camel: String = name.split('_').enumerate().map(|(i, p)| if i == 0 { p.to_owned() } else { p[..1].to_uppercase() + &p[1..] }).collect(); | |
| 121 | + | &body[camel] | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | fn text(body: &Value, name: &str) -> String { | |
| 125 | + | match field(body, name) { | |
| 126 | + | Value::String(s) => s.clone(), | |
| 127 | + | Value::Number(n) => n.to_string(), | |
| 128 | + | // A wrapper written as an object, `{ "value": … }`. | |
| 129 | + | Value::Object(o) => o.get("value").map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_owned)).unwrap_or_default(), | |
| 130 | + | _ => String::new(), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | fn number(body: &Value, name: &str) -> Option<u64> { | |
| 135 | + | text(body, name).trim().parse().ok() | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// The run and job a runtime token names, which a request's backend ids | |
| 139 | + | /// must match. | |
| 140 | + | fn backend_ids(token: &str) -> (String, String) { | |
| 141 | + | let c = claims(token).unwrap_or_default(); | |
| 142 | + | (c["run"].as_str().unwrap_or_default().to_owned(), c["job"].as_str().unwrap_or_default().to_owned()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// What the toolkit's artifact client lists. | |
| 146 | + | fn listed(artifact: &Artifact) -> Value { | |
| 147 | + | json!({ | |
| 148 | + | "workflow_run_backend_id": artifact.run_id, | |
| 149 | + | "workflow_job_run_backend_id": artifact.job_id, | |
| 150 | + | "database_id": artifact.id.to_string(), | |
| 151 | + | "name": artifact.name, | |
| 152 | + | "size": artifact.size.to_string(), | |
| 153 | + | "created_at": artifact.created_at, | |
| 154 | + | "digest": artifact.digest, | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Starts an R2 upload for an entry the service reserved, and the signed | |
| 159 | + | /// link the toolkit sends it to. | |
| 160 | + | async fn start_upload(bucket: &Bucket, services: &Services, job: &str, token: &str, kind: &str, id: &str, object: &str) -> Result<Outcome<String>> { | |
| 161 | + | let upload = bucket.create_multipart_upload(object).execute().await?; | |
| 162 | + | let upload = upload.upload_id().await; | |
| 163 | + | let signed: Outcome<String> = g1t_kit::call( | |
| 164 | + | &services.actions, | |
| 165 | + | "blob_sign", | |
| 166 | + | &BlobSignArgs { job: job.to_owned(), token: token.to_owned(), kind: kind.to_owned(), id: id.to_owned(), upload }, | |
| 167 | + | ) | |
| 168 | + | .await?; | |
| 169 | + | Ok(match signed { | |
| 170 | + | Outcome::Ok(blob) => Outcome::Ok(blob_url(&services.addresses.api, &blob)), | |
| 171 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// `POST /twirp/{service}/{method}`. | |
| 176 | + | pub async fn twirp(mut request: Request, env: &Env, services: &Services, service: &str, method: &str) -> Result<Response> { | |
| 177 | + | let token = bearer(&request); | |
| 178 | + | let Some(job) = runtime_job(&token) else { | |
| 179 | + | return twirp_error("unauthenticated", "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| 180 | + | }; | |
| 181 | + | let body: Value = request.json().await.unwrap_or(Value::Null); | |
| 182 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 183 | + | let actions = &services.actions; | |
| 184 | + | let (run, own_job) = backend_ids(&token); | |
| 185 | + | // An artifact call names its run, and for an upload its job: the | |
| 186 | + | // token's. | |
| 187 | + | if service == ARTIFACT_SERVICE { | |
| 188 | + | let asked_run = text(&body, "workflow_run_backend_id"); | |
| 189 | + | if !asked_run.is_empty() && asked_run != run { | |
| 190 | + | return twirp_error("permission_denied", "The runtime token is for another run."); | |
| 191 | + | } | |
| 192 | + | let asked_job = text(&body, "workflow_job_run_backend_id"); | |
| 193 | + | if matches!(method, "CreateArtifact" | "FinalizeArtifact") && !asked_job.is_empty() && asked_job != own_job { | |
| 194 | + | return twirp_error("permission_denied", "The runtime token is for another job."); | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | match (service, method) { | |
| 198 | + | (CACHE_SERVICE, "GetCacheEntryDownloadURL") => { | |
| 199 | + | let restore: Vec<String> = field(&body, "restore_keys").as_array().map(|k| k.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default(); | |
| 200 | + | let args = CacheLookupArgs { job, token, key: text(&body, "key"), restore, version: Some(text(&body, "version")) }; | |
| 201 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 202 | + | match found { | |
| 203 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), .. })) => { | |
| 204 | + | Response::from_json(&json!({ "ok": true, "signed_download_url": blob_url(&services.addresses.api, &blob), "matched_key": key })) | |
| 205 | + | } | |
| 206 | + | Outcome::Ok(_) => Response::from_json(&json!({ "ok": false, "signed_download_url": "", "matched_key": "" })), | |
| 207 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | (CACHE_SERVICE, "CreateCacheEntry") => { | |
| 211 | + | let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key: text(&body, "key"), size: 0, version: Some(text(&body, "version")) }; | |
| 212 | + | let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?; | |
| 213 | + | let reserved = match reserved { | |
| 214 | + | Outcome::Ok(reserved) => reserved, | |
| 215 | + | // The client warns with this and goes on, as for a key | |
| 216 | + | // another job is saving. | |
| 217 | + | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 218 | + | }; | |
| 219 | + | match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? { | |
| 220 | + | Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })), | |
| 221 | + | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "signed_upload_url": "", "message": refused.message })), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | (CACHE_SERVICE, "FinalizeCacheEntryUpload") => { | |
| 225 | + | let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: None, key: Some(text(&body, "key")), version: Some(text(&body, "version")) }; | |
| 226 | + | let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?; | |
| 227 | + | let pending = match pending { | |
| 228 | + | Outcome::Ok(pending) => pending, | |
| 229 | + | Outcome::Fail(refused) => return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 230 | + | }; | |
| 231 | + | let Some(object) = bucket.head(&pending.object).await? else { | |
| 232 | + | return Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": "Nothing was uploaded for that entry." })); | |
| 233 | + | }; | |
| 234 | + | match commit_cache(&bucket, services, &job, &token, &pending.id, object.size()).await? { | |
| 235 | + | Outcome::Ok(()) => Response::from_json(&json!({ "ok": true, "entry_id": pending.number.to_string() })), | |
| 236 | + | Outcome::Fail(refused) => Response::from_json(&json!({ "ok": false, "entry_id": "0", "message": refused.message })), | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | (ARTIFACT_SERVICE, "CreateArtifact") => { | |
| 240 | + | let expires_at = Some(text(&body, "expires_at")).filter(|e| !e.is_empty()); | |
| 241 | + | let args = ArtifactReserveArgs { job: job.clone(), token: token.clone(), name: text(&body, "name"), expires_at, ..ArtifactReserveArgs::default() }; | |
| 242 | + | let reserved: Outcome<ArtifactReservation> = g1t_kit::call(actions, "artifact_reserve", &args).await?; | |
| 243 | + | let reserved = match reserved { | |
| 244 | + | Outcome::Ok(reserved) => reserved, | |
| 245 | + | Outcome::Fail(refused) => return twirp_failure(&refused), | |
| 246 | + | }; | |
| 247 | + | match start_upload(&bucket, services, &job, &token, "artifact", &reserved.id.to_string(), &reserved.object).await? { | |
| 248 | + | Outcome::Ok(url) => Response::from_json(&json!({ "ok": true, "signed_upload_url": url })), | |
| 249 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | (ARTIFACT_SERVICE, "FinalizeArtifact") => { | |
| 253 | + | let digest = Some(text(&body, "hash")).filter(|h| !h.is_empty()); | |
| 254 | + | // The size it was measured at as it was stored, not the one it | |
| 255 | + | // says. | |
| 256 | + | let args = ArtifactCommitArgs { job, token, id: None, name: Some(text(&body, "name")), size: 0, digest }; | |
| 257 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "artifact_commit", &args).await?; | |
| 258 | + | match done { | |
| 259 | + | Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })), | |
| 260 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | (ARTIFACT_SERVICE, "ListArtifacts") => { | |
| 264 | + | let name = Some(text(&body, "name_filter")).filter(|n| !n.is_empty()); | |
| 265 | + | let id = number(&body, "id_filter"); | |
| 266 | + | let args = JobArtifactsArgs { job, token, run_id: None, name, id }; | |
| 267 | + | let found: Outcome<Vec<Artifact>> = g1t_kit::call(actions, "job_artifacts", &args).await?; | |
| 268 | + | match found { | |
| 269 | + | Outcome::Ok(found) => Response::from_json(&json!({ "artifacts": found.iter().map(listed).collect::<Vec<_>>() })), | |
| 270 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | (ARTIFACT_SERVICE, "GetSignedArtifactURL") => { | |
| 274 | + | let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None }; | |
| 275 | + | let found: Outcome<ArtifactBlob> = g1t_kit::call(actions, "job_artifact", &args).await?; | |
| 276 | + | match found { | |
| 277 | + | Outcome::Ok(found) if !found.blob.is_empty() => Response::from_json(&json!({ "signed_url": blob_url(&services.addresses.api, &found.blob) })), | |
| 278 | + | Outcome::Ok(_) => twirp_error("failed_precondition", "Download links are not set up on this installation."), | |
| 279 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | (ARTIFACT_SERVICE, "DeleteArtifact") => { | |
| 283 | + | let args = JobArtifactsArgs { job, token, run_id: None, name: Some(text(&body, "name")), id: None }; | |
| 284 | + | let done: Outcome<Artifact> = g1t_kit::call(actions, "job_delete_artifact", &args).await?; | |
| 285 | + | match done { | |
| 286 | + | Outcome::Ok(artifact) => Response::from_json(&json!({ "ok": true, "artifact_id": artifact.id.to_string() })), | |
| 287 | + | Outcome::Fail(refused) => twirp_failure(&refused), | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | _ => twirp_error("bad_route", &format!("No method {method} on {service}.")), | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Marks an uploaded cache entry ready, and deletes what that evicted. | |
| 295 | + | async fn commit_cache(bucket: &Bucket, services: &Services, job: &str, token: &str, id: &str, size: u64) -> Result<Outcome<()>> { | |
| 296 | + | let committed: Outcome<CacheCommitted> = g1t_kit::call( | |
| 297 | + | &services.actions, | |
| 298 | + | "cache_commit", | |
| 299 | + | &CacheCommitArgs { job: job.to_owned(), token: token.to_owned(), id: id.to_owned(), size }, | |
| 300 | + | ) | |
| 301 | + | .await?; | |
| 302 | + | Ok(match committed { | |
| 303 | + | Outcome::Ok(committed) => { | |
| 304 | + | if !committed.evicted.is_empty() { | |
| 305 | + | bucket.delete_multiple(committed.evicted.iter().map(String::as_str).collect()).await?; | |
| 306 | + | } | |
| 307 | + | Outcome::Ok(()) | |
| 308 | + | } | |
| 309 | + | Outcome::Fail(refused) => Outcome::Fail(refused), | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // ── The cache's older protocol ────────────────────────────────────────────── | |
| 314 | + | ||
| 315 | + | fn plain_error(status: u16, message: &str) -> Result<Response> { | |
| 316 | + | Ok(Response::from_json(&json!({ "message": message, "error": { "message": message } }))?.with_status(status)) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | fn query(request: &Request, name: &str) -> Option<String> { | |
| 320 | + | request.url().ok()?.query_pairs().find(|(k, _)| k == name).map(|(_, v)| v.into_owned()) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// The part a chunk of the older protocol is, from its `Content-Range`: | |
| 324 | + | /// chunks are `CACHE_PART_BYTES` apart, as the toolkit sends them. | |
| 325 | + | pub fn chunk_part(range: &str) -> Option<(u16, u64)> { | |
| 326 | + | let range = range.trim().strip_prefix("bytes ")?; | |
| 327 | + | let (span, _) = range.split_once('/')?; | |
| 328 | + | let (start, end) = span.split_once('-')?; | |
| 329 | + | let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?); | |
| 330 | + | if end < start || start % CACHE_PART_BYTES != 0 || end - start + 1 > CACHE_PART_BYTES { | |
| 331 | + | return None; | |
| 332 | + | } | |
| 333 | + | Some(((start / CACHE_PART_BYTES + 1) as u16, end - start + 1)) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// `{ACTIONS_CACHE_URL}_apis/artifactcache/…`. `rest` is the path after it. | |
| 337 | + | pub async fn cache_v1(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 338 | + | let token = bearer(&request); | |
| 339 | + | let Some(job) = runtime_job(&token) else { | |
| 340 | + | return plain_error(401, "Send the job's ACTIONS_RUNTIME_TOKEN as a bearer token."); | |
| 341 | + | }; | |
| 342 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 343 | + | let actions = &services.actions; | |
| 344 | + | let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect(); | |
| 345 | + | match (method, parts.as_slice()) { | |
| 346 | + | ("GET", ["cache"]) => { | |
| 347 | + | let keys: Vec<String> = query(&request, "keys").unwrap_or_default().split(',').map(|k| k.trim().to_owned()).filter(|k| !k.is_empty()).collect(); | |
| 348 | + | let Some((key, restore)) = keys.split_first() else { | |
| 349 | + | return plain_error(400, "Give keys."); | |
| 350 | + | }; | |
| 351 | + | let version = query(&request, "version").unwrap_or_default(); | |
| 352 | + | let args = CacheLookupArgs { job, token, key: key.clone(), restore: restore.to_vec(), version: Some(version.clone()) }; | |
| 353 | + | let found: Outcome<Option<CacheHit>> = g1t_kit::call(actions, "cache_lookup", &args).await?; | |
| 354 | + | match found { | |
| 355 | + | Outcome::Ok(Some(CacheHit { key, blob: Some(blob), created_at, .. })) => Response::from_json(&json!({ | |
| 356 | + | "cacheKey": key, | |
| 357 | + | "cacheVersion": version, | |
| 358 | + | "scope": "", | |
| 359 | + | "creationTime": created_at, | |
| 360 | + | "archiveLocation": blob_url(&services.addresses.api, &blob), | |
| 361 | + | })), | |
| 362 | + | Outcome::Ok(_) => Ok(Response::empty()?.with_status(204)), | |
| 363 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ("POST", ["caches"]) => { | |
| 367 | + | let body: Value = request.json().await.unwrap_or(Value::Null); | |
| 368 | + | let size = body["cacheSize"].as_u64().unwrap_or(0); | |
| 369 | + | let key = body["key"].as_str().unwrap_or_default().to_owned(); | |
| 370 | + | let version = body["version"].as_str().unwrap_or_default().to_owned(); | |
| 371 | + | let args = CacheReserveArgs { job: job.clone(), token: token.clone(), key, size, version: Some(version) }; | |
| 372 | + | let reserved: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_reserve", &args).await?; | |
| 373 | + | let reserved = match reserved { | |
| 374 | + | Outcome::Ok(reserved) => reserved, | |
| 375 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 376 | + | }; | |
| 377 | + | match start_upload(&bucket, services, &job, &token, "cache", &reserved.id, &reserved.object).await? { | |
| 378 | + | Outcome::Ok(_) => Ok(Response::from_json(&json!({ "cacheId": reserved.number }))?.with_status(201)), | |
| 379 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | (_, ["caches", number]) => { | |
| 383 | + | let Ok(number) = number.parse::<u64>() else { | |
| 384 | + | return plain_error(404, "No such cache entry."); | |
| 385 | + | }; | |
| 386 | + | let args = CacheUploadArgs { job: job.clone(), token: token.clone(), number: Some(number), key: None, version: None }; | |
| 387 | + | let pending: Outcome<CacheReservation> = g1t_kit::call(actions, "cache_upload", &args).await?; | |
| 388 | + | let pending = match pending { | |
| 389 | + | Outcome::Ok(pending) => pending, | |
| 390 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 391 | + | }; | |
| 392 | + | let (Some(blob), Some(upload_id)) = (pending.blob.clone(), pending.upload.clone()) else { | |
| 393 | + | return plain_error(409, "That entry's upload was not started."); | |
| 394 | + | }; | |
| 395 | + | match method { | |
| 396 | + | "PATCH" => { | |
| 397 | + | let range = request.headers().get("content-range")?.unwrap_or_default(); | |
| 398 | + | let Some((part, length)) = chunk_part(&range) else { | |
| 399 | + | return plain_error(400, &format!("Send the entry in chunks of {} MB, each with its Content-Range.", CACHE_PART_BYTES / 1_048_576)); | |
| 400 | + | }; | |
| 401 | + | let Some(body) = request.inner().body() else { return plain_error(400, "The chunk is empty.") }; | |
| 402 | + | let upload = bucket.resume_multipart_upload(&pending.object, &upload_id)?; | |
| 403 | + | let uploaded = upload.upload_part(part, body).await?; | |
| 404 | + | let recorded = BlobArgs { blob, part: u32::from(part), etag: uploaded.etag(), size: length }; | |
| 405 | + | let _: Outcome<bool> = g1t_kit::call(actions, "blob_part", &recorded).await?; | |
| 406 | + | Ok(Response::empty()?.with_status(204)) | |
| 407 | + | } | |
| 408 | + | "POST" => { | |
| 409 | + | let parts: Outcome<Vec<BlobPart>> = g1t_kit::call(actions, "blob_parts", &BlobArgs { blob: blob.clone(), ..BlobArgs::default() }).await?; | |
| 410 | + | let parts = match parts { | |
| 411 | + | Outcome::Ok(parts) => parts, | |
| 412 | + | Outcome::Fail(refused) => return plain_error(refused.code.http_status(), &refused.message), | |
| 413 | + | }; | |
| 414 | + | let size = match finish(&bucket, &pending.object, &upload_id, &parts).await { | |
| 415 | + | Ok(size) => size, | |
| 416 | + | Err(problem) => return plain_error(400, &format!("The entry could not be completed: {problem}")), | |
| 417 | + | }; | |
| 418 | + | let _: Outcome<bool> = g1t_kit::call(actions, "blob_done", &BlobArgs { blob, size, ..BlobArgs::default() }).await?; | |
| 419 | + | match commit_cache(&bucket, services, &job, &token, &pending.id, size).await? { | |
| 420 | + | Outcome::Ok(()) => Ok(Response::empty()?.with_status(204)), | |
| 421 | + | Outcome::Fail(refused) => plain_error(refused.code.http_status(), &refused.message), | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | _ => plain_error(405, "PATCH a chunk, or POST to commit."), | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | _ => plain_error(404, "No such endpoint."), | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | /// Completes an R2 upload from its recorded parts, in order; the size it | |
| 432 | + | /// came to. An upload with no parts is an empty object. | |
| 433 | + | async fn finish(bucket: &Bucket, object: &str, upload_id: &str, parts: &[BlobPart]) -> std::result::Result<u64, String> { | |
| 434 | + | let upload = bucket.resume_multipart_upload(object, upload_id).map_err(|e| e.to_string())?; | |
| 435 | + | if parts.is_empty() { | |
| 436 | + | let _ = upload.abort().await; | |
| 437 | + | bucket.put(object, Vec::<u8>::new()).execute().await.map_err(|e| e.to_string())?; | |
| 438 | + | return Ok(0); | |
| 439 | + | } | |
| 440 | + | let done = upload | |
| 441 | + | .complete(parts.iter().map(|p| UploadedPart::new(p.part as u16, p.etag.clone()))) | |
| 442 | + | .await | |
| 443 | + | .map_err(|e| e.to_string())?; | |
| 444 | + | Ok(done.size()) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // ── Blobs ─────────────────────────────────────────────────────────────────── | |
| 448 | + | ||
| 449 | + | /// A block's index, from its id: the toolkit's client (Azure's SDK) makes | |
| 450 | + | /// block ids as base64 of a prefix and the index padded with zeros. | |
| 451 | + | pub fn block_index(id: &str) -> Option<u32> { | |
| 452 | + | let decoded = STANDARD.decode(id.trim()).ok()?; | |
| 453 | + | let text = String::from_utf8(decoded).ok()?; | |
| 454 | + | let digits: String = text.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect(); | |
| 455 | + | if digits.is_empty() { | |
| 456 | + | return None; | |
| 457 | + | } | |
| 458 | + | digits.parse().ok() | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | /// The block ids of a Put Block List body, in order. | |
| 462 | + | pub fn block_list(xml: &str) -> Vec<String> { | |
| 463 | + | let mut ids = Vec::new(); | |
| 464 | + | let mut rest = xml; | |
| 465 | + | while let Some(open) = rest.find('<') { | |
| 466 | + | rest = &rest[open + 1..]; | |
| 467 | + | let Some(close) = rest.find('>') else { break }; | |
| 468 | + | let tag = &rest[..close]; | |
| 469 | + | rest = &rest[close + 1..]; | |
| 470 | + | if matches!(tag, "Latest" | "Committed" | "Uncommitted") { | |
| 471 | + | let Some(end) = rest.find("</") else { break }; | |
| 472 | + | ids.push(rest[..end].trim().to_owned()); | |
| 473 | + | rest = &rest[end..]; | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ids | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | /// The parts a block list names, as recorded: each block must have been | |
| 480 | + | /// sent, as the part its index says, and they must run from the first. | |
| 481 | + | pub fn parts_for(ids: &[String], recorded: &[BlobPart]) -> std::result::Result<Vec<BlobPart>, String> { | |
| 482 | + | let mut out = Vec::with_capacity(ids.len()); | |
| 483 | + | for (position, id) in ids.iter().enumerate() { | |
| 484 | + | let index = block_index(id).ok_or_else(|| format!("The block id {id} does not end in its index."))?; | |
| 485 | + | let part = index + 1; | |
| 486 | + | if part as usize != position + 1 { | |
| 487 | + | return Err("The blocks must be listed in the order they were numbered.".to_owned()); | |
| 488 | + | } | |
| 489 | + | let found = recorded.iter().find(|p| p.part == part).ok_or_else(|| format!("Block {id} was never sent."))?; | |
| 490 | + | out.push(found.clone()); | |
| 491 | + | } | |
| 492 | + | Ok(out) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | fn azure(status: u16) -> Result<Response> { | |
| 496 | + | let mut response = Response::empty()?.with_status(status); | |
| 497 | + | let headers = response.headers_mut(); | |
| 498 | + | headers.set("x-ms-request-id", &g1t_contracts::new_id("req", g1t_kit::now_ms()))?; | |
| 499 | + | headers.set("x-ms-version", "2024-11-04")?; | |
| 500 | + | headers.set("x-ms-request-server-encrypted", "true")?; | |
| 501 | + | Ok(response) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | fn azure_error(status: u16, code: &str, message: &str) -> Result<Response> { | |
| 505 | + | let body = format!("<?xml version=\"1.0\" encoding=\"utf-8\"?><Error><Code>{code}</Code><Message>{message}</Message></Error>"); | |
| 506 | + | let mut response = Response::ok(body)?.with_status(status); | |
| 507 | + | response.headers_mut().set("content-type", "application/xml")?; | |
| 508 | + | response.headers_mut().set("x-ms-error-code", code)?; | |
| 509 | + | Ok(response) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | /// `/actions/toolkit/blobs/{token}`: GET or HEAD a download, PUT an upload. | |
| 513 | + | pub async fn blob(mut request: Request, env: &Env, services: &Services, method: &str, token: &str) -> Result<Response> { | |
| 514 | + | let opened: Outcome<BlobGrant> = g1t_kit::call(&services.actions, "blob_open", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 515 | + | let grant = match opened { | |
| 516 | + | Outcome::Ok(grant) => grant, | |
| 517 | + | Outcome::Fail(refused) => { | |
| 518 | + | let status = if refused.code == FailureCode::NotFound { 404 } else { 403 }; | |
| 519 | + | return azure_error(status, if status == 404 { "BlobNotFound" } else { "AuthenticationFailed" }, &refused.message); | |
| 520 | + | } | |
| 521 | + | }; | |
| 522 | + | let bucket = env.bucket("ACTIONS_CACHE")?; | |
| 523 | + | match (method, grant.upload.as_deref()) { | |
| 524 | + | ("GET" | "HEAD", None) => { | |
| 525 | + | let headers = |response: &mut Response, size: u64| -> Result<()> { | |
| 526 | + | let headers = response.headers_mut(); | |
| 527 | + | headers.set("content-length", &size.to_string())?; | |
| 528 | + | headers.set("content-type", grant.content_type.as_deref().unwrap_or("application/octet-stream"))?; | |
| 529 | + | headers.set("x-ms-blob-type", "BlockBlob")?; | |
| 530 | + | if let Some(name) = &grant.filename { | |
| 531 | + | headers.set("content-disposition", &format!("attachment; filename=\"{}\"", name.replace('"', "")))?; | |
| 532 | + | } | |
| 533 | + | Ok(()) | |
| 534 | + | }; | |
| 535 | + | if method == "HEAD" { | |
| 536 | + | let Some(object) = bucket.head(&grant.object).await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 537 | + | let mut response = Response::empty()?; | |
| 538 | + | headers(&mut response, object.size())?; | |
| 539 | + | return Ok(response); | |
| 540 | + | } | |
| 541 | + | let Some(object) = bucket.get(&grant.object).execute().await? else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 542 | + | let size = object.size(); | |
| 543 | + | let Some(body) = object.body() else { return azure_error(404, "BlobNotFound", "It is gone.") }; | |
| 544 | + | let mut response = Response::from_body(body.response_body()?)?; | |
| 545 | + | headers(&mut response, size)?; | |
| 546 | + | Ok(response) | |
| 547 | + | } | |
| 548 | + | ("PUT", Some(upload_id)) => { | |
| 549 | + | let comp = query(&request, "comp").unwrap_or_default(); | |
| 550 | + | let limit = if grant.kind == "cache" { CACHE_MAX_ENTRY_BYTES } else { ARTIFACT_MAX_BYTES }; | |
| 551 | + | match comp.as_str() { | |
| 552 | + | // Put Block, or Put Blob: one part. | |
| 553 | + | "block" | "" => { | |
| 554 | + | let part = if comp == "block" { | |
| 555 | + | match query(&request, "blockid").as_deref().and_then(block_index) { | |
| 556 | + | Some(index) if index < 10_000 => index + 1, | |
| 557 | + | _ => return azure_error(400, "InvalidQueryParameterValue", "A block id ends in its index, from 0."), | |
| 558 | + | } | |
| 559 | + | } else { | |
| 560 | + | 1 | |
| 561 | + | }; | |
| 562 | + | let length = request.headers().get("content-length")?.and_then(|l| l.parse::<u64>().ok()).unwrap_or(0); | |
| 563 | + | if length > MAX_BLOCK_BYTES || length > limit { | |
| 564 | + | return azure_error(413, "RequestBodyTooLarge", "That block is larger than g1t takes at once."); | |
| 565 | + | } | |
| 566 | + | let upload = bucket.resume_multipart_upload(&grant.object, upload_id)?; | |
| 567 | + | let uploaded = match request.inner().body() { | |
| 568 | + | Some(body) if length > 0 => upload.upload_part(part as u16, body).await?, | |
| 569 | + | _ => upload.upload_part(part as u16, Vec::<u8>::new()).await?, | |
| 570 | + | }; | |
| 571 | + | let recorded = BlobArgs { blob: token.to_owned(), part, etag: uploaded.etag(), size: length }; | |
| 572 | + | let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_part", &recorded).await?; | |
| 573 | + | if comp == "block" { | |
| 574 | + | return azure(201); | |
| 575 | + | } | |
| 576 | + | // Put Blob is the whole thing: finish it now. | |
| 577 | + | complete_blob(&bucket, services, token, &grant, upload_id, &[], limit, true).await | |
| 578 | + | } | |
| 579 | + | "blocklist" => { | |
| 580 | + | let xml = request.text().await.unwrap_or_default(); | |
| 581 | + | let ids = block_list(&xml); | |
| 582 | + | complete_blob(&bucket, services, token, &grant, upload_id, &ids, limit, false).await | |
| 583 | + | } | |
| 584 | + | _ => azure_error(400, "InvalidQueryParameterValue", "g1t takes Put Blob, Put Block and Put Block List."), | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | _ => azure_error(405, "UnsupportedHttpVerb", "That link is not for this."), | |
| 588 | + | } | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | /// Finishes an upload from its parts: the blocks a list names, or the one | |
| 592 | + | /// part of a Put Blob. | |
| 593 | + | #[allow(clippy::too_many_arguments)] | |
| 594 | + | async fn complete_blob( | |
| 595 | + | bucket: &Bucket, | |
| 596 | + | services: &Services, | |
| 597 | + | token: &str, | |
| 598 | + | grant: &BlobGrant, | |
| 599 | + | upload_id: &str, | |
| 600 | + | ids: &[String], | |
| 601 | + | limit: u64, | |
| 602 | + | whole: bool, | |
| 603 | + | ) -> Result<Response> { | |
| 604 | + | let recorded: Outcome<Vec<BlobPart>> = g1t_kit::call(&services.actions, "blob_parts", &BlobArgs { blob: token.to_owned(), ..BlobArgs::default() }).await?; | |
| 605 | + | let recorded = match recorded { | |
| 606 | + | Outcome::Ok(recorded) => recorded, | |
| 607 | + | Outcome::Fail(refused) => return azure_error(403, "AuthenticationFailed", &refused.message), | |
| 608 | + | }; | |
| 609 | + | let parts = if whole { | |
| 610 | + | recorded.into_iter().filter(|p| p.part == 1).collect() | |
| 611 | + | } else { | |
| 612 | + | match parts_for(ids, &recorded) { | |
| 613 | + | Ok(parts) => parts, | |
| 614 | + | Err(problem) => return azure_error(400, "InvalidBlockList", &problem), | |
| 615 | + | } | |
| 616 | + | }; | |
| 617 | + | let size = match finish(bucket, &grant.object, upload_id, &parts).await { | |
| 618 | + | Ok(size) => size, | |
| 619 | + | Err(problem) => return azure_error(400, "InvalidBlockList", &format!("The upload could not be completed: {problem}")), | |
| 620 | + | }; | |
| 621 | + | if size > limit { | |
| 622 | + | bucket.delete(&grant.object).await?; | |
| 623 | + | return azure_error(413, "RequestBodyTooLarge", &format!("It is {} MB, more than g1t keeps ({} MB).", size / 1_048_576, limit / 1_048_576)); | |
| 624 | + | } | |
| 625 | + | let _: Outcome<bool> = g1t_kit::call(&services.actions, "blob_done", &BlobArgs { blob: token.to_owned(), size, ..BlobArgs::default() }).await?; | |
| 626 | + | azure(201) | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | #[cfg(test)] | |
| 630 | + | mod tests { | |
| 631 | + | use super::*; | |
| 632 | + | ||
| 633 | + | /// What Azure's SDK sends as block ids: base64 of a 36-character uuid | |
| 634 | + | /// prefix and the index padded to 48 characters in all. | |
| 635 | + | fn azure_block_id(index: u32) -> String { | |
| 636 | + | let prefix = "4a2f0d2e-8a44-4f1b-9d55-6f1a2b3c4d5e"; | |
| 637 | + | let padded = format!("{prefix}{index:0>width$}", width = 48 - prefix.len()); | |
| 638 | + | STANDARD.encode(padded) | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | #[test] | |
| 642 | + | fn block_ids_give_their_index() { | |
| 643 | + | assert_eq!(block_index(&azure_block_id(0)), Some(0)); | |
| 644 | + | assert_eq!(block_index(&azure_block_id(17)), Some(17)); | |
| 645 | + | assert_eq!(block_index(&STANDARD.encode("no-digits")), None); | |
| 646 | + | assert_eq!(block_index("not base64!"), None); | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | #[test] | |
| 650 | + | fn a_block_list_is_read_in_order_and_matched_to_parts() { | |
| 651 | + | // As the SDK's commitBlockList sends it. | |
| 652 | + | let xml = format!( | |
| 653 | + | "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><BlockList><Latest>{}</Latest><Latest>{}</Latest></BlockList>", | |
| 654 | + | azure_block_id(0), | |
| 655 | + | azure_block_id(1) | |
| 656 | + | ); | |
| 657 | + | let ids = block_list(&xml); | |
| 658 | + | assert_eq!(ids, [azure_block_id(0), azure_block_id(1)]); | |
| 659 | + | // Sent out of order, as they are concurrently. | |
| 660 | + | let recorded = vec![ | |
| 661 | + | BlobPart { part: 2, etag: "b".into(), size: 3 }, | |
| 662 | + | BlobPart { part: 1, etag: "a".into(), size: 8 }, | |
| 663 | + | ]; | |
| 664 | + | let parts = parts_for(&ids, &recorded).unwrap(); | |
| 665 | + | assert_eq!(parts.iter().map(|p| p.etag.as_str()).collect::<Vec<_>>(), ["a", "b"]); | |
| 666 | + | // A block never sent, or listed out of order. | |
| 667 | + | assert!(parts_for(&[azure_block_id(0), azure_block_id(2)], &recorded).is_err()); | |
| 668 | + | assert!(parts_for(&[azure_block_id(1), azure_block_id(0)], &recorded).is_err()); | |
| 669 | + | assert!(block_list("<BlockList></BlockList>").is_empty()); | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | #[test] | |
| 673 | + | fn older_protocol_chunks_are_parts() { | |
| 674 | + | let mb32 = CACHE_PART_BYTES; | |
| 675 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32 - 1)), Some((1, mb32))); | |
| 676 | + | assert_eq!(chunk_part(&format!("bytes {}-{}/*", mb32 * 2, mb32 * 2 + 99)), Some((3, 100))); | |
| 677 | + | // Not on a chunk's boundary, too long, or not a range. | |
| 678 | + | assert_eq!(chunk_part("bytes 5-10/*"), None); | |
| 679 | + | assert_eq!(chunk_part(&format!("bytes 0-{}/*", mb32)), None); | |
| 680 | + | assert_eq!(chunk_part("0-10"), None); | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | /// The toolkit's requests, as `@actions/cache` 4 and `@actions/artifact` | |
| 684 | + | /// 2 send them (protobuf-ts, proto field names, no defaults). | |
| 685 | + | #[test] | |
| 686 | + | fn twirp_requests_read_in_the_toolkits_spelling() { | |
| 687 | + | let create_cache = json!({ "key": "node-cache-Linux-x64-npm-abc", "version": "a7f2c1e0" }); | |
| 688 | + | assert_eq!(text(&create_cache, "key"), "node-cache-Linux-x64-npm-abc"); | |
| 689 | + | let lookup = json!({ "key": "k", "restore_keys": ["k-", "x-"], "version": "v" }); | |
| 690 | + | assert_eq!(field(&lookup, "restore_keys").as_array().unwrap().len(), 2); | |
| 691 | + | let finalize = json!({ "key": "k", "size_bytes": "1048576", "version": "v" }); | |
| 692 | + | assert_eq!(number(&finalize, "size_bytes"), Some(1_048_576)); | |
| 693 | + | let create_artifact = json!({ | |
| 694 | + | "workflow_run_backend_id": "run_1", | |
| 695 | + | "workflow_job_run_backend_id": "job_1", | |
| 696 | + | "name": "dist", | |
| 697 | + | "expires_at": "2026-10-13T00:00:00Z", | |
| 698 | + | "version": 4 | |
| 699 | + | }); | |
| 700 | + | assert_eq!(text(&create_artifact, "workflow_job_run_backend_id"), "job_1"); | |
| 701 | + | let list = json!({ "workflow_run_backend_id": "run_1", "workflow_job_run_backend_id": "job_1", "id_filter": "42", "name_filter": "dist" }); | |
| 702 | + | assert_eq!(number(&list, "id_filter"), Some(42)); | |
| 703 | + | assert_eq!(text(&list, "name_filter"), "dist"); | |
| 704 | + | // A client writing JSON names instead reads the same. | |
| 705 | + | let camel = json!({ "workflowRunBackendId": "run_1", "sizeBytes": 3 }); | |
| 706 | + | assert_eq!(text(&camel, "workflow_run_backend_id"), "run_1"); | |
| 707 | + | assert_eq!(number(&camel, "size_bytes"), Some(3)); | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | #[test] | |
| 711 | + | fn the_runtime_token_names_its_run_and_job() { | |
| 712 | + | let payload = URL_SAFE_NO_PAD.encode(json!({ "job": "job_1", "run": "run_1" }).to_string()); | |
| 713 | + | let token = format!("h.{payload}.s"); | |
| 714 | + | assert_eq!(runtime_job(&token).as_deref(), Some("job_1")); | |
| 715 | + | assert_eq!(backend_ids(&token), ("run_1".to_owned(), "job_1".to_owned())); | |
| 716 | + | assert_eq!(runtime_job("deadbeef"), None); | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | #[test] | |
| 720 | + | fn a_job_is_told_where_the_toolkit_s_services_are() { | |
| 721 | + | let vars = runtime_variables("https://api.g1t.sh", "tok", false); | |
| 722 | + | // Twirp paths are resolved against the root of ACTIONS_RESULTS_URL. | |
| 723 | + | assert_eq!(vars["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/"); | |
| 724 | + | // The older protocol appends `_apis/artifactcache/…`. | |
| 725 | + | assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/"); | |
| 726 | + | assert_eq!(vars["ACTIONS_CACHE_SERVICE_V2"], "True"); | |
| 727 | + | assert!(vars.get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none()); | |
| 728 | + | let vars = runtime_variables("https://api.g1t.sh", "tok", true); | |
| 729 | + | // core.getIDToken appends `&audience=…`. | |
| 730 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_URL"], "https://api.g1t.sh/actions/oidc/token?api-version=2.0"); | |
| 731 | + | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "tok"); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[test] | |
| 735 | + | fn artifacts_are_listed_as_the_toolkit_reads_them() { | |
| 736 | + | let artifact = Artifact { | |
| 737 | + | id: 7, | |
| 738 | + | name: "dist".into(), | |
| 739 | + | size: 10, | |
| 740 | + | digest: None, | |
| 741 | + | format: "zip".into(), | |
| 742 | + | run_id: "run_1".into(), | |
| 743 | + | job_id: "job_1".into(), | |
| 744 | + | repo_id: "repo_1".into(), | |
| 745 | + | expired: false, | |
| 746 | + | created_at: "2026-10-08T12:00:00.000Z".into(), | |
| 747 | + | updated_at: "2026-10-08T12:00:00.000Z".into(), | |
| 748 | + | expires_at: "2026-10-22T12:00:00.000Z".into(), | |
| 749 | + | head_branch: None, | |
| 750 | + | head_sha: None, | |
| 751 | + | }; | |
| 752 | + | let shown = listed(&artifact); | |
| 753 | + | assert_eq!(shown["database_id"], "7"); | |
| 754 | + | assert_eq!(shown["size"], "10"); | |
| 755 | + | assert_eq!(shown["workflow_job_run_backend_id"], "job_1"); | |
| 756 | + | } | |
| 757 | + | } |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | + | use crate::artifacts::ArtifactsOp; | |
| 22 | 23 | use crate::deployments::DeploymentsOp; | |
| 23 | 24 | use crate::operations::Op; | |
| 24 | 25 | use crate::checks::ChecksOp; | |
| 200 | 201 | Tool { | |
| 201 | 202 | name: "workflow", | |
| 202 | 203 | title: "Workflows", | |
| 203 | − | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning 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. 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`), their groups, and where agent work runs.", | |
| 204 | + | 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. 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`), their groups, and where agent work runs.", | |
| 204 | 205 | default_action: None, | |
| 205 | 206 | actions: &[ | |
| 206 | 207 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| 211 | 212 | a("cancel", Op::CancelWorkflowRun, "Cancel a run"), | |
| 212 | 213 | a("rerun", Op::RerunWorkflowRun, "Run a finished run again"), | |
| 213 | 214 | a("update", Op::UpdateWorkflow, "Turn a workflow on or off"), | |
| 215 | + | a("list_artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), "A repository's artifacts, newest first; or a run's with run_artifacts"), | |
| 216 | + | a("run_artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), "One run's artifacts"), | |
| 217 | + | a("get_artifact", Op::Artifacts(ArtifactsOp::GetArtifact), "One artifact: size, digest, expiry, run"), | |
| 218 | + | a("download_artifact", Op::Artifacts(ArtifactsOp::DownloadArtifact), "A 10-minute link to an artifact's zip"), | |
| 219 | + | a("delete_artifact", Op::Artifacts(ArtifactsOp::DeleteArtifact), "Delete an artifact before it expires"), | |
| 220 | + | a("artifact_retention", Op::Artifacts(ArtifactsOp::GetArtifactRetention), "Days the repository keeps artifacts"), | |
| 221 | + | a("set_artifact_retention", Op::Artifacts(ArtifactsOp::SetArtifactRetention), "Change the days the repository keeps artifacts"), | |
| 214 | 222 | a("combined_status", Op::Checks(ChecksOp::GetCombinedStatus), "A commit's statuses and the state they add up to"), | |
| 215 | 223 | a("list_statuses", Op::Checks(ChecksOp::ListCommitStatuses), "A commit's statuses, newest first"), | |
| 216 | 224 | a("set_status", Op::Checks(ChecksOp::CreateCommitStatus), "Set a status on a commit"), |
| 33 | 33 | // A person's pinned projects: list_pinned_projects and changing them. | |
| 34 | 34 | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 35 | 35 | ], | |
| 36 | − | // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache | |
| 36 | + | // GitHub Actions artifacts older runners kept, in chunks, with KV's own | |
| 37 | + | // expiry, read until it passes (and cache | |
| 37 | 38 | // entries saved before the cache moved to R2, until they expire). | |
| 38 | 39 | "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }], | |
| 39 | − | // actions/cache entries, up to 2 GB each, uploaded in parts. The actions | |
| 40 | + | // actions/cache entries (`c/`) and artifacts (`a/`), uploaded in parts. The actions | |
| 40 | 41 | // service lists them and decides what is kept (services/actions/src/cache.rs). | |
| 41 | 42 | "r2_buckets": [{ "binding": "ACTIONS_CACHE", "bucket_name": "g1t-actions-cache" }], | |
| 42 | 43 | "observability": { "enabled": true } |
| 220 | 220 | | One job on g1t's machines | 60 minutes. On a self-hosted runner, 24 hours. | | |
| 221 | 221 | | One cache entry | 2 GiB, compressed. A larger one is not saved. | | |
| 222 | 222 | | A repository's caches | 10 GiB together. Past it, the entries restored longest ago are removed. | | |
| 223 | − | | One artifact | 60 MB, kept for 14 days | | |
| 223 | + | | One artifact | 5 GiB, zipped. Kept 14 days unless the repository says otherwise, at most 90. | | |
| 224 | + | | A run's artifacts | 10 GiB together. | | |
| 224 | 225 | ||
| 225 | 226 | The machine sizes are Cloudflare Containers' instance sizes. For more, use a | |
| 226 | 227 | [self-hosted runner](/guides/self-hosted-runners/). See | |
| 230 | 231 | ||
| 231 | 232 | - Reusable workflows from another repository. Ones in the same repository | |
| 232 | 233 | work. | |
| 233 | − | - Actions that cache through the hosted toolkit's own cache service, such as | |
| 234 | − | `setup-node` with `cache: npm`. They run without it; use `actions/cache`. | |
| 234 | + | - Actions that upload or download artifacts with the toolkit's artifact | |
| 235 | + | library themselves. The library refuses to run against any server but | |
| 236 | + | github.com. `actions/upload-artifact`, `actions/download-artifact` and | |
| 237 | + | `actions/upload-artifact/merge` work, as g1t runs them itself. | |
| 238 | + | - A cache entry between 100 and 128 MB saved by an action built on the | |
| 239 | + | toolkit, such as `setup-node` with `cache: npm`. The toolkit sends an | |
| 240 | + | entry under 128 MB in one request, and g1t takes at most 100 MB in one | |
| 241 | + | request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and | |
| 242 | + | the job goes on; smaller and larger entries are saved. | |
| 235 | 243 | - Environments' protection rules: required reviewers, wait timers and branch | |
| 236 | 244 | limits. A job with `environment:` gets that environment's values and runs | |
| 237 | 245 | without waiting. | |
| 238 | 246 | ||
| 239 | 247 | See [Not yet](/guides/actions/#not-yet). **Status.** Planned. | |
| 240 | 248 | ||
| 249 | + | ### No npm trusted publishing or provenance | |
| 250 | + | ||
| 251 | + | A workflow on g1t can't publish to npm with trusted publishing, or with | |
| 252 | + | `--provenance`. | |
| 253 | + | ||
| 254 | + | - **Why.** Both trade the job's OIDC token with npm and Sigstore, which | |
| 255 | + | accept tokens only from the CI services they list. g1t's | |
| 256 | + | [OIDC tokens](/guides/actions/#oidc-tokens) work with any cloud that | |
| 257 | + | lets you add an issuer, and npm does not. | |
| 258 | + | - **Instead.** Publish with a granular access token in a secret | |
| 259 | + | (`NODE_AUTH_TOKEN`); see [npm](/guides/actions/#npm). | |
| 260 | + | - **Status.** Depends on npm. | |
| 261 | + | ||
| 241 | 262 | ## Deployments | |
| 242 | 263 | ||
| 243 | 264 | ### Static sites and Workers only |
| 43 | 43 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. | | |
| 44 | 44 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. | | |
| 45 | 45 | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. | | |
| 46 | − | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 46 | + | | `actions/upload-artifact`, `actions/download-artifact`, `actions/upload-artifact/merge` | The same inputs and outputs as version 4: `retention-days`, `overwrite`, `compression-level`, `include-hidden-files`, `!` exclusions, download by `pattern` with `merge-multiple`, and from another run with `run-id` and `github-token`. Up to 5 GiB each; see [artifacts](#artifacts). | | |
| 47 | 47 | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | + | | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). | | |
| 49 | + | | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). | | |
| 48 | 50 | ||
| 49 | 51 | The **Actions** page of a workflow says, under *How this runs on g1t*, | |
| 50 | 52 | anything in it that runs differently. | |
| 60 | 62 | g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets) | |
| 61 | 63 | that runs jobs in Docker uses it. | |
| 62 | 64 | - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work. | |
| 63 | − | - **The toolkit's own cache.** Actions that cache through GitHub's service | |
| 64 | − | themselves, such as `actions/setup-node` with `cache: npm`, run without | |
| 65 | − | it. Use `actions/cache` for the same effect. | |
| 65 | + | - **Actions that upload artifacts with the toolkit's artifact library | |
| 66 | + | themselves.** The library refuses to run against any server but | |
| 67 | + | github.com. `actions/upload-artifact`, `actions/download-artifact` and | |
| 68 | + | `actions/upload-artifact/merge` work, because g1t runs them itself. See | |
| 69 | + | [actions built on the toolkit](#actions-built-on-the-toolkit). | |
| 66 | 70 | - **Environments' protection rules** (required reviewers, wait timers, | |
| 67 | 71 | branch limits). A job with `environment:` gets that environment's | |
| 68 | 72 | [values](/guides/secrets-and-variables/#a-value-per-environment), and runs | |
| 164 | 168 | ``` | |
| 165 | 169 | ||
| 166 | 170 | Each restore and save says on the job's log how large the entry was and | |
| 167 | − | how long it took. A workspace on the plan pays for what its caches hold | |
| 168 | − | (`Actions cache storage` on its statement), at R2's price plus the margin; | |
| 169 | − | see [usage and billing](/guides/usage-and-billing/#actions-cache). | |
| 171 | + | how long it took. A workspace on the plan pays for what its caches and | |
| 172 | + | [artifacts](#artifacts) hold (`Actions cache storage` on its statement), | |
| 173 | + | at R2's price plus the margin; see | |
| 174 | + | [usage and billing](/guides/usage-and-billing/#actions-cache). | |
| 175 | + | ||
| 176 | + | ## Artifacts | |
| 177 | + | ||
| 178 | + | `actions/upload-artifact` keeps files a job made with its run, for later | |
| 179 | + | jobs, other runs and people: | |
| 180 | + | ||
| 181 | + | | | | | |
| 182 | + | | --- | --- | | |
| 183 | + | | One artifact | Up to 5 GiB, zipped. | | |
| 184 | + | | A run's artifacts | Up to 10 GiB together. | | |
| 185 | + | | How long | The repository's setting: 14 days unless someone with the Maintain role changes it under **Settings → Repository → Artifacts**, from 1 to 90 days. `retention-days` asks for fewer days, never more. | | |
| 186 | + | | Names | One artifact per name in a run. Uploading a name again fails, unless the upload says `overwrite: true`, which replaces it. A name is up to 256 characters, none of `" : < > \| * ? \ /`. | | |
| 187 | + | | `path` | Files, folders and globs, `**` included; a line starting with `!` leaves matching paths out. Files and folders whose names start with `.` are left out unless `include-hidden-files: true`. | | |
| 188 | + | | Compression | `compression-level` 0 (stored) to 9; 6 unless you say. | | |
| 189 | + | | Outputs | `artifact-id` (a number), `artifact-url` (its run's page) and `artifact-digest` (the SHA-256 of its zip). | | |
| 190 | + | ||
| 191 | + | ```yaml | |
| 192 | + | - uses: actions/upload-artifact@v4 | |
| 193 | + | with: | |
| 194 | + | name: web-dist | |
| 195 | + | path: | | |
| 196 | + | dist/ | |
| 197 | + | !dist/**/*.map | |
| 198 | + | retention-days: 5 | |
| 199 | + | compression-level: 9 | |
| 200 | + | ``` | |
| 201 | + | ||
| 202 | + | `actions/download-artifact` downloads one by `name` into `path`, or every | |
| 203 | + | artifact of the run, each into a folder of its name; `pattern` picks them | |
| 204 | + | by name, and `merge-multiple: true` puts them all in one folder. | |
| 205 | + | `artifact-ids` picks them by number. With `github-token` and `run-id`, it | |
| 206 | + | downloads from another run of the same repository, such as the one a | |
| 207 | + | `workflow_run` workflow follows: | |
| 208 | + | ||
| 209 | + | ```yaml | |
| 210 | + | - uses: actions/download-artifact@v4 | |
| 211 | + | with: | |
| 212 | + | name: web-dist | |
| 213 | + | github-token: ${{ secrets.GITHUB_TOKEN }} | |
| 214 | + | run-id: ${{ github.event.workflow_run.id }} | |
| 215 | + | ``` | |
| 216 | + | ||
| 217 | + | `actions/upload-artifact/merge` downloads the run's artifacts that match | |
| 218 | + | `pattern`, uploads them as one artifact (`name`, `merged-artifacts` unless | |
| 219 | + | you say), and deletes them with `delete-merged: true`. | |
| 220 | + | ||
| 221 | + | A run's page lists its artifacts with their size and when they expire. | |
| 222 | + | Anyone who can see the run downloads them there; someone with the Write | |
| 223 | + | role can delete one before it expires. | |
| 224 | + | ||
| 225 | + | ## Actions built on the toolkit | |
| 226 | + | ||
| 227 | + | Many actions save to the cache with GitHub's toolkit, `@actions/cache`, | |
| 228 | + | rather than through `actions/cache`: `actions/setup-node`, | |
| 229 | + | `actions/setup-python`, `actions/setup-go` and `actions/setup-java` with | |
| 230 | + | `cache:`, `Swatinem/rust-cache`, and others. They work on g1t as they | |
| 231 | + | are: every job gets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` and | |
| 232 | + | `ACTIONS_RESULTS_URL`, and g1t answers the toolkit's requests from the | |
| 233 | + | repository's cache. | |
| 234 | + | ||
| 235 | + | - Their entries are the repository's, under [the cache's](#the-cache) | |
| 236 | + | limits, and are deleted the same way. | |
| 237 | + | - An entry is restored only by the same kind of save: the toolkit names a | |
| 238 | + | version for each entry, from its paths and compression. An entry | |
| 239 | + | `setup-node` saved is not restored by `actions/cache`, and the other way | |
| 240 | + | round. | |
| 241 | + | - An entry the toolkit sends whole, which it does below 128 MB, is not | |
| 242 | + | saved when it is over 100 MB, the most g1t takes in one request. The | |
| 243 | + | step warns and the job goes on. | |
| 244 | + | - The toolkit's artifact library refuses to run against any server but | |
| 245 | + | github.com, so an action that uploads artifacts with it directly fails | |
| 246 | + | with its own message. `actions/upload-artifact`, | |
| 247 | + | `actions/download-artifact` and `actions/upload-artifact/merge` work: | |
| 248 | + | g1t runs those itself. | |
| 249 | + | ||
| 250 | + | ## OIDC tokens | |
| 251 | + | ||
| 252 | + | A job can prove which repository, branch and environment it runs for with | |
| 253 | + | a short-lived OpenID Connect token signed by g1t, and trade it for a cloud | |
| 254 | + | provider's credentials. Nothing long-lived needs to sit in a secret. | |
| 255 | + | ||
| 256 | + | 1. Give the job, or the workflow, `permissions: id-token: write`. A job | |
| 257 | + | without it gets no token, and neither does a run of a pull request from | |
| 258 | + | outside the repository. | |
| 259 | + | 2. Tell your cloud to trust g1t's issuer for your repository (below). | |
| 260 | + | 3. Use the provider's own login action, which asks for the token. | |
| 261 | + | ||
| 262 | + | ```yaml | |
| 263 | + | permissions: | |
| 264 | + | id-token: write | |
| 265 | + | contents: read | |
| 266 | + | ||
| 267 | + | jobs: | |
| 268 | + | deploy: | |
| 269 | + | runs-on: ubuntu-latest | |
| 270 | + | environment: production | |
| 271 | + | steps: | |
| 272 | + | - uses: aws-actions/configure-aws-credentials@v4 | |
| 273 | + | with: | |
| 274 | + | role-to-assume: arn:aws:iam::123456789012:role/acme-web-deploy | |
| 275 | + | aws-region: us-east-1 | |
| 276 | + | ``` | |
| 277 | + | ||
| 278 | + | The job gets `ACTIONS_ID_TOKEN_REQUEST_URL` and | |
| 279 | + | `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, which `core.getIDToken()` reads. To ask | |
| 280 | + | for a token yourself, name its audience: | |
| 281 | + | ||
| 282 | + | ```sh | |
| 283 | + | curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ | |
| 284 | + | "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value | |
| 285 | + | ``` | |
| 286 | + | ||
| 287 | + | | | | | |
| 288 | + | | --- | --- | | |
| 289 | + | | Issuer | `https://api.g1t.sh/actions/oidc` | | |
| 290 | + | | Discovery | `https://api.g1t.sh/actions/oidc/.well-known/openid-configuration` | | |
| 291 | + | | Keys | `https://api.g1t.sh/actions/oidc/.well-known/jwks`, RS256, each with its `kid` | | |
| 292 | + | | Lifetime | 5 minutes | | |
| 293 | + | | Audience | What the job asks for; `https://g1t.sh/<owner>` when it asks for none | | |
| 294 | + | ||
| 295 | + | ### Claims | |
| 296 | + | ||
| 297 | + | Each token carries the claims GitHub's do, so trust policies written for | |
| 298 | + | those read g1t's the same way. | |
| 299 | + | ||
| 300 | + | | Claim | Example | | |
| 301 | + | | --- | --- | | |
| 302 | + | | `sub` | `repo:acme/web:environment:production` for a job with an `environment:`; `repo:acme/web:pull_request` for a pull request's run; otherwise `repo:acme/web:ref:refs/heads/main` (or `refs/tags/v1.2.0`) | | |
| 303 | + | | `repository`, `repository_owner` | `acme/web`, `acme` | | |
| 304 | + | | `repository_id`, `repository_owner_id` | g1t's ids for them, such as `rep_01kpw0…` | | |
| 305 | + | | `repository_visibility` | `public` or `private` | | |
| 306 | + | | `ref`, `ref_type`, `ref_protected`, `sha` | `refs/heads/main`, `branch`, `"true"`, the commit | | |
| 307 | + | | `head_ref`, `base_ref` | A pull request's branches | | |
| 308 | + | | `environment` | The job's environment, when it has one | | |
| 309 | + | | `event_name` | `push`, `pull_request`, `workflow_dispatch`, … | | |
| 310 | + | | `workflow`, `workflow_ref`, `workflow_sha` | `Deploy`, `acme/web/.g1t/workflows/deploy.yml@refs/heads/main`, the commit | | |
| 311 | + | | `job_workflow_ref`, `job_workflow_sha` | The workflow that defines the job: a called workflow's own file | | |
| 312 | + | | `run_id`, `run_number`, `run_attempt` | `run_01kq9c…`, `"12"`, `"1"` | | |
| 313 | + | | `actor`, `actor_id` | Who started the run | | |
| 314 | + | | `runner_environment` | `github-hosted` on g1t's machines, `self-hosted` on yours | | |
| 315 | + | | `iss`, `aud`, `jti`, `iat`, `nbf`, `exp` | As in any OIDC token | | |
| 316 | + | ||
| 317 | + | ### AWS | |
| 318 | + | ||
| 319 | + | 1. Add g1t as an identity provider, under **IAM → Identity providers**: | |
| 320 | + | provider type **OpenID Connect**, provider URL | |
| 321 | + | `https://api.g1t.sh/actions/oidc`, audience `sts.amazonaws.com`. Or: | |
| 322 | + | ||
| 323 | + | ```sh | |
| 324 | + | aws iam create-open-id-connect-provider \ | |
| 325 | + | --url https://api.g1t.sh/actions/oidc \ | |
| 326 | + | --client-id-list sts.amazonaws.com | |
| 327 | + | ``` | |
| 328 | + | ||
| 329 | + | 2. Give the role a trust policy for your repository: | |
| 330 | + | ||
| 331 | + | ```json | |
| 332 | + | { | |
| 333 | + | "Version": "2012-10-17", | |
| 334 | + | "Statement": [{ | |
| 335 | + | "Effect": "Allow", | |
| 336 | + | "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/api.g1t.sh/actions/oidc" }, | |
| 337 | + | "Action": "sts:AssumeRoleWithWebIdentity", | |
| 338 | + | "Condition": { | |
| 339 | + | "StringEquals": { | |
| 340 | + | "api.g1t.sh/actions/oidc:aud": "sts.amazonaws.com", | |
| 341 | + | "api.g1t.sh/actions/oidc:sub": "repo:acme/web:environment:production" | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | }] | |
| 345 | + | } | |
| 346 | + | ``` | |
| 347 | + | ||
| 348 | + | 3. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`, | |
| 349 | + | as above. | |
| 350 | + | ||
| 351 | + | ### Google Cloud | |
| 352 | + | ||
| 353 | + | 1. Make a workload identity pool and a provider for g1t: | |
| 354 | + | ||
| 355 | + | ```sh | |
| 356 | + | gcloud iam workload-identity-pools create g1t --location=global | |
| 357 | + | gcloud iam workload-identity-pools providers create-oidc g1t \ | |
| 358 | + | --location=global --workload-identity-pool=g1t \ | |
| 359 | + | --issuer-uri=https://api.g1t.sh/actions/oidc \ | |
| 360 | + | --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \ | |
| 361 | + | --attribute-condition="assertion.repository_owner == 'acme'" | |
| 362 | + | ``` | |
| 363 | + | ||
| 364 | + | 2. Let the repository act as a service account: | |
| 365 | + | ||
| 366 | + | ```sh | |
| 367 | + | gcloud iam service-accounts add-iam-policy-binding deploy@acme-prod.iam.gserviceaccount.com \ | |
| 368 | + | --role=roles/iam.workloadIdentityUser \ | |
| 369 | + | --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/g1t/attribute.repository/acme/web" | |
| 370 | + | ``` | |
| 371 | + | ||
| 372 | + | 3. Use `google-github-actions/auth@v2` with | |
| 373 | + | `workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/g1t/providers/g1t` | |
| 374 | + | and `service_account`. It asks for the provider's own name as the | |
| 375 | + | audience, which the provider accepts unless you change its allowed | |
| 376 | + | audiences. | |
| 377 | + | ||
| 378 | + | ### Azure | |
| 379 | + | ||
| 380 | + | 1. On the app registration or user-assigned managed identity, add a | |
| 381 | + | federated credential with the scenario **Other issuer**: issuer | |
| 382 | + | `https://api.g1t.sh/actions/oidc`, subject identifier | |
| 383 | + | `repo:acme/web:environment:production`, audience | |
| 384 | + | `api://AzureADTokenExchange`. Or: | |
| 385 | + | ||
| 386 | + | ```sh | |
| 387 | + | az ad app federated-credential create --id <application id> --parameters '{ | |
| 388 | + | "name": "g1t-acme-web-production", | |
| 389 | + | "issuer": "https://api.g1t.sh/actions/oidc", | |
| 390 | + | "subject": "repo:acme/web:environment:production", | |
| 391 | + | "audiences": ["api://AzureADTokenExchange"] | |
| 392 | + | }' | |
| 393 | + | ``` | |
| 394 | + | ||
| 395 | + | 2. Give it a role on what it deploys to, as for any identity. | |
| 396 | + | 3. Use `azure/login@v2` with `client-id`, `tenant-id` and | |
| 397 | + | `subscription-id`, and no secret. | |
| 398 | + | ||
| 399 | + | A federated credential matches the subject exactly: add one per | |
| 400 | + | environment or branch that deploys. | |
| 401 | + | ||
| 402 | + | ### Cloudflare | |
| 403 | + | ||
| 404 | + | Cloudflare's API takes API tokens, not OIDC tokens. Keep a token scoped | |
| 405 | + | to what the workflow deploys in a secret, available to that workflow only | |
| 406 | + | (see [workflow-only domains](/guides/guardrails/#workflow-only-domains) | |
| 407 | + | for limiting where it can be sent). | |
| 170 | 408 | ||
| 409 | + | A Worker of your own can trust g1t's jobs directly, by checking the token | |
| 410 | + | a job sends it against g1t's keys: | |
| 411 | + | ||
| 412 | + | ```ts | |
| 413 | + | import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 414 | + | ||
| 415 | + | const keys = createRemoteJWKSet(new URL("https://api.g1t.sh/actions/oidc/.well-known/jwks")); | |
| 416 | + | ||
| 417 | + | export async function fromG1tJob(request: Request): Promise<boolean> { | |
| 418 | + | const token = request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""; | |
| 419 | + | const { payload } = await jwtVerify(token, keys, { | |
| 420 | + | issuer: "https://api.g1t.sh/actions/oidc", | |
| 421 | + | audience: "https://deploy.example.com", | |
| 422 | + | }); | |
| 423 | + | return payload.sub === "repo:acme/web:environment:production"; | |
| 424 | + | } | |
| 425 | + | ``` | |
| 426 | + | ||
| 427 | + | ### npm | |
| 428 | + | ||
| 429 | + | npm's trusted publishing and provenance accept OIDC tokens only from the | |
| 430 | + | CI services npm lists, and g1t is not one of them yet. Publish with a | |
| 431 | + | granular access token in a secret instead: | |
| 432 | + | ||
| 433 | + | ```yaml | |
| 434 | + | - uses: actions/setup-node@v4 | |
| 435 | + | with: | |
| 436 | + | node-version: 24 | |
| 437 | + | registry-url: https://registry.npmjs.org | |
| 438 | + | - run: npm publish | |
| 439 | + | env: | |
| 440 | + | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 441 | + | ``` | |
| 442 | + | ||
| 443 | + | See [What g1t can't do yet](/about/limitations/#no-npm-trusted-publishing-or-provenance). | |
| 444 | + | ||
| 171 | 445 | ## Runs and logs | |
| 172 | 446 | ||
| 173 | 447 | Open a repository's **Actions** page, in its sidebar. Pick a workflow to | |
| 331 | 605 | | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` | | |
| 332 | 606 | | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` | | |
| 333 | 607 | | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` | | |
| 608 | + | | `list_artifacts` | `GET /repos/{owner}/{repo}/actions/artifacts`, with `name`, `page`, `per_page` | | |
| 609 | + | | `run_artifacts` | `GET …/actions/runs/{id}/artifacts`, with `name` | | |
| 610 | + | | `get_artifact` | `GET …/actions/artifacts/{artifact_id}` | | |
| 611 | + | | `download_artifact` | `GET …/actions/artifacts/{artifact_id}/zip`: a `302` to a link good for 10 minutes | | |
| 612 | + | | `delete_artifact` | `DELETE …/actions/artifacts/{artifact_id}` | | |
| 613 | + | | `artifact_retention`, `set_artifact_retention` | `GET` and `PUT …/actions/permissions/artifact-and-log-retention` with `days` (it sets artifacts' days only; logs are kept with their run) | | |
| 334 | 614 | ||
| 335 | 615 | Secrets and variables have a tool of their own, `secret`: | |
| 336 | 616 | ||
| 350 | 630 | -d '{"ref": "main", "inputs": {"environment": "staging"}}' | |
| 351 | 631 | ``` | |
| 352 | 632 | ||
| 633 | + | To save an artifact from a script, follow the redirect: | |
| 634 | + | ||
| 635 | + | ```sh | |
| 636 | + | curl -L -o web-dist.zip -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 637 | + | https://api.g1t.sh/repos/acme/web/actions/artifacts/4182/zip | |
| 638 | + | ``` |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.