A repository has its own sidebar, as settings do
In a repository the sidebar slides to its menu: the way back to mission control, the repository, then Code, Work, Automate and its settings. The workspace's usage and settings stay at the bottom under its name. Actions: artifacts (upload-artifact, download-artifact) and the cache (actions/cache, cache/restore, cache/save) work, kept in Workers KV in chunks with expiry until R2 is enabled; a run's artifacts download at /repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}. A g1t agent's pull request runs its workflows when marked ready, and each head runs each workflow once.
11 files+748−830/11 viewed
| 1 | + | //! Artifacts and the cache of GitHub Actions jobs, kept in Workers KV in | |
| 2 | + | //! chunks, with KV's own expiry: artifacts for 14 days with their run, | |
| 3 | + | //! cache entries for 7 days with their repository. | |
| 4 | + | //! | |
| 5 | + | //! A sandbox reaches these with its job's token, at | |
| 6 | + | //! `/actions/jobs/{job}/artifacts[/{name}]` and `/actions/jobs/{job}/cache`. | |
| 7 | + | //! People download an artifact at | |
| 8 | + | //! `/repos/{owner}/{repo}/actions/runs/{run}/artifacts/{name}`. | |
| 9 | + | ||
| 10 | + | use serde::{Deserialize, Serialize}; | |
| 11 | + | use serde_json::{Value, json}; | |
| 12 | + | use worker::kv::KvStore; | |
| 13 | + | use worker::{Env, Request, Response, Result}; | |
| 14 | + | ||
| 15 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 16 | + | ||
| 17 | + | use crate::operations::Services; | |
| 18 | + | ||
| 19 | + | /// KV's largest value is 25 MiB; chunks stay under it. | |
| 20 | + | const CHUNK: usize = 20 * 1024 * 1024; | |
| 21 | + | /// The largest artifact or cache entry, kept within a Worker's memory. | |
| 22 | + | const MAX_BYTES: usize = 60 * 1024 * 1024; | |
| 23 | + | const ARTIFACT_TTL: u64 = 14 * 24 * 60 * 60; | |
| 24 | + | const CACHE_TTL: u64 = 7 * 24 * 60 * 60; | |
| 25 | + | ||
| 26 | + | #[derive(Serialize, Deserialize)] | |
| 27 | + | struct Meta { | |
| 28 | + | size: usize, | |
| 29 | + | chunks: usize, | |
| 30 | + | /// Milliseconds since the epoch, to find the newest cache entry. | |
| 31 | + | at: u64, | |
| 32 | + | /// The name or key it was saved under. | |
| 33 | + | name: String, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | fn store(env: &Env) -> Result<KvStore> { | |
| 37 | + | env.kv("BLOBS") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | async fn put(kv: &KvStore, base: &str, name: &str, bytes: &[u8], ttl: u64) -> Result<()> { | |
| 41 | + | let chunks: Vec<&[u8]> = if bytes.is_empty() { vec![&[][..]] } else { bytes.chunks(CHUNK).collect() }; | |
| 42 | + | for (index, chunk) in chunks.iter().enumerate() { | |
| 43 | + | kv.put_bytes(&format!("{base}#{index}"), chunk)?.expiration_ttl(ttl).execute().await?; | |
| 44 | + | } | |
| 45 | + | let meta = Meta { size: bytes.len(), chunks: chunks.len(), at: g1t_kit::now_ms(), name: name.to_owned() }; | |
| 46 | + | // The metadata travels with the key in listings, so the newest entry | |
| 47 | + | // can be found without reading each. | |
| 48 | + | kv.put(base, serde_json::to_string(&meta)?)? | |
| 49 | + | .metadata(&meta)? | |
| 50 | + | .expiration_ttl(ttl) | |
| 51 | + | .execute() | |
| 52 | + | .await?; | |
| 53 | + | Ok(()) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | async fn get(kv: &KvStore, base: &str) -> Result<Option<Vec<u8>>> { | |
| 57 | + | let Some(meta) = kv.get(base).json::<Meta>().await? else { return Ok(None) }; | |
| 58 | + | let mut out = Vec::with_capacity(meta.size); | |
| 59 | + | for index in 0..meta.chunks { | |
| 60 | + | match kv.get(&format!("{base}#{index}")).bytes().await? { | |
| 61 | + | Some(bytes) => out.extend_from_slice(&bytes), | |
| 62 | + | // A chunk that expired first: the whole entry is gone. | |
| 63 | + | None => return Ok(None), | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | Ok(Some(out)) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The metadata of every entry under a prefix, newest first. | |
| 70 | + | async fn list(kv: &KvStore, prefix: &str) -> Result<Vec<(String, Meta)>> { | |
| 71 | + | let mut found = Vec::new(); | |
| 72 | + | let mut cursor: Option<String> = None; | |
| 73 | + | loop { | |
| 74 | + | let mut listing = kv.list().prefix(prefix.to_owned()); | |
| 75 | + | if let Some(cursor) = cursor.take() { | |
| 76 | + | listing = listing.cursor(cursor); | |
| 77 | + | } | |
| 78 | + | let page = listing.execute().await?; | |
| 79 | + | for key in page.keys { | |
| 80 | + | if key.name.contains('#') { | |
| 81 | + | continue; | |
| 82 | + | } | |
| 83 | + | if let Some(meta) = key.metadata.and_then(|m| serde_json::from_value::<Meta>(m).ok()) { | |
| 84 | + | found.push((key.name, meta)); | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if page.list_complete || page.cursor.is_none() { | |
| 88 | + | break; | |
| 89 | + | } | |
| 90 | + | cursor = page.cursor; | |
| 91 | + | } | |
| 92 | + | found.sort_by_key(|entry| std::cmp::Reverse(entry.1.at)); | |
| 93 | + | Ok(found) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | fn error(status: u16, message: &str) -> Result<Response> { | |
| 97 | + | Ok(Response::from_json(&json!({ "error": { "message": message } }))?.with_status(status)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn valid_name(name: &str) -> bool { | |
| 101 | + | !name.is_empty() && name.len() <= 200 && !name.starts_with('.') && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | fn decode(text: &str) -> String { | |
| 105 | + | let bytes = text.as_bytes(); | |
| 106 | + | let mut out = Vec::with_capacity(bytes.len()); | |
| 107 | + | let mut i = 0; | |
| 108 | + | while i < bytes.len() { | |
| 109 | + | match bytes[i] { | |
| 110 | + | b'%' if i + 2 < bytes.len() => { | |
| 111 | + | match u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("zz"), 16) { | |
| 112 | + | Ok(byte) => { | |
| 113 | + | out.push(byte); | |
| 114 | + | i += 3; | |
| 115 | + | } | |
| 116 | + | Err(_) => { | |
| 117 | + | out.push(b'%'); | |
| 118 | + | i += 1; | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | b'+' => { | |
| 123 | + | out.push(b' '); | |
| 124 | + | i += 1; | |
| 125 | + | } | |
| 126 | + | byte => { | |
| 127 | + | out.push(byte); | |
| 128 | + | i += 1; | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | String::from_utf8_lossy(&out).into_owned() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | fn query(request: &Request, name: &str) -> Option<String> { | |
| 136 | + | let url = request.url().ok()?; | |
| 137 | + | url.query_pairs().find(|(key, _)| key == name).map(|(_, value)| value.into_owned()) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// A sandbox storing or fetching an artifact or cache entry. `rest` is | |
| 141 | + | /// the path after `/actions/jobs/`. | |
| 142 | + | pub async fn for_job(mut request: Request, env: &Env, services: &Services, method: &str, rest: &str) -> Result<Response> { | |
| 143 | + | let (job, what) = rest.split_once('/').unwrap_or((rest, "")); | |
| 144 | + | let token = request | |
| 145 | + | .headers() | |
| 146 | + | .get("authorization")? | |
| 147 | + | .and_then(|h| h.strip_prefix("Bearer ").map(str::to_owned)) | |
| 148 | + | .unwrap_or_default(); | |
| 149 | + | let owner: Outcome<Value> = g1t_kit::call(&services.actions, "job_auth", &json!({ "job": job, "token": token })).await?; | |
| 150 | + | let owner = match owner { | |
| 151 | + | Outcome::Ok(owner) => owner, | |
| 152 | + | Outcome::Fail(_) => return error(401, "That job is not running, or the token is not its."), | |
| 153 | + | }; | |
| 154 | + | let run = owner["run"].as_str().unwrap_or_default().to_owned(); | |
| 155 | + | let repo = owner["repoId"].as_str().unwrap_or_default().to_owned(); | |
| 156 | + | let kv = store(env)?; | |
| 157 | + | match (method, what) { | |
| 158 | + | ("GET", "artifacts") => { | |
| 159 | + | let listed: Vec<Value> = list(&kv, &format!("a/{run}/")) | |
| 160 | + | .await? | |
| 161 | + | .into_iter() | |
| 162 | + | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size })) | |
| 163 | + | .collect(); | |
| 164 | + | Response::from_json(&listed) | |
| 165 | + | } | |
| 166 | + | (_, what) if what.starts_with("artifacts/") => { | |
| 167 | + | let name = decode(&what["artifacts/".len()..]); | |
| 168 | + | if !valid_name(&name) { | |
| 169 | + | return error(400, "That is not an artifact name."); | |
| 170 | + | } | |
| 171 | + | let base = format!("a/{run}/{name}"); | |
| 172 | + | if method == "PUT" { | |
| 173 | + | let bytes = request.bytes().await?; | |
| 174 | + | if bytes.len() > MAX_BYTES { | |
| 175 | + | return error(413, "Artifacts are at most 60 MB."); | |
| 176 | + | } | |
| 177 | + | put(&kv, &base, &name, &bytes, ARTIFACT_TTL).await?; | |
| 178 | + | return Response::from_json(&json!({ "name": name, "size": bytes.len() })); | |
| 179 | + | } | |
| 180 | + | match get(&kv, &base).await? { | |
| 181 | + | Some(bytes) => Response::from_bytes(bytes), | |
| 182 | + | None => error(404, "No such artifact."), | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | (_, "cache") => { | |
| 186 | + | let key = query(&request, "key").unwrap_or_default(); | |
| 187 | + | if key.is_empty() || key.len() > 400 { | |
| 188 | + | return error(400, "A cache key is 1 to 400 characters."); | |
| 189 | + | } | |
| 190 | + | if method == "PUT" { | |
| 191 | + | let base = format!("c/{repo}/{key}"); | |
| 192 | + | // A key is written once, as on GitHub. | |
| 193 | + | if kv.get(&base).text().await?.is_some() { | |
| 194 | + | return Response::from_json(&json!({ "saved": false, "reason": "That key is already cached." })); | |
| 195 | + | } | |
| 196 | + | let bytes = request.bytes().await?; | |
| 197 | + | if bytes.len() > MAX_BYTES { | |
| 198 | + | return error(413, "Cache entries are at most 60 MB."); | |
| 199 | + | } | |
| 200 | + | put(&kv, &base, &key, &bytes, CACHE_TTL).await?; | |
| 201 | + | return Response::from_json(&json!({ "saved": true })); | |
| 202 | + | } | |
| 203 | + | // The exact key, else the newest entry under each restore key. | |
| 204 | + | let exact = format!("c/{repo}/{key}"); | |
| 205 | + | if let Some(bytes) = get(&kv, &exact).await? { | |
| 206 | + | let mut response = Response::from_bytes(bytes)?; | |
| 207 | + | response.headers_mut().set("x-g1t-key", &key)?; | |
| 208 | + | return Ok(response); | |
| 209 | + | } | |
| 210 | + | for prefix in query(&request, "restore").unwrap_or_default().lines().map(str::trim).filter(|p| !p.is_empty()) { | |
| 211 | + | if let Some((base, meta)) = list(&kv, &format!("c/{repo}/{prefix}")).await?.into_iter().next() | |
| 212 | + | && let Some(bytes) = get(&kv, &base).await? | |
| 213 | + | { | |
| 214 | + | let mut response = Response::from_bytes(bytes)?; | |
| 215 | + | response.headers_mut().set("x-g1t-key", &meta.name)?; | |
| 216 | + | return Ok(response); | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | error(404, "Nothing cached under those keys.") | |
| 220 | + | } | |
| 221 | + | _ => error(404, "No such endpoint."), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Someone who can see the run downloading one of its artifacts. | |
| 226 | + | pub async fn download(env: &Env, services: &Services, viewer: &g1t_contracts::Viewer, owner: &str, repo: &str, run: &str, name: &str) -> Result<Response> { | |
| 227 | + | let seen: Outcome<Value> = g1t_kit::call( | |
| 228 | + | &services.actions, | |
| 229 | + | "run", | |
| 230 | + | &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }), | |
| 231 | + | ) | |
| 232 | + | .await?; | |
| 233 | + | if let Outcome::Fail(refused) = seen { | |
| 234 | + | let status = if refused.code == FailureCode::NotFound { 404 } else { 403 }; | |
| 235 | + | return error(status, &refused.message); | |
| 236 | + | } | |
| 237 | + | let name = decode(name); | |
| 238 | + | match get(&store(env)?, &format!("a/{run}/{name}")).await? { | |
| 239 | + | Some(bytes) => { | |
| 240 | + | let mut response = Response::from_bytes(bytes)?; | |
| 241 | + | let headers = response.headers_mut(); | |
| 242 | + | headers.set("content-type", "application/gzip")?; | |
| 243 | + | headers.set("content-disposition", &format!("attachment; filename=\"{}.tar.gz\"", name.replace('"', "")))?; | |
| 244 | + | Ok(response) | |
| 245 | + | } | |
| 246 | + | None => error(404, "No such artifact, or it has expired."), | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// A run's artifacts, for its page. | |
| 251 | + | pub async fn of_run(env: &Env, run: &str) -> Result<Vec<Value>> { | |
| 252 | + | Ok(list(&store(env)?, &format!("a/{run}/")) | |
| 253 | + | .await? | |
| 254 | + | .into_iter() | |
| 255 | + | .map(|(_, meta)| json!({ "name": meta.name, "size": meta.size, "at": meta.at })) | |
| 256 | + | .collect()) | |
| 257 | + | } |
| 4 | 4 | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | + | mod blobs; | |
| 7 | 8 | mod mcp; | |
| 8 | 9 | mod oauth; | |
| 9 | 10 | mod openapi; | |
| 346 | 347 | return receive_hook(&mut request, &services, id).await; | |
| 347 | 348 | } | |
| 348 | 349 | ||
| 350 | + | // A sandbox's artifacts and cache, with its job's token, which is not a | |
| 351 | + | // g1t token either. | |
| 352 | + | if !on_mcp | |
| 353 | + | && let Some(rest) = path.strip_prefix("/actions/jobs/") | |
| 354 | + | && (rest.contains("/artifacts") || rest.ends_with("/cache")) | |
| 355 | + | { | |
| 356 | + | let rest = rest.to_owned(); | |
| 357 | + | return blobs::for_job(request, env, &services, method, &rest).await; | |
| 358 | + | } | |
| 359 | + | ||
| 349 | 360 | let viewer = match authenticate(&request, &services).await? { | |
| 350 | 361 | Ok(viewer) => viewer, | |
| 351 | 362 | Err(refused) => return Ok(refused), | |
| 378 | 389 | match (method, path.trim_end_matches('/')) { | |
| 379 | 390 | ("GET", "") => return Response::from_json(&index()), | |
| 380 | 391 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 392 | + | // A run's artifacts: listed, or one downloaded. | |
| 393 | + | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => { | |
| 394 | + | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); | |
| 395 | + | if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() { | |
| 396 | + | return match rest { | |
| 397 | + | [] => { | |
| 398 | + | let seen: Outcome<Value> = g1t_kit::call( | |
| 399 | + | &services.actions, | |
| 400 | + | "run", | |
| 401 | + | &json!({ "repo": { "namespace": owner, "name": repo }, "viewer": viewer, "id": run }), | |
| 402 | + | ) | |
| 403 | + | .await?; | |
| 404 | + | match seen { | |
| 405 | + | Outcome::Ok(_) => Response::from_json(&blobs::of_run(env, run).await?), | |
| 406 | + | Outcome::Fail(refused) => failure(&refused), | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | [name] => blobs::download(env, &services, &viewer, owner, repo, run, name).await, | |
| 410 | + | _ => fail(FailureCode::NotFound, "No such endpoint."), | |
| 411 | + | }; | |
| 412 | + | } | |
| 413 | + | } | |
| 381 | 414 | ("POST", "/device/code") => return device_code(&mut request, &services).await, | |
| 382 | 415 | ("POST", "/device/token") => return device_token(&mut request, &services).await, | |
| 383 | 416 | // Where a pull request lives, for a tool that knows only its fork. |
| 24 | 24 | { "binding": "AUTOMATIONS", "service": "g1t-automations" }, | |
| 25 | 25 | { "binding": "ACTIONS", "service": "g1t-actions" } | |
| 26 | 26 | ], | |
| 27 | + | // GitHub Actions artifacts and cache, in chunks, with KV's own expiry. | |
| 28 | + | // (Moves to R2 once R2 is enabled on the account.) | |
| 29 | + | "kv_namespaces": [{ "binding": "BLOBS", "id": "16a4232cb746418db53782aa068be693" }], | |
| 27 | 30 | "observability": { "enabled": true } | |
| 28 | 31 | } |
| 300 | 300 | ); | |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | + | type ActiveRepo = NonNullable<ShellData["repo"]>; | |
| 304 | + | ||
| 305 | + | /** | |
| 306 | + | * A repository's own menu, which the sidebar slides to while you are in | |
| 307 | + | * it, as it does for settings: everything about the repository and nothing | |
| 308 | + | * else, with the way back to everything. | |
| 309 | + | */ | |
| 310 | + | function RepoMenu({ repo, isPrivate, open }: { repo: ActiveRepo; isPrivate: boolean; open: boolean }) { | |
| 311 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 312 | + | return ( | |
| 313 | + | <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}> | |
| 314 | + | <Link | |
| 315 | + | to="/" | |
| 316 | + | className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg" | |
| 317 | + | > | |
| 318 | + | <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" /> | |
| 319 | + | Mission control | |
| 320 | + | </Link> | |
| 321 | + | <Link | |
| 322 | + | to={base} | |
| 323 | + | className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60" | |
| 324 | + | > | |
| 325 | + | <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> | |
| 326 | + | {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />} | |
| 327 | + | </span> | |
| 328 | + | <span className="min-w-0 truncate font-mono text-[0.8125rem]"> | |
| 329 | + | <span className="text-faint">{repo.namespace}/</span> | |
| 330 | + | <span className="font-semibold text-fg">{repo.name}</span> | |
| 331 | + | </span> | |
| 332 | + | </Link> | |
| 333 | + | <SidebarGroup title="Code"> | |
| 334 | + | <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}> | |
| 335 | + | Code | |
| 336 | + | </SidebarLink> | |
| 337 | + | <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}> | |
| 338 | + | Commits | |
| 339 | + | </SidebarLink> | |
| 340 | + | </SidebarGroup> | |
| 341 | + | <SidebarGroup title="Work"> | |
| 342 | + | <SidebarLink to={`${base}/issues`} icon={<CircleDot size={15} />} count={repo.issues}> | |
| 343 | + | Issues | |
| 344 | + | </SidebarLink> | |
| 345 | + | <SidebarLink to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={repo.pulls}> | |
| 346 | + | Pull requests | |
| 347 | + | </SidebarLink> | |
| 348 | + | {repo.member && ( | |
| 349 | + | <SidebarLink to={`${base}/plans`} icon={<ListTree size={15} />}> | |
| 350 | + | Plan | |
| 351 | + | </SidebarLink> | |
| 352 | + | )} | |
| 353 | + | <SidebarLink to={`${base}/queue`} icon={<Layers size={15} />}> | |
| 354 | + | Merge queue | |
| 355 | + | </SidebarLink> | |
| 356 | + | </SidebarGroup> | |
| 357 | + | <SidebarGroup title="Automate"> | |
| 358 | + | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 359 | + | Actions | |
| 360 | + | </SidebarLink> | |
| 361 | + | <SidebarLink to={`${base}/automations`} icon={<Zap size={15} />}> | |
| 362 | + | Automations | |
| 363 | + | </SidebarLink> | |
| 364 | + | </SidebarGroup> | |
| 365 | + | {repo.member && ( | |
| 366 | + | <SidebarGroup title="Repository"> | |
| 367 | + | <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 368 | + | Settings | |
| 369 | + | </SidebarLink> | |
| 370 | + | </SidebarGroup> | |
| 371 | + | )} | |
| 372 | + | </nav> | |
| 373 | + | ); | |
| 374 | + | } | |
| 375 | + | ||
| 303 | 376 | /** Your own settings, as the sidebar shows them on the settings page. */ | |
| 304 | 377 | function AccountSettingsMenu({ open }: { open: boolean }) { | |
| 305 | 378 | const { hash } = useLocation(); | |
| 344 | 417 | // the sidebar over. | |
| 345 | 418 | const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug; | |
| 346 | 419 | const inAccount = (going ?? pathname) === "/settings"; | |
| 347 | − | const away = inSettings || inAccount; | |
| 348 | − | // Which settings sit on the far side of the track. Kept while sliding back, | |
| 349 | − | // so they do not vanish on the way out. | |
| 350 | − | const side = useRef<"workspace" | "account">("workspace"); | |
| 420 | + | const active = shell.repo; | |
| 421 | + | // In a repository, or on the way into one, its own menu takes the sidebar. | |
| 422 | + | const target = going ?? pathname; | |
| 423 | + | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); | |
| 424 | + | const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 425 | + | const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-"; | |
| 426 | + | const away = inSettings || inAccount || inRepo; | |
| 427 | + | // What sits on the far side of the track. Kept while sliding back, so it | |
| 428 | + | // does not vanish on the way out. | |
| 429 | + | const side = useRef<"workspace" | "account" | "repo">("workspace"); | |
| 351 | 430 | if (inAccount) side.current = "account"; | |
| 352 | 431 | else if (inSettings) side.current = "workspace"; | |
| 353 | − | const active = shell.repo; | |
| 354 | − | const repoBase = active ? `/${active.namespace}/${active.name}` : null; | |
| 432 | + | else if (inRepo) side.current = "repo"; | |
| 433 | + | // The repository last shown, kept for the slide back. | |
| 434 | + | const shown = useRef(active); | |
| 435 | + | if (active) shown.current = active; | |
| 355 | 436 | // The repository being looked at is listed even when it is someone else's. | |
| 356 | 437 | const listed = | |
| 357 | 438 | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 410 | 491 | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 411 | 492 | )} | |
| 412 | 493 | {listed.map((repo) => { | |
| 413 | − | const open = | |
| 414 | − | active && repo.namespace === active.namespace && repo.name === active.name; | |
| 415 | 494 | const base = `/${repo.namespace}/${repo.name}`; | |
| 416 | 495 | return ( | |
| 417 | 496 | <div key={base}> | |
| 418 | 497 | <SidebarLink | |
| 419 | 498 | to={base} | |
| 420 | − | end={!open} | |
| 421 | 499 | icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />} | |
| 422 | 500 | > | |
| 423 | 501 | <span className="font-mono text-[0.8125rem]"> | |
| 427 | 505 | {repo.name} | |
| 428 | 506 | </span> | |
| 429 | 507 | </SidebarLink> | |
| 430 | − | {open && repoBase && ( | |
| 431 | − | <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2"> | |
| 432 | − | <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}> | |
| 433 | − | Code | |
| 434 | − | </SidebarLink> | |
| 435 | − | <SidebarLink | |
| 436 | − | to={`${repoBase}/issues`} | |
| 437 | − | icon={<CircleDot size={14} />} | |
| 438 | − | count={active.issues} | |
| 439 | − | > | |
| 440 | − | Issues | |
| 441 | − | </SidebarLink> | |
| 442 | − | <SidebarLink | |
| 443 | − | to={`${repoBase}/pulls`} | |
| 444 | − | also={`${repoBase}/pull`} | |
| 445 | − | icon={<GitPullRequest size={14} />} | |
| 446 | − | count={active.pulls} | |
| 447 | − | > | |
| 448 | − | Pull requests | |
| 449 | − | </SidebarLink> | |
| 450 | − | <SidebarLink to={`${repoBase}/actions`} icon={<PlayCircle size={14} />}> | |
| 451 | − | Actions | |
| 452 | − | </SidebarLink> | |
| 453 | − | <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}> | |
| 454 | − | Merge queue | |
| 455 | − | </SidebarLink> | |
| 456 | − | <SidebarLink | |
| 457 | − | to={`${repoBase}/commits`} | |
| 458 | − | also={`${repoBase}/commit`} | |
| 459 | − | icon={<History size={14} />} | |
| 460 | − | > | |
| 461 | − | Commits | |
| 462 | − | </SidebarLink> | |
| 463 | − | {active.member && ( | |
| 464 | − | <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}> | |
| 465 | − | Plan | |
| 466 | − | </SidebarLink> | |
| 467 | − | )} | |
| 468 | − | <SidebarLink to={`${repoBase}/automations`} icon={<Zap size={14} />}> | |
| 469 | − | Automations | |
| 470 | − | </SidebarLink> | |
| 471 | − | {active.member && ( | |
| 472 | − | <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}> | |
| 473 | − | Settings | |
| 474 | − | </SidebarLink> | |
| 475 | − | )} | |
| 476 | − | </div> | |
| 477 | − | )} | |
| 478 | 508 | </div> | |
| 479 | 509 | ); | |
| 480 | 510 | })} | |
| 481 | 511 | </SidebarGroup> | |
| 482 | 512 | </nav> | |
| 483 | − | {side.current === "account" || !ws ? ( | |
| 513 | + | {side.current === "repo" && shown.current ? ( | |
| 514 | + | <RepoMenu | |
| 515 | + | repo={shown.current} | |
| 516 | + | isPrivate={shell.repos.some((repo) => repo.namespace === shown.current!.namespace && repo.name === shown.current!.name && repo.isPrivate)} | |
| 517 | + | open={inRepo} | |
| 518 | + | /> | |
| 519 | + | ) : side.current === "account" || !ws ? ( | |
| 484 | 520 | <AccountSettingsMenu open={inAccount} /> | |
| 485 | 521 | ) : ( | |
| 486 | 522 | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 492 | 528 | <div className="space-y-2 p-2"> | |
| 493 | 529 | {ws && ( | |
| 494 | 530 | <div className="space-y-px"> | |
| 531 | + | <p className="px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">{ws.slug}</p> | |
| 495 | 532 | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}> | |
| 496 | 533 | Usage | |
| 497 | 534 | </SidebarLink> |
| 79 | 79 | pub fn wants_type(&self, action: Option<&str>) -> bool { | |
| 80 | 80 | let Some(action) = action else { return true }; | |
| 81 | 81 | if self.types.is_empty() { | |
| 82 | + | // A g1t agent's pull request has no code until it is marked | |
| 83 | + | // ready, so that is when its default runs start, as `opened` | |
| 84 | + | // would on GitHub. | |
| 85 | + | if action == "ready_for_review" && self.event.starts_with("pull_request") && self.event != "pull_request_review" { | |
| 86 | + | return true; | |
| 87 | + | } | |
| 82 | 88 | let defaults = default_types(&self.event); | |
| 83 | 89 | return defaults.is_empty() || defaults.contains(&action); | |
| 84 | 90 | } | |
| 539 | 545 | assert!(pr.wants_type(Some("opened"))); | |
| 540 | 546 | assert!(pr.wants_type(Some("synchronize"))); | |
| 541 | 547 | assert!(!pr.wants_type(Some("closed"))); | |
| 548 | + | assert!(pr.wants_type(Some("ready_for_review"))); | |
| 542 | 549 | let typed = parse("on:\n pull_request:\n types: [closed]\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: 'true' }]").unwrap(); | |
| 543 | 550 | assert!(typed.trigger("pull_request").unwrap().wants_type(Some("closed"))); | |
| 544 | 551 | assert!(!typed.trigger("pull_request").unwrap().wants_type(Some("opened"))); |
| 1 | + | //! Artifacts and the cache: `actions/upload-artifact`, | |
| 2 | + | //! `actions/download-artifact` and `actions/cache`, done natively against | |
| 3 | + | //! g1t, which keeps them in R2. Artifacts belong to the run; cache entries | |
| 4 | + | //! to the repository, found by exact key or by the newest under a | |
| 5 | + | //! `restore-keys` prefix. | |
| 6 | + | ||
| 7 | + | use std::collections::BTreeMap; | |
| 8 | + | use std::io::Read; | |
| 9 | + | use std::path::Path; | |
| 10 | + | use std::process::Command; | |
| 11 | + | use std::time::Duration; | |
| 12 | + | ||
| 13 | + | use super::process::{self, Commands, Ended}; | |
| 14 | + | use super::{Job, Post, PostRun}; | |
| 15 | + | ||
| 16 | + | /// The largest upload g1t takes, as the platform limits a request. | |
| 17 | + | const MAX_UPLOAD: u64 = 60 * 1024 * 1024; | |
| 18 | + | ||
| 19 | + | fn lines(text: &str) -> Vec<String> { | |
| 20 | + | text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')).map(str::to_owned).collect() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | fn quote(text: &str) -> String { | |
| 24 | + | format!("'{}'", text.replace('\'', "'\\''")) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | fn safe_name(name: &str) -> bool { | |
| 28 | + | !name.is_empty() && name.len() <= 200 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ' ')) && !name.starts_with('.') | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl Job { | |
| 32 | + | fn url(&self, rest: &str) -> String { | |
| 33 | + | format!("{}/actions/jobs/{}/{rest}", self.log.api.base, self.log.api.job) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | fn auth(&self) -> String { | |
| 37 | + | format!("Bearer {}", self.log.api.token) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// Runs a shell line, logging its output; whether it succeeded. | |
| 41 | + | fn shell(&mut self, script: &str) -> bool { | |
| 42 | + | let mut command = Command::new("bash"); | |
| 43 | + | command.args(["-c", script]).current_dir(&self.workspace); | |
| 44 | + | let mut commands = Commands::default(); | |
| 45 | + | matches!(process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | fn upload(&mut self, rest: &str, file: &Path) -> Result<u64, String> { | |
| 49 | + | let size = std::fs::metadata(file).map_err(|e| e.to_string())?.len(); | |
| 50 | + | if size > MAX_UPLOAD { | |
| 51 | + | return Err(format!("it is {} MB, more than g1t takes at once ({} MB)", size / 1_048_576, MAX_UPLOAD / 1_048_576)); | |
| 52 | + | } | |
| 53 | + | let bytes = std::fs::read(file).map_err(|e| e.to_string())?; | |
| 54 | + | ureq::put(&self.url(rest)) | |
| 55 | + | .set("authorization", &self.auth()) | |
| 56 | + | .set("content-type", "application/gzip") | |
| 57 | + | .timeout(Duration::from_secs(600)) | |
| 58 | + | .send_bytes(&bytes) | |
| 59 | + | .map_err(|e| e.to_string())?; | |
| 60 | + | Ok(size) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Downloads into `file`; `Ok(None)` when there is nothing there. | |
| 64 | + | fn download(&mut self, rest: &str, file: &Path) -> Result<Option<String>, String> { | |
| 65 | + | let response = match ureq::get(&self.url(rest)).set("authorization", &self.auth()).timeout(Duration::from_secs(600)).call() { | |
| 66 | + | Ok(response) => response, | |
| 67 | + | Err(ureq::Error::Status(404, _)) => return Ok(None), | |
| 68 | + | Err(error) => return Err(error.to_string()), | |
| 69 | + | }; | |
| 70 | + | let matched = response.header("x-g1t-key").map(str::to_owned).unwrap_or_default(); | |
| 71 | + | let mut bytes = Vec::new(); | |
| 72 | + | response.into_reader().take(MAX_UPLOAD * 2).read_to_end(&mut bytes).map_err(|e| e.to_string())?; | |
| 73 | + | std::fs::write(file, bytes).map_err(|e| e.to_string())?; | |
| 74 | + | Ok(Some(matched)) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// `actions/upload-artifact`. | |
| 78 | + | pub(crate) fn upload_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 79 | + | let name = with.get("name").filter(|n| !n.is_empty()).cloned().unwrap_or_else(|| "artifact".into()); | |
| 80 | + | if !safe_name(&name) { | |
| 81 | + | self.log.line(&format!("##[error]`{name}` is not an artifact name g1t takes: letters, digits, spaces, `-`, `_` and `.`.")); | |
| 82 | + | return (false, BTreeMap::new()); | |
| 83 | + | } | |
| 84 | + | let paths = lines(with.get("path").map(String::as_str).unwrap_or_default()); | |
| 85 | + | let missing = with.get("if-no-files-found").map(String::as_str).unwrap_or("warn").to_owned(); | |
| 86 | + | let archive = self.temp.join(format!("artifact-{name}.tgz")); | |
| 87 | + | // As on GitHub: one folder uploads its contents; otherwise paths | |
| 88 | + | // are kept relative to the workspace. | |
| 89 | + | let single_dir = paths.len() == 1 && self.workspace.join(&paths[0]).is_dir(); | |
| 90 | + | let script = if single_dir { | |
| 91 | + | format!("tar -czf {} -C {} .", quote(&archive.display().to_string()), quote(&paths[0])) | |
| 92 | + | } else { | |
| 93 | + | let patterns: Vec<String> = paths.iter().filter(|p| !p.starts_with('!')).map(|p| p.replace('\'', "")).collect(); | |
| 94 | + | format!( | |
| 95 | + | "shopt -s globstar nullglob dotglob; files=( {} ); if [ ${{#files[@]}} -eq 0 ]; then exit 3; fi; tar -czf {} -- \"${{files[@]}}\"", | |
| 96 | + | patterns.join(" "), | |
| 97 | + | quote(&archive.display().to_string()) | |
| 98 | + | ) | |
| 99 | + | }; | |
| 100 | + | let mut command = Command::new("bash"); | |
| 101 | + | command.args(["-c", &script]).current_dir(&self.workspace); | |
| 102 | + | let mut commands = Commands::default(); | |
| 103 | + | match process::run(command, Duration::from_secs(1800), &mut self.log, &mut commands) { | |
| 104 | + | Ok(Ended::Exited(0)) => {} | |
| 105 | + | Ok(Ended::Exited(3)) => { | |
| 106 | + | let message = format!("No files were found at {}.", paths.join(", ")); | |
| 107 | + | return match missing.as_str() { | |
| 108 | + | "error" => { | |
| 109 | + | self.log.line(&format!("##[error]{message}")); | |
| 110 | + | (false, BTreeMap::new()) | |
| 111 | + | } | |
| 112 | + | "ignore" => (true, BTreeMap::new()), | |
| 113 | + | _ => { | |
| 114 | + | self.log.line(&format!("##[warning]{message} Nothing was uploaded.")); | |
| 115 | + | (true, BTreeMap::new()) | |
| 116 | + | } | |
| 117 | + | }; | |
| 118 | + | } | |
| 119 | + | _ => { | |
| 120 | + | self.log.line("##[error]The files could not be packed."); | |
| 121 | + | return (false, BTreeMap::new()); | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | match self.upload(&format!("artifacts/{name}"), &archive) { | |
| 125 | + | Ok(size) => { | |
| 126 | + | self.log.line(&format!("Uploaded artifact {name} ({} KB). It is kept with the run for 14 days.", size.div_ceil(1024))); | |
| 127 | + | let mut outputs = BTreeMap::new(); | |
| 128 | + | outputs.insert("artifact-id".into(), name.clone()); | |
| 129 | + | (true, outputs) | |
| 130 | + | } | |
| 131 | + | Err(error) => { | |
| 132 | + | self.log.line(&format!("##[error]The artifact could not be uploaded: {error}")); | |
| 133 | + | (false, BTreeMap::new()) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// `actions/download-artifact`: one by name, or every artifact of the | |
| 139 | + | /// run, each into a folder of its name. | |
| 140 | + | pub(crate) fn download_artifact(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 141 | + | let dest = with.get("path").filter(|p| !p.is_empty()).map_or(self.workspace.clone(), |p| self.workspace.join(p)); | |
| 142 | + | let names: Vec<String> = match with.get("name").filter(|n| !n.is_empty()) { | |
| 143 | + | Some(name) => vec![name.clone()], | |
| 144 | + | None => { | |
| 145 | + | let listed = ureq::get(&self.url("artifacts")).set("authorization", &self.auth()).call().ok().and_then(|r| r.into_json::<Vec<serde_json::Value>>().ok()).unwrap_or_default(); | |
| 146 | + | listed.iter().filter_map(|a| a["name"].as_str().map(str::to_owned)).collect() | |
| 147 | + | } | |
| 148 | + | }; | |
| 149 | + | let merge = with.get("merge-multiple").is_some_and(|m| m == "true"); | |
| 150 | + | let single = with.get("name").is_some_and(|n| !n.is_empty()); | |
| 151 | + | for name in &names { | |
| 152 | + | let archive = self.temp.join(format!("download-{name}.tgz")); | |
| 153 | + | match self.download(&format!("artifacts/{name}"), &archive) { | |
| 154 | + | Ok(Some(_)) => {} | |
| 155 | + | Ok(None) => { | |
| 156 | + | self.log.line(&format!("##[error]This run has no artifact called {name}.")); | |
| 157 | + | return (false, BTreeMap::new()); | |
| 158 | + | } | |
| 159 | + | Err(error) => { | |
| 160 | + | self.log.line(&format!("##[error]The artifact {name} could not be downloaded: {error}")); | |
| 161 | + | return (false, BTreeMap::new()); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | let target = if single || merge { dest.clone() } else { dest.join(name) }; | |
| 165 | + | let _ = std::fs::create_dir_all(&target); | |
| 166 | + | if !self.shell(&format!("tar -xzf {} -C {}", quote(&archive.display().to_string()), quote(&target.display().to_string()))) { | |
| 167 | + | return (false, BTreeMap::new()); | |
| 168 | + | } | |
| 169 | + | self.log.line(&format!("Downloaded artifact {name} into {}", target.display())); | |
| 170 | + | } | |
| 171 | + | let mut outputs = BTreeMap::new(); | |
| 172 | + | outputs.insert("download-path".into(), dest.display().to_string()); | |
| 173 | + | (true, outputs) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | fn cache_paths(&self, with: &BTreeMap<String, String>) -> Vec<String> { | |
| 177 | + | let home = self.base_env_value("HOME").unwrap_or_else(|| "/home/node".into()); | |
| 178 | + | lines(with.get("path").map(String::as_str).unwrap_or_default()) | |
| 179 | + | .into_iter() | |
| 180 | + | .map(|p| { | |
| 181 | + | let p = if let Some(rest) = p.strip_prefix("~/") { format!("{home}/{rest}") } else { p }; | |
| 182 | + | if p.starts_with('/') { p } else { self.workspace.join(p).display().to_string() } | |
| 183 | + | }) | |
| 184 | + | .collect() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// `actions/cache` and `actions/cache/restore`: restores what it can, | |
| 188 | + | /// and for `actions/cache`, saves at the end of the job on a miss. | |
| 189 | + | pub(crate) fn cache(&mut self, with: &BTreeMap<String, String>, save_after: bool, title: &str) -> (bool, BTreeMap<String, String>) { | |
| 190 | + | let key = with.get("key").cloned().unwrap_or_default(); | |
| 191 | + | if key.is_empty() { | |
| 192 | + | self.log.line("##[error]The cache needs a `key`."); | |
| 193 | + | return (false, BTreeMap::new()); | |
| 194 | + | } | |
| 195 | + | let paths = self.cache_paths(with); | |
| 196 | + | let restore = lines(with.get("restore-keys").map(String::as_str).unwrap_or_default()); | |
| 197 | + | let query = format!( | |
| 198 | + | "cache?key={}&restore={}", | |
| 199 | + | urlencode(&key), | |
| 200 | + | urlencode(&restore.join("\n")) | |
| 201 | + | ); | |
| 202 | + | let archive = self.temp.join("cache-restore.tgz"); | |
| 203 | + | let mut outputs = BTreeMap::new(); | |
| 204 | + | let exact = match self.download(&query, &archive) { | |
| 205 | + | Ok(Some(matched)) => { | |
| 206 | + | let lookup_only = with.get("lookup-only").is_some_and(|v| v == "true"); | |
| 207 | + | if !lookup_only && !self.shell(&format!("tar -xzPf {}", quote(&archive.display().to_string()))) { | |
| 208 | + | self.log.line("##[warning]The cache was found but could not be unpacked."); | |
| 209 | + | } | |
| 210 | + | self.log.line(&format!("Cache restored from key: {matched}")); | |
| 211 | + | outputs.insert("cache-matched-key".into(), matched.clone()); | |
| 212 | + | matched == key | |
| 213 | + | } | |
| 214 | + | Ok(None) => { | |
| 215 | + | self.log.line(&format!("Cache not found for input keys: {}", std::iter::once(key.clone()).chain(restore).collect::<Vec<_>>().join(", "))); | |
| 216 | + | if with.get("fail-on-cache-miss").is_some_and(|v| v == "true") { | |
| 217 | + | self.log.line("##[error]The cache missed, and `fail-on-cache-miss` is set."); | |
| 218 | + | return (false, outputs); | |
| 219 | + | } | |
| 220 | + | false | |
| 221 | + | } | |
| 222 | + | Err(error) => { | |
| 223 | + | self.log.line(&format!("##[warning]The cache could not be read: {error}")); | |
| 224 | + | false | |
| 225 | + | } | |
| 226 | + | }; | |
| 227 | + | outputs.insert("cache-hit".into(), exact.to_string()); | |
| 228 | + | outputs.insert("cache-primary-key".into(), key.clone()); | |
| 229 | + | if save_after && !exact { | |
| 230 | + | self.posts.push(Post { | |
| 231 | + | name: format!("Post {title}"), | |
| 232 | + | condition: "success()".into(), | |
| 233 | + | env: BTreeMap::new(), | |
| 234 | + | run: PostRun::CacheSave { key, paths }, | |
| 235 | + | }); | |
| 236 | + | } | |
| 237 | + | (true, outputs) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Saves paths under a key, unless the key is taken. | |
| 241 | + | pub(crate) fn cache_save(&mut self, key: &str, paths: &[String]) -> bool { | |
| 242 | + | if paths.is_empty() { | |
| 243 | + | self.log.line("##[warning]Nothing to cache: no `path`."); | |
| 244 | + | return true; | |
| 245 | + | } | |
| 246 | + | let archive = self.temp.join("cache-save.tgz"); | |
| 247 | + | let list: Vec<String> = paths.iter().filter(|p| Path::new(p).exists()).map(|p| quote(p)).collect(); | |
| 248 | + | if list.is_empty() { | |
| 249 | + | self.log.line("##[warning]None of the cache's paths exist; nothing was saved."); | |
| 250 | + | return true; | |
| 251 | + | } | |
| 252 | + | if !self.shell(&format!("tar -czPf {} {}", quote(&archive.display().to_string()), list.join(" "))) { | |
| 253 | + | self.log.line("##[warning]The cache could not be packed; nothing was saved."); | |
| 254 | + | return true; | |
| 255 | + | } | |
| 256 | + | match self.upload(&format!("cache?key={}", urlencode(key)), &archive) { | |
| 257 | + | Ok(size) => self.log.line(&format!("Cache saved with key: {key} ({} KB)", size.div_ceil(1024))), | |
| 258 | + | // A cache that cannot be saved does not fail the job, as on GitHub. | |
| 259 | + | Err(error) => self.log.line(&format!("##[warning]The cache could not be saved: {error}")), | |
| 260 | + | } | |
| 261 | + | true | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// `actions/cache/save`. | |
| 265 | + | pub(crate) fn cache_save_now(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 266 | + | let key = with.get("key").cloned().unwrap_or_default(); | |
| 267 | + | let paths = self.cache_paths(with); | |
| 268 | + | (self.cache_save(&key, &paths), BTreeMap::new()) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | fn urlencode(text: &str) -> String { | |
| 273 | + | let mut out = String::new(); | |
| 274 | + | for byte in text.bytes() { | |
| 275 | + | if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { | |
| 276 | + | out.push(byte as char); | |
| 277 | + | } else { | |
| 278 | + | out.push_str(&format!("%{byte:02X}")); | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | out | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | #[cfg(test)] | |
| 285 | + | mod tests { | |
| 286 | + | use super::*; | |
| 287 | + | ||
| 288 | + | #[test] | |
| 289 | + | fn keys_are_encoded_and_names_checked() { | |
| 290 | + | assert_eq!(urlencode("Linux-node-abc/1 2"), "Linux-node-abc%2F1%202"); | |
| 291 | + | assert!(safe_name("coverage report")); | |
| 292 | + | assert!(!safe_name("../etc")); | |
| 293 | + | assert_eq!(lines("dist/\n\n# note\n coverage \n"), ["dist/", "coverage"]); | |
| 294 | + | } | |
| 295 | + | } |
| 8 | 8 | //! and `ACTIONS_TOKEN`, the job and its own token. Everything else, the | |
| 9 | 9 | //! job's definition, its contexts and its secrets, is fetched with them. | |
| 10 | 10 | ||
| 11 | + | mod blobs; | |
| 11 | 12 | mod files; | |
| 12 | 13 | mod process; | |
| 13 | 14 | mod report; | |
| 42 | 43 | pub(crate) env: BTreeMap<String, String>, | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | − | /// An action's `post` step, run when the job's steps are done. | |
| 46 | + | /// A step run when the job's steps are done: an action's `post`, or | |
| 47 | + | /// saving the cache. | |
| 46 | 48 | pub(crate) struct Post { | |
| 47 | 49 | pub(crate) name: String, | |
| 48 | − | pub(crate) action_dir: PathBuf, | |
| 49 | − | pub(crate) script: String, | |
| 50 | 50 | pub(crate) condition: String, | |
| 51 | 51 | pub(crate) env: BTreeMap<String, String>, | |
| 52 | + | pub(crate) run: PostRun, | |
| 52 | 53 | } | |
| 53 | 54 | ||
| 55 | + | pub(crate) enum PostRun { | |
| 56 | + | Node { action_dir: PathBuf, script: String }, | |
| 57 | + | CacheSave { key: String, paths: Vec<String> }, | |
| 58 | + | } | |
| 59 | + | ||
| 54 | 60 | pub(crate) struct Job { | |
| 55 | 61 | pub(crate) log: Log, | |
| 56 | 62 | pub(crate) spec: Value, | |
| 94 | 100 | } | |
| 95 | 101 | ||
| 96 | 102 | impl Job { | |
| 103 | + | pub(crate) fn base_env_value(&self, name: &str) -> Option<String> { | |
| 104 | + | self.base_env.get(name).cloned() | |
| 105 | + | } | |
| 106 | + | ||
| 97 | 107 | fn status(&self) -> Status { | |
| 98 | 108 | if self.failed { Status::Failure } else { Status::Success } | |
| 99 | 109 | } | |
| 534 | 544 | } | |
| 535 | 545 | job.log.step(number); | |
| 536 | 546 | job.log.step_state(number, &post.name, "in_progress", None); | |
| 537 | − | let ok = job.run_node(&post.action_dir, &post.script, &post.env); | |
| 547 | + | let ok = match &post.run { | |
| 548 | + | PostRun::Node { action_dir, script } => job.run_node(action_dir, script, &post.env), | |
| 549 | + | PostRun::CacheSave { key, paths } => job.cache_save(key, paths), | |
| 550 | + | }; | |
| 538 | 551 | job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" })); | |
| 539 | 552 | if !ok { | |
| 540 | 553 | job.failed = true; |
| 15 | 15 | ||
| 16 | 16 | use super::files::StepFiles; | |
| 17 | 17 | use super::process::{self, Commands, Ended}; | |
| 18 | − | use super::{Frame, Job, Post}; | |
| 18 | + | use super::{Frame, Job, Post, PostRun}; | |
| 19 | 19 | ||
| 20 | 20 | const ACTIONS_DIR: &str = "/home/runner/_actions"; | |
| 21 | 21 | ||
| 214 | 214 | let lower = name.to_ascii_lowercase(); | |
| 215 | 215 | match lower.as_str() { | |
| 216 | 216 | "actions/checkout" => return self.checkout(with), | |
| 217 | − | "actions/upload-artifact" => { | |
| 218 | − | self.log.line("##[warning]Artifacts are not kept on g1t yet: nothing was uploaded, and the job goes on."); | |
| 219 | − | return (true, BTreeMap::new()); | |
| 220 | − | } | |
| 221 | − | "actions/download-artifact" => { | |
| 222 | − | self.log.line("##[error]Artifacts are not kept on g1t yet, so there is nothing to download."); | |
| 223 | − | return (false, BTreeMap::new()); | |
| 224 | − | } | |
| 217 | + | "actions/upload-artifact" => return self.upload_artifact(with), | |
| 218 | + | "actions/download-artifact" => return self.download_artifact(with), | |
| 219 | + | "actions/cache" => return self.cache(with, true, title), | |
| 220 | + | "actions/cache/restore" => return self.cache(with, false, title), | |
| 221 | + | "actions/cache/save" => return self.cache_save_now(with), | |
| 225 | 222 | _ => {} | |
| 226 | 223 | } | |
| 227 | 224 | let source = if let Some(local) = name.strip_prefix("./") { | |
| 330 | 327 | } | |
| 331 | 328 | self.posts.push(Post { | |
| 332 | 329 | name: format!("Post {title}"), | |
| 333 | − | action_dir: dir.clone(), | |
| 334 | − | script: post, | |
| 335 | 330 | condition: condition_of("post-if"), | |
| 336 | 331 | env: post_env, | |
| 332 | + | run: PostRun::Node { action_dir: dir.clone(), script: post }, | |
| 337 | 333 | }); | |
| 338 | 334 | } | |
| 339 | 335 | return (ok, outputs); |
| 176 | 176 | "set_setting" => reply(&service.set_setting(args(body)?).await?), | |
| 177 | 177 | "delete_setting" => reply(&service.delete_setting(args(body)?).await?), | |
| 178 | 178 | "job_spec" => reply(&service.job_spec(args(body)?).await?), | |
| 179 | + | "job_auth" => reply(&service.job_auth(args(body)?).await?), | |
| 179 | 180 | "job_report" => reply(&service.job_report(args(body)?).await?), | |
| 180 | 181 | _ => Response::error("Unknown method", 404), | |
| 181 | 182 | } |
| 945 | 945 | }) | |
| 946 | 946 | } | |
| 947 | 947 | ||
| 948 | + | /// `job_auth`: which run and repository a running job's token is for, | |
| 949 | + | /// so the API can keep its artifacts and cache. | |
| 950 | + | pub async fn job_auth(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 951 | + | let job = check!(self.job_for_token(&a).await?); | |
| 952 | + | Ok(Outcome::Ok(json!({ "run": job.run_id, "repoId": job.repo_id }))) | |
| 953 | + | } | |
| 954 | + | ||
| 948 | 955 | /// `job_spec`: everything the sandbox needs to run the job. | |
| 949 | 956 | pub async fn job_spec(&self, a: JobCallArgs) -> Result<Outcome<Value>> { | |
| 950 | 957 | let job = check!(self.job_for_token(&a).await?); |
| 206 | 206 | subject.paths = Some(pull.files.iter().map(|f| f.path.clone()).collect()); | |
| 207 | 207 | return Ok(Some(subject)); | |
| 208 | 208 | } | |
| 209 | − | // A merged pull request's run is on the commit it landed as. | |
| 210 | − | let sha = match (action, data["commit"].as_str()) { | |
| 211 | − | (Some("closed"), Some(commit)) => commit.to_owned(), | |
| 212 | − | _ => match &pull.head_commit { | |
| 213 | − | Some(head) => head.clone(), | |
| 214 | − | None => return Ok(None), | |
| 215 | − | }, | |
| 209 | + | // A merged pull request's run is on the commit it landed as, | |
| 210 | + | // in the repository; otherwise on its head, where that is. | |
| 211 | + | let landed = match (action, data["commit"].as_str()) { | |
| 212 | + | (Some("closed"), Some(commit)) => Some(commit.to_owned()), | |
| 213 | + | _ => None, | |
| 214 | + | }; | |
| 215 | + | let sha = match (&landed, data["commit"].as_str(), &pull.head_commit) { | |
| 216 | + | (Some(commit), _, _) => commit.clone(), | |
| 217 | + | (None, Some(commit), _) => commit.to_owned(), | |
| 218 | + | (None, None, Some(head)) => head.clone(), | |
| 219 | + | (None, None, None) => return Ok(None), | |
| 220 | + | }; | |
| 221 | + | let source = match landed { | |
| 222 | + | Some(_) => path.clone(), | |
| 223 | + | None => pull.fork.clone().unwrap_or_else(|| path.clone()), | |
| 216 | 224 | }; | |
| 217 | − | let source = pull.fork.clone().unwrap_or_else(|| path.clone()); | |
| 218 | 225 | Some(Subject { | |
| 219 | − | source: if data["commit"].is_string() { path.clone() } else { source }, | |
| 226 | + | source, | |
| 220 | 227 | source_ref: Some(sha.clone()), | |
| 221 | 228 | git_ref: format!("refs/pull/{}/merge", pull.number), | |
| 222 | 229 | sha, | |
| 281 | 288 | continue; | |
| 282 | 289 | }; | |
| 283 | 290 | let read = self.read_workflows(&subject.source, &ws, subject.source_ref.as_deref()).await?; | |
| 284 | − | self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &event.id, event.actor.as_deref(), &sender) | |
| 291 | + | // A pull request's head runs each workflow once, however many | |
| 292 | + | // events say it is there (marked ready, and pushed). | |
| 293 | + | let key = match subject.pull { | |
| 294 | + | Some(number) if event_name.starts_with("pull_request") && event_name != "pull_request_review" => { | |
| 295 | + | let phase = if action == Some("closed") { "closed" } else { "open" }; | |
| 296 | + | format!("{event_name}:{number}:{}:{phase}", subject.sha) | |
| 297 | + | } | |
| 298 | + | _ => event.id.clone(), | |
| 299 | + | }; | |
| 300 | + | self.start_matching(&repo, &ws, read, &mut subject, event_name, action, &key, event.actor.as_deref(), &sender) | |
| 285 | 301 | .await?; | |
| 286 | 302 | } | |
| 287 | 303 | Ok(()) |