Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)
| 14 | 14 | mod checks; | |
| 15 | 15 | mod deployments; | |
| 16 | 16 | mod deploy_keys; | |
| 17 | + | mod logs; | |
| 17 | 18 | mod mcp; | |
| 18 | 19 | mod notifications; | |
| 19 | 20 | mod oauth; | |
| ⋯ | |||
| 642 | 643 | return mcp::handle(request, &services, &viewer).await; | |
| 643 | 644 | } | |
| 644 | 645 | ||
| 646 | + | // Workflow logs to download: a run's as a zip, a job's as text (logs.rs). | |
| 647 | + | if method == "GET" { | |
| 648 | + | let text = url.query_pairs().any(|(name, value)| name == "format" && value == "text"); | |
| 649 | + | if let Some(wanted) = logs::wanted(&path, text) { | |
| 650 | + | return logs::download(&services, &viewer, wanted).await; | |
| 651 | + | } | |
| 652 | + | } | |
| 653 | + | ||
| 645 | 654 | match (method, path.trim_end_matches('/')) { | |
| 646 | 655 | ("GET", "") => return reply(&index(&services.addresses)), | |
| 647 | 656 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 1 | + | //! Downloading workflow logs, at GitHub's addresses: a run's (or one | |
| 2 | + | //! attempt's) as a zip archive, and one job's as plain text. | |
| 3 | + | //! | |
| 4 | + | //! - `GET /repos/{owner}/{repo}/actions/runs/{id}/logs` | |
| 5 | + | //! - `GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}/logs` | |
| 6 | + | //! - `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?format=text` | |
| 7 | + | //! | |
| 8 | + | //! The archive holds `{n}_{job}.txt`, each job's whole log, and a folder | |
| 9 | + | //! per job with `{step}_{step name}.txt` for each step, as GitHub's does. | |
| 10 | + | //! Who may read the run may download its logs; a token needs the scope | |
| 11 | + | //! `get_job_logs` needs. | |
| 12 | + | ||
| 13 | + | use g1t_contracts::actions::{JobLogText, JobLogTextArgs, RunLogsArgs}; | |
| 14 | + | use g1t_contracts::repos::RepoPath; | |
| 15 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 16 | + | use serde_json::json; | |
| 17 | + | use worker::{Response, Result}; | |
| 18 | + | ||
| 19 | + | use crate::operations::Op; | |
| 20 | + | use crate::operations::Services; | |
| 21 | + | use crate::{audit, fail, failure}; | |
| 22 | + | ||
| 23 | + | /// What a download path asks for. | |
| 24 | + | #[derive(Debug, PartialEq)] | |
| 25 | + | pub enum Wanted<'a> { | |
| 26 | + | Run { owner: &'a str, repo: &'a str, id: &'a str, attempt: Option<u64> }, | |
| 27 | + | Job { owner: &'a str, repo: &'a str, job: &'a str }, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// The download a `GET` path (and its query) asks for, if it is one. | |
| 31 | + | pub fn wanted<'a>(path: &'a str, text: bool) -> Option<Wanted<'a>> { | |
| 32 | + | let parts: Vec<&str> = path.strip_prefix("/repos/")?.trim_end_matches('/').split('/').collect(); | |
| 33 | + | match parts.as_slice() { | |
| 34 | + | [owner, repo, "actions", "runs", id, "logs"] => Some(Wanted::Run { owner, repo, id, attempt: None }), | |
| 35 | + | [owner, repo, "actions", "runs", id, "attempts", attempt, "logs"] => { | |
| 36 | + | Some(Wanted::Run { owner, repo, id, attempt: Some(attempt.parse().ok()?) }) | |
| 37 | + | } | |
| 38 | + | [owner, repo, "actions", "jobs", job, "logs"] if text => Some(Wanted::Job { owner, repo, job }), | |
| 39 | + | _ => None, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// A name safe as a file name in an archive: no slashes or characters | |
| 44 | + | /// Windows refuses, at most 100 characters. | |
| 45 | + | pub fn file_name(name: &str) -> String { | |
| 46 | + | let cleaned: String = name | |
| 47 | + | .chars() | |
| 48 | + | .map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control() { '_' } else { c }) | |
| 49 | + | .take(100) | |
| 50 | + | .collect(); | |
| 51 | + | let trimmed = cleaned.trim().trim_matches('.'); | |
| 52 | + | if trimmed.is_empty() { "job".to_owned() } else { trimmed.to_owned() } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// A job's log as one text, in order. | |
| 56 | + | pub fn job_text(job: &JobLogText) -> String { | |
| 57 | + | if job.omitted { | |
| 58 | + | return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n".to_owned(); | |
| 59 | + | } | |
| 60 | + | job.chunks.iter().map(|chunk| chunk.text.as_str()).collect() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The files of a run's log archive, in order. | |
| 64 | + | pub fn archive_files(jobs: &[JobLogText]) -> Vec<(String, String)> { | |
| 65 | + | let mut files = Vec::new(); | |
| 66 | + | for (index, job) in jobs.iter().enumerate() { | |
| 67 | + | let name = file_name(&job.name); | |
| 68 | + | files.push((format!("{}_{name}.txt", index + 1), job_text(job))); | |
| 69 | + | if job.omitted { | |
| 70 | + | continue; | |
| 71 | + | } | |
| 72 | + | let mut steps: Vec<u32> = job.chunks.iter().map(|chunk| chunk.step).collect(); | |
| 73 | + | steps.sort_unstable(); | |
| 74 | + | steps.dedup(); | |
| 75 | + | for step in steps { | |
| 76 | + | let title = if step == 0 { | |
| 77 | + | "Set up job".to_owned() | |
| 78 | + | } else { | |
| 79 | + | job.steps.iter().find(|s| s.number == step).map_or_else(|| format!("Step {step}"), |s| s.name.clone()) | |
| 80 | + | }; | |
| 81 | + | let text: String = job.chunks.iter().filter(|chunk| chunk.step == step).map(|chunk| chunk.text.as_str()).collect(); | |
| 82 | + | files.push((format!("{name}/{}_{}.txt", step, file_name(&title)), text)); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | files | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | const CRC_POLY: u32 = 0xedb8_8320; | |
| 89 | + | ||
| 90 | + | fn crc32(data: &[u8]) -> u32 { | |
| 91 | + | let mut crc = 0xffff_ffffu32; | |
| 92 | + | for byte in data { | |
| 93 | + | crc ^= u32::from(*byte); | |
| 94 | + | for _ in 0..8 { | |
| 95 | + | crc = if crc & 1 == 1 { (crc >> 1) ^ CRC_POLY } else { crc >> 1 }; | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | !crc | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// A zip archive of `files`, stored (not compressed), with UTF-8 names. | |
| 102 | + | /// Logs are small next to the 4 GB zip64 would be needed for. | |
| 103 | + | pub fn zip(files: &[(String, String)]) -> Vec<u8> { | |
| 104 | + | let mut out: Vec<u8> = Vec::new(); | |
| 105 | + | let mut central: Vec<u8> = Vec::new(); | |
| 106 | + | for (name, text) in files { | |
| 107 | + | let data = text.as_bytes(); | |
| 108 | + | let crc = crc32(data); | |
| 109 | + | let offset = out.len() as u32; | |
| 110 | + | let size = data.len() as u32; | |
| 111 | + | let name = name.as_bytes(); | |
| 112 | + | // Local file header: version 2.0, UTF-8 names (bit 11), stored. | |
| 113 | + | out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); | |
| 114 | + | out.extend_from_slice(&20u16.to_le_bytes()); | |
| 115 | + | out.extend_from_slice(&0x0800u16.to_le_bytes()); | |
| 116 | + | out.extend_from_slice(&0u16.to_le_bytes()); | |
| 117 | + | out.extend_from_slice(&0u16.to_le_bytes()); | |
| 118 | + | out.extend_from_slice(&0x21u16.to_le_bytes()); // 1980-01-01 | |
| 119 | + | out.extend_from_slice(&crc.to_le_bytes()); | |
| 120 | + | out.extend_from_slice(&size.to_le_bytes()); | |
| 121 | + | out.extend_from_slice(&size.to_le_bytes()); | |
| 122 | + | out.extend_from_slice(&(name.len() as u16).to_le_bytes()); | |
| 123 | + | out.extend_from_slice(&0u16.to_le_bytes()); | |
| 124 | + | out.extend_from_slice(name); | |
| 125 | + | out.extend_from_slice(data); | |
| 126 | + | // Its central directory entry. | |
| 127 | + | central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); | |
| 128 | + | central.extend_from_slice(&20u16.to_le_bytes()); | |
| 129 | + | central.extend_from_slice(&20u16.to_le_bytes()); | |
| 130 | + | central.extend_from_slice(&0x0800u16.to_le_bytes()); | |
| 131 | + | central.extend_from_slice(&0u16.to_le_bytes()); | |
| 132 | + | central.extend_from_slice(&0u16.to_le_bytes()); | |
| 133 | + | central.extend_from_slice(&0x21u16.to_le_bytes()); | |
| 134 | + | central.extend_from_slice(&crc.to_le_bytes()); | |
| 135 | + | central.extend_from_slice(&size.to_le_bytes()); | |
| 136 | + | central.extend_from_slice(&size.to_le_bytes()); | |
| 137 | + | central.extend_from_slice(&(name.len() as u16).to_le_bytes()); | |
| 138 | + | central.extend_from_slice(&[0u8; 12]); | |
| 139 | + | central.extend_from_slice(&offset.to_le_bytes()); | |
| 140 | + | central.extend_from_slice(name); | |
| 141 | + | } | |
| 142 | + | let start = out.len() as u32; | |
| 143 | + | let count = files.len() as u16; | |
| 144 | + | out.extend_from_slice(¢ral); | |
| 145 | + | out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); | |
| 146 | + | out.extend_from_slice(&[0u8; 4]); | |
| 147 | + | out.extend_from_slice(&count.to_le_bytes()); | |
| 148 | + | out.extend_from_slice(&count.to_le_bytes()); | |
| 149 | + | out.extend_from_slice(&(central.len() as u32).to_le_bytes()); | |
| 150 | + | out.extend_from_slice(&start.to_le_bytes()); | |
| 151 | + | out.extend_from_slice(&0u16.to_le_bytes()); | |
| 152 | + | out | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn attachment(bytes: Vec<u8>, content_type: &str, file: &str) -> Result<Response> { | |
| 156 | + | let mut response = Response::from_bytes(bytes)?; | |
| 157 | + | let headers = response.headers_mut(); | |
| 158 | + | headers.set("content-type", content_type)?; | |
| 159 | + | headers.set("content-disposition", &format!("attachment; filename=\"{}\"", file.replace('"', "")))?; | |
| 160 | + | headers.set("cache-control", "no-store")?; | |
| 161 | + | Ok(response) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Answers a download `wanted` names, for `viewer`. | |
| 165 | + | pub async fn download(services: &Services, viewer: &Viewer, wanted: Wanted<'_>) -> Result<Response> { | |
| 166 | + | let (owner, repo) = match &wanted { | |
| 167 | + | Wanted::Run { owner, repo, .. } | Wanted::Job { owner, repo, .. } => (*owner, *repo), | |
| 168 | + | }; | |
| 169 | + | // A workflow job's token reaches its own repository only, and any | |
| 170 | + | // token needs what reading a job's log needs. | |
| 171 | + | if viewer.as_ref().and_then(|user| user.token.as_deref()).is_some_and(|token| !token.reaches(&format!("{owner}/{repo}"))) { | |
| 172 | + | return fail(FailureCode::NotFound, "There is no such repository."); | |
| 173 | + | } | |
| 174 | + | let input = json!({ "repo": format!("{owner}/{repo}") }); | |
| 175 | + | if let Some(scope) = audit::missing_scope(Op::GetJobLogs, viewer, &input) { | |
| 176 | + | return Ok(Response::from_json(&json!({ | |
| 177 | + | "error": { "code": FailureCode::Forbidden, "message": "This token cannot read workflow logs.", "needed_scope": scope.as_str() } | |
| 178 | + | }))? | |
| 179 | + | .with_status(403)); | |
| 180 | + | } | |
| 181 | + | let path = RepoPath { namespace: owner.to_owned(), name: repo.to_owned() }; | |
| 182 | + | match wanted { | |
| 183 | + | Wanted::Run { id, attempt, .. } => { | |
| 184 | + | let logs: Outcome<Vec<JobLogText>> = | |
| 185 | + | g1t_kit::call(&services.actions, "run_logs", &RunLogsArgs { repo: path, viewer: viewer.clone(), id: id.to_owned(), attempt }).await?; | |
| 186 | + | match logs { | |
| 187 | + | Outcome::Ok(jobs) => { | |
| 188 | + | let file = match attempt { | |
| 189 | + | Some(n) => format!("logs_{id}_attempt_{n}.zip"), | |
| 190 | + | None => format!("logs_{id}.zip"), | |
| 191 | + | }; | |
| 192 | + | attachment(zip(&archive_files(&jobs)), "application/zip", &file) | |
| 193 | + | } | |
| 194 | + | Outcome::Fail(refused) => failure(&refused), | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | Wanted::Job { job, .. } => { | |
| 198 | + | let log: Outcome<JobLogText> = | |
| 199 | + | g1t_kit::call(&services.actions, "job_log_text", &JobLogTextArgs { repo: path, viewer: viewer.clone(), job: job.to_owned() }).await?; | |
| 200 | + | match log { | |
| 201 | + | Outcome::Ok(log) => attachment(job_text(&log).into_bytes(), "text/plain; charset=utf-8", &format!("{}.txt", file_name(&log.name))), | |
| 202 | + | Outcome::Fail(refused) => failure(&refused), | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[cfg(test)] | |
| 209 | + | mod tests { | |
| 210 | + | use g1t_contracts::actions::{JobLogText, LogChunk, StepState}; | |
| 211 | + | ||
| 212 | + | use super::*; | |
| 213 | + | ||
| 214 | + | fn job(name: &str, chunks: &[(u32, &str)]) -> JobLogText { | |
| 215 | + | JobLogText { | |
| 216 | + | job_id: "job_1".into(), | |
| 217 | + | name: name.into(), | |
| 218 | + | steps: vec![StepState { number: 1, name: "Run cargo test".into(), ..StepState::default() }], | |
| 219 | + | chunks: chunks.iter().enumerate().map(|(i, (step, text))| LogChunk { seq: i as u64 + 1, step: *step, text: (*text).into() }).collect(), | |
| 220 | + | done: true, | |
| 221 | + | omitted: false, | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn download_paths_are_githubs() { | |
| 227 | + | assert_eq!( | |
| 228 | + | wanted("/repos/acme/web/actions/runs/run_1/logs", false), | |
| 229 | + | Some(Wanted::Run { owner: "acme", repo: "web", id: "run_1", attempt: None }) | |
| 230 | + | ); | |
| 231 | + | assert_eq!( | |
| 232 | + | wanted("/repos/acme/web/actions/runs/run_1/attempts/2/logs", false), | |
| 233 | + | Some(Wanted::Run { owner: "acme", repo: "web", id: "run_1", attempt: Some(2) }) | |
| 234 | + | ); | |
| 235 | + | assert_eq!(wanted("/repos/acme/web/actions/runs/run_1/attempts/x/logs", false), None); | |
| 236 | + | // A job's log is text when asked for; otherwise the JSON operation answers. | |
| 237 | + | assert_eq!(wanted("/repos/acme/web/actions/jobs/job_1/logs", false), None); | |
| 238 | + | assert_eq!(wanted("/repos/acme/web/actions/jobs/job_1/logs", true), Some(Wanted::Job { owner: "acme", repo: "web", job: "job_1" })); | |
| 239 | + | assert_eq!(wanted("/repos/acme/web/actions/runs/run_1", false), None); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn names_are_safe_in_an_archive() { | |
| 244 | + | assert_eq!(file_name("test (ubuntu-latest, 20)"), "test (ubuntu-latest, 20)"); | |
| 245 | + | assert_eq!(file_name("build / a:b"), "build _ a_b"); | |
| 246 | + | assert_eq!(file_name(".."), "job"); | |
| 247 | + | assert_eq!(file_name(&"x".repeat(300)).len(), 100); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn an_archive_has_each_job_whole_and_by_step() { | |
| 252 | + | let jobs = [job("test", &[(0, "set up\n"), (1, "running\n"), (1, "ok\n")]), JobLogText { omitted: true, ..job("deploy/x", &[]) }]; | |
| 253 | + | let files = archive_files(&jobs); | |
| 254 | + | let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); | |
| 255 | + | assert_eq!(names, ["1_test.txt", "test/0_Set up job.txt", "test/1_Run cargo test.txt", "2_deploy_x.txt"]); | |
| 256 | + | assert_eq!(files[0].1, "set up\nrunning\nok\n"); | |
| 257 | + | assert_eq!(files[2].1, "running\nok\n"); | |
| 258 | + | assert!(files[3].1.contains("left out")); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn the_archive_is_a_zip() { | |
| 263 | + | let bytes = zip(&[("a.txt".into(), "hello".into()), ("dir/b.txt".into(), String::new())]); | |
| 264 | + | assert_eq!(&bytes[..4], &[0x50, 0x4b, 0x03, 0x04]); | |
| 265 | + | // The end of the central directory names both files. | |
| 266 | + | let end = bytes.len() - 22; | |
| 267 | + | assert_eq!(&bytes[end..end + 4], &[0x50, 0x4b, 0x05, 0x06]); | |
| 268 | + | assert_eq!(u16::from_le_bytes([bytes[end + 10], bytes[end + 11]]), 2); | |
| 269 | + | assert_eq!(crc32(b"hello"), 0x3610_a686); | |
| 270 | + | assert_eq!(crc32(b""), 0); | |
| 271 | + | } | |
| 272 | + | } |
| 808 | 808 | ("PUT", "enable") => "enable_workflow".to_owned(), | |
| 809 | 809 | ("PUT", "disable") => "disable_workflow".to_owned(), | |
| 810 | 810 | ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(), | |
| 811 | + | ("POST", "rerun") if route.path.contains("/jobs/:job/") => "rerun_job".to_owned(), | |
| 812 | + | ("POST", "force-cancel") => "force_cancel_workflow_run".to_owned(), | |
| 813 | + | ("GET", ":attempt") => "get_workflow_run_attempt".to_owned(), | |
| 811 | 814 | ("PATCH", ":setting") => "update_actions_variable".to_owned(), | |
| 812 | 815 | ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(), | |
| 813 | 816 | // One repository's notifications, and an issue's subscription by | |
| ⋯ | |||
| 841 | 844 | "enable_workflow" => "Turn a workflow on", | |
| 842 | 845 | "disable_workflow" => "Turn a workflow off", | |
| 843 | 846 | "rerun_failed_jobs" => "Re-run failed jobs", | |
| 847 | + | "rerun_job" => "Re-run a job", | |
| 848 | + | "force_cancel_workflow_run" => "Force-cancel a workflow run", | |
| 849 | + | "get_workflow_run_attempt" => "Get a workflow run attempt", | |
| 844 | 850 | "update_actions_variable" => "Update a variable", | |
| 845 | 851 | "list_runs_of_workflow" => "List a workflow's runs", | |
| 846 | 852 | "list_repo_notifications" => "List a repository's notifications", | |
| 1501 | 1501 | "A repository's workflow runs, newest first: of one workflow (its id or file name), a branch, an event, a pull request's number, or a commit." | |
| 1502 | 1502 | } | |
| 1503 | 1503 | Op::GetWorkflowRun => { | |
| 1504 | − | "One workflow run with its jobs: each job's steps and how they went, its annotations (::error:: and the like), and why it stopped. Read a job's log with get_job_logs." | |
| 1504 | + | "One workflow run with its jobs: each job's steps and how they went, its annotations (::error:: and the like), and why it stopped. Read a job's log with get_job_logs. `attempts` lists every attempt (each re-run is one) with who started it and how it ended; give `attempt` to read an earlier one, whose jobs keep their own ids and logs." | |
| 1505 | 1505 | } | |
| 1506 | 1506 | Op::GetJobLogs => { | |
| 1507 | 1507 | "A job's log, in order, after `after` (a sequence number from an earlier call). `done` says whether more will come. Lines starting ##[group], ##[endgroup], ##[error] and ##[warning] mark groups and messages." | |
| ⋯ | |||
| 1509 | 1509 | Op::DispatchWorkflow => { | |
| 1510 | 1510 | "Run a workflow that has `on: workflow_dispatch`, on a branch or tag (the default branch if none), with its inputs. Needs the Write role or higher." | |
| 1511 | 1511 | } | |
| 1512 | − | Op::CancelWorkflowRun => "Cancel a run that is still going: its waiting jobs are cancelled and its running ones stopped. Needs the Write role or higher.", | |
| 1512 | + | Op::CancelWorkflowRun => { | |
| 1513 | + | "Cancel a run that is still going: its waiting jobs are cancelled at once, and its running ones stop the step they are on, run their `if: always()` and `cancelled()` steps and post steps, and end cancelled (stopped outright after 5 minutes). Cancelling a run that is already cancelling, or `force`, stops its jobs outright. Needs the Write role or higher." | |
| 1514 | + | } | |
| 1513 | 1515 | Op::RerunWorkflowRun => { | |
| 1514 | − | "Run a finished workflow run again: every job, or with failed_only the jobs that did not succeed and the jobs that need them. Needs the Write role or higher." | |
| 1516 | + | "Run a finished workflow run again, as a new attempt: every job, with failed_only the jobs that did not succeed, or with `job` one job (by its id in the latest attempt); each with the jobs that need them. `debug` (or GitHub's `enable_debug_logging`) runs the attempt with debug logging. The attempt before is kept, with its jobs' logs. Needs the Write role or higher." | |
| 1515 | 1517 | } | |
| 1516 | 1518 | Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.", | |
| 1517 | 1519 | Op::ListActionsSecrets => { | |
| ⋯ | |||
| 2577 | 2579 | &["repo"], | |
| 2578 | 2580 | ), | |
| 2579 | 2581 | Op::GetWorkflowRun => object( | |
| 2580 | − | json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }), | |
| 2582 | + | json!({ | |
| 2583 | + | "repo": repo_schema(), | |
| 2584 | + | "id": { "type": "string", "description": "The run's id." }, | |
| 2585 | + | "attempt": { "type": "integer", "description": "An earlier attempt, from 1. The latest if not given." }, | |
| 2586 | + | }), | |
| 2581 | 2587 | &["repo", "id"], | |
| 2582 | 2588 | ), | |
| 2583 | 2589 | Op::GetJobLogs => object( | |
| ⋯ | |||
| 2598 | 2604 | &["repo", "workflow"], | |
| 2599 | 2605 | ), | |
| 2600 | 2606 | Op::CancelWorkflowRun => object( | |
| 2601 | − | json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }), | |
| 2607 | + | json!({ | |
| 2608 | + | "repo": repo_schema(), | |
| 2609 | + | "id": { "type": "string", "description": "The run's id." }, | |
| 2610 | + | "force": { "type": "boolean", "description": "Stop running jobs outright, without their cleanup steps." }, | |
| 2611 | + | }), | |
| 2602 | 2612 | &["repo", "id"], | |
| 2603 | 2613 | ), | |
| 2604 | 2614 | Op::RerunWorkflowRun => object( | |
| 2605 | 2615 | json!({ | |
| 2606 | 2616 | "repo": repo_schema(), | |
| 2607 | − | "id": { "type": "string", "description": "The run's id." }, | |
| 2617 | + | "id": { "type": "string", "description": "The run's id. Not needed with `job`." }, | |
| 2608 | 2618 | "failed_only": { "type": "boolean", "description": "Only the jobs that did not succeed, and those that need them." }, | |
| 2619 | + | "job": { "type": "string", "description": "One job to run again, by its id in the latest attempt, with the jobs that need it." }, | |
| 2620 | + | "debug": { "type": "boolean", "description": "Run the new attempt with debug logging: RUNNER_DEBUG=1, and ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG set to true." }, | |
| 2621 | + | "enable_debug_logging": { "type": "boolean", "description": "The same as `debug`, by GitHub's name for it." }, | |
| 2609 | 2622 | }), | |
| 2610 | − | &["repo", "id"], | |
| 2623 | + | &["repo"], | |
| 2611 | 2624 | ), | |
| 2612 | 2625 | Op::UpdateWorkflow => object( | |
| 2613 | 2626 | json!({ | |
| ⋯ | |||
| 4716 | 4729 | ) | |
| 4717 | 4730 | .await | |
| 4718 | 4731 | } | |
| 4719 | − | Op::GetWorkflowRun => pass(actions, "run", &json!({ "repo": repo, "viewer": viewer, "id": text(input, "id") })).await, | |
| 4732 | + | Op::GetWorkflowRun => { | |
| 4733 | + | pass(actions, "run", &json!({ "repo": repo, "viewer": viewer, "id": text(input, "id"), "attempt": integer(input, "attempt") })).await | |
| 4734 | + | } | |
| 4720 | 4735 | Op::GetJobLogs => { | |
| 4721 | 4736 | pass( | |
| 4722 | 4737 | actions, | |
| ⋯ | |||
| 4748 | 4763 | "repo": repo, | |
| 4749 | 4764 | "id": text(input, "id"), | |
| 4750 | 4765 | "failed_only": input["failed_only"].as_bool() == Some(true), | |
| 4766 | + | "job": optional_text(input, "job"), | |
| 4767 | + | "debug": input["debug"].as_bool() == Some(true) || input["enable_debug_logging"].as_bool() == Some(true), | |
| 4768 | + | "force": input["force"].as_bool() == Some(true), | |
| 4751 | 4769 | }), | |
| 4752 | 4770 | ) | |
| 4753 | 4771 | .await | |
| 2744 | 2744 | "job": "test", | |
| 2745 | 2745 | "message": "`services` containers (such as a database) are not started on g1t yet." | |
| 2746 | 2746 | } | |
| 2747 | + | ], | |
| 2748 | + | "attempts": [ | |
| 2749 | + | { | |
| 2750 | + | "attempt": 1, | |
| 2751 | + | "status": "completed", | |
| 2752 | + | "conclusion": "failure", | |
| 2753 | + | "actor": "syntaqx", | |
| 2754 | + | "debug": false, | |
| 2755 | + | "started_at": "2026-10-04T15:42:09.020Z", | |
| 2756 | + | "finished_at": "2026-10-04T15:44:31.877Z" | |
| 2757 | + | } | |
| 2747 | 2758 | ] | |
| 2748 | − | } | |
| 2759 | + | }, | |
| 2760 | + | "notes": "Each re-run is a new attempt. Read an earlier one with `attempt`, or at `GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}`; its jobs have ids of their own, so `get_job_logs` reads the log each had then. A job that is `cancelling` was cancelled and is running its cleanup steps." | |
| 2749 | 2761 | }, | |
| 2750 | 2762 | "get_job_logs": { | |
| 2751 | 2763 | "params": { | |
| ⋯ | |||
| 2881 | 2893 | "finished_at": null | |
| 2882 | 2894 | } | |
| 2883 | 2895 | }, | |
| 2896 | + | "rerun_job": { | |
| 2897 | + | "params": { | |
| 2898 | + | "job": "job_01kpx7b3d0e4f8g2h6j0k4m8ns" | |
| 2899 | + | }, | |
| 2900 | + | "request": { | |
| 2901 | + | "enable_debug_logging": true | |
| 2902 | + | }, | |
| 2903 | + | "response": { | |
| 2904 | + | "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr", | |
| 2905 | + | "workflow_id": "wfl_01kpw2c5d8e1f4g7h0j3k6m9np", | |
| 2906 | + | "path": ".g1t/workflows/ci.yml", | |
| 2907 | + | "name": "CI", | |
| 2908 | + | "title": "Greeting should name the caller", | |
| 2909 | + | "number": 12, | |
| 2910 | + | "attempt": 2, | |
| 2911 | + | "event": "push", | |
| 2912 | + | "ref": "refs/heads/main", | |
| 2913 | + | "sha": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2914 | + | "pull": null, | |
| 2915 | + | "status": "queued", | |
| 2916 | + | "conclusion": null, | |
| 2917 | + | "error": null, | |
| 2918 | + | "actor": "syntaqx", | |
| 2919 | + | "created_at": "2026-10-04T15:42:07.318Z", | |
| 2920 | + | "started_at": null, | |
| 2921 | + | "finished_at": null | |
| 2922 | + | }, | |
| 2923 | + | "notes": "Runs the job again with every job that needs it, as a new attempt; the rest keep how they ended. A job of a matrix runs again with the rest of its matrix, and a job of a called workflow with the job that calls it. The run must have finished." | |
| 2924 | + | }, | |
| 2925 | + | "force_cancel_workflow_run": { | |
| 2926 | + | "params": { | |
| 2927 | + | "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr" | |
| 2928 | + | }, | |
| 2929 | + | "response": { | |
| 2930 | + | "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr", | |
| 2931 | + | "workflow_id": "wfl_01kpw2c5d8e1f4g7h0j3k6m9np", | |
| 2932 | + | "path": ".g1t/workflows/ci.yml", | |
| 2933 | + | "name": "CI", | |
| 2934 | + | "title": "Greeting should name the caller", | |
| 2935 | + | "number": 12, | |
| 2936 | + | "attempt": 1, | |
| 2937 | + | "event": "push", | |
| 2938 | + | "ref": "refs/heads/main", | |
| 2939 | + | "sha": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", | |
| 2940 | + | "pull": null, | |
| 2941 | + | "status": "completed", | |
| 2942 | + | "conclusion": "cancelled", | |
| 2943 | + | "error": null, | |
| 2944 | + | "actor": "syntaqx", | |
| 2945 | + | "created_at": "2026-10-04T15:42:07.318Z", | |
| 2946 | + | "started_at": "2026-10-04T15:42:09.020Z", | |
| 2947 | + | "finished_at": "2026-10-04T15:43:02.411Z" | |
| 2948 | + | }, | |
| 2949 | + | "notes": "Stops running jobs at once, without their `if: always()`, `cancelled()` or post steps. Use it when a job's cleanup hangs." | |
| 2950 | + | }, | |
| 2884 | 2951 | "update_workflow": { | |
| 2885 | 2952 | "params": { | |
| 2886 | 2953 | "workflow": "nightly.yml" | |
| 680 | 680 | &[], | |
| 681 | 681 | ), | |
| 682 | 682 | route( | |
| 683 | + | "GET", | |
| 684 | + | "/repos/:owner/:name/actions/runs/:id/attempts/:attempt", | |
| 685 | + | Op::GetWorkflowRun, | |
| 686 | + | &[], | |
| 687 | + | ), | |
| 688 | + | route( | |
| 683 | 689 | "POST", | |
| 684 | 690 | "/repos/:owner/:name/actions/runs/:id/cancel", | |
| 685 | 691 | Op::CancelWorkflowRun, | |
| ⋯ | |||
| 687 | 693 | ), | |
| 688 | 694 | route( | |
| 689 | 695 | "POST", | |
| 696 | + | "/repos/:owner/:name/actions/runs/:id/force-cancel", | |
| 697 | + | Op::CancelWorkflowRun, | |
| 698 | + | &[], | |
| 699 | + | ), | |
| 700 | + | route( | |
| 701 | + | "POST", | |
| 690 | 702 | "/repos/:owner/:name/actions/runs/:id/rerun", | |
| 691 | 703 | Op::RerunWorkflowRun, | |
| 692 | 704 | &[], | |
| ⋯ | |||
| 698 | 710 | &[], | |
| 699 | 711 | ), | |
| 700 | 712 | route( | |
| 713 | + | "POST", | |
| 714 | + | "/repos/:owner/:name/actions/jobs/:job/rerun", | |
| 715 | + | Op::RerunWorkflowRun, | |
| 716 | + | &[], | |
| 717 | + | ), | |
| 718 | + | route( | |
| 701 | 719 | "GET", | |
| 702 | 720 | "/repos/:owner/:name/actions/jobs/:job/logs", | |
| 703 | 721 | Op::GetJobLogs, | |
| ⋯ | |||
| 1054 | 1072 | if route.path.ends_with("/rerun-failed-jobs") { | |
| 1055 | 1073 | input.insert("failed_only".to_owned(), Value::Bool(true)); | |
| 1056 | 1074 | } | |
| 1075 | + | if route.path.ends_with("/force-cancel") { | |
| 1076 | + | input.insert("force".to_owned(), Value::Bool(true)); | |
| 1077 | + | } | |
| 1057 | 1078 | // Unsaving and waking a thread are a DELETE of what PUT made. | |
| 1058 | 1079 | if route.method == "DELETE" && route.path.ends_with("/saved") { | |
| 1059 | 1080 | input.insert("saved".to_owned(), Value::Bool(false)); | |
| 214 | 214 | actions: &[ | |
| 215 | 215 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| 216 | 216 | a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"), | |
| 217 | − | a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps"), | |
| 217 | + | a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps; an earlier attempt with attempt"), | |
| 218 | 218 | a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"), | |
| 219 | 219 | a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"), | |
| 220 | − | a("cancel", Op::CancelWorkflowRun, "Cancel a run"), | |
| 221 | − | a("rerun", Op::RerunWorkflowRun, "Run a finished run again"), | |
| 220 | + | a("cancel", Op::CancelWorkflowRun, "Cancel a run, letting its jobs clean up; force stops them outright"), | |
| 221 | + | a("rerun", Op::RerunWorkflowRun, "Run a finished run again: all, failed_only, or one job; debug for debug logging"), | |
| 222 | 222 | a("update", Op::UpdateWorkflow, "Turn a workflow on or off"), | |
| 223 | 223 | a("list_artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), "A repository's artifacts, newest first; or a run's with run_artifacts"), | |
| 224 | 224 | a("run_artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), "One run's artifacts"), |
| 41 | 41 | | Composite actions | The same. | | |
| 42 | 42 | | Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, and nesting up to four deep. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). | | |
| 43 | 43 | | `actions/checkout` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. | | |
| 44 | − | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 44 | + | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. Step summaries show on the run's page; see [job summaries](#job-summaries). | | |
| 45 | 45 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run, and [masked](#masking-secrets) values stay hidden. | | |
| 46 | 46 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is [the job's own token](#the-jobs-token); `GITHUB_TOKEN` is its alias. | | |
| 47 | 47 | | `environment:` on a job | The job waits for the environment's [protection rules](#environments), then reads each key's row for that environment, as 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. The name may be an expression. | | |
| ⋯ | |||
| 729 | 729 | ||
| 730 | 730 | A run's page shows its jobs, each job's steps, and their logs as they are | |
| 731 | 731 | written. Groups fold, errors and warnings are marked, and secrets are | |
| 732 | − | replaced with `***`. **Cancel**, **Re-run all jobs** and **Re-run failed | |
| 733 | − | jobs** do what they say. | |
| 732 | + | replaced with `***`. | |
| 734 | 733 | ||
| 735 | 734 | The start of each job's log lists what its [token](#the-jobs-token) may do. | |
| 736 | 735 | ||
| 736 | + | ### Job summaries | |
| 737 | + | ||
| 738 | + | Markdown a step appends to the file in `$GITHUB_STEP_SUMMARY` shows at | |
| 739 | + | the top of the run's page, a card per job, in the order its steps wrote | |
| 740 | + | it: | |
| 741 | + | ||
| 742 | + | ```yaml | |
| 743 | + | - name: Report the tests | |
| 744 | + | if: always() | |
| 745 | + | run: | | |
| 746 | + | echo "### Test results" >> "$GITHUB_STEP_SUMMARY" | |
| 747 | + | echo "| Suite | Passed | Failed |" >> "$GITHUB_STEP_SUMMARY" | |
| 748 | + | echo "| --- | ---: | ---: |" >> "$GITHUB_STEP_SUMMARY" | |
| 749 | + | echo "| unit | 41 | 0 |" >> "$GITHUB_STEP_SUMMARY" | |
| 750 | + | ``` | |
| 751 | + | ||
| 752 | + | | What | How it works | | |
| 753 | + | | --- | --- | | |
| 754 | + | | Formatting | GitHub-flavoured Markdown: tables, task lists, alerts such as `> [!WARNING]`, code blocks, and the HTML GitHub allows. Scripts, styles and event handlers are removed. | | |
| 755 | + | | Secrets | Masked like the log, before the summary leaves the runner. | | |
| 756 | + | | Size | Up to 1 MiB a step. A larger summary is refused with an error in the step's log, as on GitHub. | | |
| 757 | + | | Steps | Up to 20 steps of a job keep a summary; later ones are dropped. | | |
| 758 | + | | Actions | A JavaScript action's `core.summary` writes to the same file, so it works unchanged. | | |
| 759 | + | ||
| 760 | + | Summaries belong to their attempt: an earlier attempt keeps its own. | |
| 761 | + | ||
| 762 | + | ### Re-running | |
| 763 | + | ||
| 764 | + | When a run has finished, someone with the Write role can run it again: | |
| 765 | + | ||
| 766 | + | | Button | Runs again | | |
| 767 | + | | --- | --- | | |
| 768 | + | | **Re-run all jobs** | Every job. | | |
| 769 | + | | **Re-run failed jobs** | Jobs that did not succeed (failed, cancelled or skipped), and every job that needs one of them. | | |
| 770 | + | | The re-run button beside a job's name | That job, and every job that needs it. A job of a matrix runs again with the rest of its matrix; a job of a reusable workflow runs again with the job that calls it. | | |
| 771 | + | ||
| 772 | + | Jobs that are not run again keep how they ended, and their outputs reach | |
| 773 | + | the jobs that need them. | |
| 774 | + | ||
| 775 | + | Each re-run is a new **attempt**. The run keeps its number, `github.run_attempt` | |
| 776 | + | goes up by one, and the attempt before is kept as it ended: its jobs, | |
| 777 | + | their steps, logs and summaries. Pick one from **Attempt #** at the top of | |
| 778 | + | the page to read it. Its jobs have ids of their own, so a link to an | |
| 779 | + | earlier attempt's job keeps showing that job's log. | |
| 780 | + | ||
| 781 | + | #### Debug logging | |
| 782 | + | ||
| 783 | + | Each re-run asks whether to **Enable debug logging**. The new attempt's | |
| 784 | + | jobs then run with: | |
| 785 | + | ||
| 786 | + | | Set | Effect | | |
| 787 | + | | --- | --- | | |
| 788 | + | | `RUNNER_DEBUG=1`, and `runner.debug` is `1` | Actions that check it, such as the toolkit's `core.isDebug()`, log more. | | |
| 789 | + | | `ACTIONS_STEP_DEBUG=true` | `::debug::` lines are shown in the log. | | |
| 790 | + | | `ACTIONS_RUNNER_DEBUG=true` | Set for actions that read it. | | |
| 791 | + | ||
| 792 | + | With debug logging, each step's log also says how its `if:` read: | |
| 793 | + | `Evaluating condition for step`, the expression, and the result. Setting a | |
| 794 | + | secret or variable named `ACTIONS_STEP_DEBUG` to `true` shows `::debug::` | |
| 795 | + | lines on every run instead. The attempt picker marks attempts that ran | |
| 796 | + | with debug logging. | |
| 797 | + | ||
| 798 | + | ### Cancelling | |
| 799 | + | ||
| 800 | + | **Cancel run** cancels jobs that have not started at once. A job that is | |
| 801 | + | running is stopped the way GitHub stops one: | |
| 802 | + | ||
| 803 | + | 1. The step it is on gets `SIGINT`, then `SIGTERM` 7.5 seconds later, and | |
| 804 | + | is killed 2.5 seconds after that. Signals reach the processes the step | |
| 805 | + | started too; in a [job container](#job-containers) they reach only the | |
| 806 | + | `docker exec` that runs the step. The step ends **cancelled**. | |
| 807 | + | 2. Its remaining steps run only if they ask to: `if: always()` or | |
| 808 | + | `if: cancelled()`. Steps without an `if:`, or with `success()` or | |
| 809 | + | `failure()`, are skipped. | |
| 810 | + | 3. Post steps (an action's `post`, saving the cache) run, as their | |
| 811 | + | `post-if` is `always()` unless the action says otherwise. | |
| 812 | + | 4. The job ends **cancelled**, whatever those steps came to. | |
| 813 | + | ||
| 814 | + | While that happens the run says **Cancelling**. A job still going 5 | |
| 815 | + | minutes after it was cancelled is stopped outright. **Force cancel** | |
| 816 | + | (shown while a run is cancelling) stops every job at once, without | |
| 817 | + | waiting for its cleanup steps. | |
| 818 | + | ||
| 819 | + | A step learns of a cancellation within about 10 seconds, even when it | |
| 820 | + | prints nothing. A job on a [self-hosted runner](/guides/self-hosted-runners/) | |
| 821 | + | is stopped the same way. | |
| 822 | + | ||
| 823 | + | ### Searching and downloading logs | |
| 824 | + | ||
| 825 | + | The **Search logs** box above a job's steps shows only the lines that hold | |
| 826 | + | what you type, in any case, with each match marked and every step that has | |
| 827 | + | one opened. Lines inside folded groups are searched too. | |
| 828 | + | ||
| 829 | + | | Download | Where | | |
| 830 | + | | --- | --- | | |
| 831 | + | | One job's whole log, as text | The download button beside the job's name. | | |
| 832 | + | | Every job's log of an attempt, as a zip | **Download logs** at the top of the run. The zip holds `1_<job>.txt` with each job's whole log, and a `<job>/` folder with `<step>_<step name>.txt` for each step. | | |
| 833 | + | ||
| 834 | + | A zip holds up to 24 MiB of logs; jobs past that are listed with a note | |
| 835 | + | to download them on their own. Each job keeps up to 4 MB of log. | |
| 836 | + | ||
| 837 | + | ### Status badges | |
| 838 | + | ||
| 839 | + | A badge shows how a workflow's latest finished run went: **passing**, | |
| 840 | + | **failing**, **cancelled**, or **no status** before it has finished one. | |
| 841 | + | ||
| 842 | + | 1. Open the repository's **Actions** page and pick the workflow. | |
| 843 | + | 2. Click **Create status badge**. | |
| 844 | + | 3. Choose a branch and an event, if you want them, and copy the Markdown. | |
| 845 | + | ||
| 846 | + | ```markdown | |
| 847 | + | [](https://g1t.sh/acme/web/actions?workflow=ci.yml) | |
| 848 | + | ``` | |
| 849 | + | ||
| 850 | + | The address is `https://g1t.sh/{workspace}/{repo}/actions/workflows/{file}/badge.svg`, | |
| 851 | + | where `{file}` is the workflow's file name in `.g1t/workflows/`. It takes: | |
| 852 | + | ||
| 853 | + | | Parameter | Shows | | |
| 854 | + | | --- | --- | | |
| 855 | + | | `branch` | Runs on that branch. Without it, the default branch's runs, or any branch's when the default branch has none. | | |
| 856 | + | | `event` | Runs started by that event, such as `push` or `pull_request`. | | |
| 857 | + | ||
| 858 | + | A public repository's badge loads for anyone and is cached for a minute. | |
| 859 | + | A private repository's loads only for someone who can see the repository, | |
| 860 | + | so it does not show in a README read anywhere else. | |
| 861 | + | ||
| 737 | 862 | ### Masking secrets | |
| 738 | 863 | ||
| 739 | 864 | Every secret's value is replaced with `***` wherever a job prints it, and | |
| ⋯ | |||
| 1110 | 1235 | | `list` | `GET /repos/{owner}/{repo}/actions/workflows` | | |
| 1111 | 1236 | | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` | | |
| 1112 | 1237 | | `get_run` | `GET /repos/{owner}/{repo}/actions/runs/{id}` | | |
| 1113 | − | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=` | | |
| 1238 | + | | `job_logs` | `GET /repos/{owner}/{repo}/actions/jobs/{job}/logs?after=`, or `?format=text` for the whole log as plain text | | |
| 1239 | + | | `get_run` with `attempt` | `GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}` | | |
| 1240 | + | | No tool: a download | `GET /repos/{owner}/{repo}/actions/runs/{id}/logs`, or `…/attempts/{attempt}/logs`: every job's log as a zip | | |
| 1114 | 1241 | | `dispatch` | `POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` with `ref` and `inputs` | | |
| 1115 | − | | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` | | |
| 1116 | − | | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` | | |
| 1242 | + | | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel`; `…/force-cancel`, or `force`, to stop running jobs without their cleanup steps | | |
| 1243 | + | | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs`; one job and those that need it with `POST /repos/{owner}/{repo}/actions/jobs/{job}/rerun`. Each takes `enable_debug_logging` (or `debug`) | | |
| 1117 | 1244 | | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` | | |
| 1118 | 1245 | | `approve_run` | `POST /repos/{owner}/{repo}/actions/runs/{id}/approve` | | |
| 1119 | 1246 | | `pending_deployments` | `GET /repos/{owner}/{repo}/actions/runs/{id}/pending_deployments` | | |
| 416 | 416 | | --- | --- | --- | --- | | |
| 417 | 417 | | [`list`](/reference/api/actions/list-workflows/) | The workflows, with their events, state, problems, notes on what runs differently, manual-run inputs and last run. | `repo` | `workflows:read` | | |
| 418 | 418 | | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | `repo` | `workflows:read` | | |
| 419 | − | | [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations. | `repo`, `id` | `workflows:read` | | |
| 419 | + | | [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations, and its `attempts`; `attempt` reads an earlier attempt, with the jobs it had then. | `repo`, `id` | `workflows:read` | | |
| 420 | 420 | | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`; `done` says if more will come. | `repo`, `job` | `workflows:read` | | |
| 421 | 421 | | [`dispatch`](/reference/api/actions/dispatch-workflow/) | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | `repo`, `workflow` | `workflows:write` | | |
| 422 | − | | [`cancel`](/reference/api/actions/cancel-workflow-run/) | Cancel a run. Write role. | `repo`, `id` | `workflows:write` | | |
| 423 | − | | [`rerun`](/reference/api/actions/rerun-workflow-run/) | Run it again; `failed_only` for the jobs that did not succeed. Write role. | `repo`, `id` | `workflows:write` | | |
| 422 | + | | [`cancel`](/reference/api/actions/cancel-workflow-run/) | Cancel a run: running jobs stop their step and run their cleanup steps first. `force` (or cancelling again) stops them outright. Write role. | `repo`, `id` | `workflows:write` | | |
| 423 | + | | [`rerun`](/reference/api/actions/rerun-workflow-run/) | Run it again as a new attempt; `failed_only` for the jobs that did not succeed, `job` for one job and those that need it, `debug` for debug logging. Write role. | `repo`, and `id` or `job` | `workflows:write` | | |
| 424 | 424 | | [`update`](/reference/api/actions/update-workflow/) | Turn a workflow on or off. Maintain role. | `repo`, `workflow`, `enabled` | `workflows:write` | | |
| 425 | 425 | | [`list_artifacts`](/reference/api/actions/list-artifacts/) | The repository's artifacts, newest first, with size, digest and expiry; `name`, `page`, `per_page`. | `repo` | `workflows:read` | | |
| 426 | 426 | | [`run_artifacts`](/reference/api/actions/list-workflow-run-artifacts/) | One run's artifacts; `name`. | `repo`, `id` (the run) | `workflows:read` | |
Binary or large file; its contents are not shown.
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Hint } from "./ui/hint"; | |
| 13 | + | import { type Line, blocks, highlight, searchLog } from "../lib/log-lines"; | |
| 13 | 14 | ||
| 14 | 15 | /** | |
| 15 | 16 | * Where a run, job or step stands. `of` says which: a run's `pending` waits | |
| ⋯ | |||
| 87 | 88 | const pull = /^refs\/pull\/(\d+)\//.exec(ref); | |
| 88 | 89 | if (pull) return `#${pull[1]}`; | |
| 89 | 90 | return ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string }; | |
| 93 | − | type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; | |
| 94 | − | ||
| 95 | − | function classify(raw: string): Line | "group-end" | { group: string } { | |
| 96 | − | if (raw.startsWith("##[group]")) return { group: raw.slice(9) }; | |
| 97 | − | if (raw.startsWith("##[endgroup]")) return "group-end"; | |
| 98 | − | for (const kind of ["error", "warning", "notice", "debug"] as const) { | |
| 99 | − | if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) }; | |
| 100 | − | } | |
| 101 | − | if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) }; | |
| 102 | − | return { kind: "text", text: raw }; | |
| 103 | 91 | } | |
| 104 | 92 | ||
| 105 | − | /** Lines into blocks: plain lines, and groups that fold. */ | |
| 106 | − | function blocks(text: string): Block[] { | |
| 107 | − | const out: Block[] = []; | |
| 108 | − | let group: Extract<Block, { kind: "group" }> | null = null; | |
| 109 | − | let number = 0; | |
| 110 | − | for (const raw of text.split("\n")) { | |
| 111 | − | if (raw === "" && number === 0) continue; | |
| 112 | − | const line = classify(raw); | |
| 113 | − | if (line === "group-end") { | |
| 114 | − | group = null; | |
| 115 | − | continue; | |
| 116 | − | } | |
| 117 | − | if ("group" in line) { | |
| 118 | − | group = { kind: "group", title: line.group, lines: [] }; | |
| 119 | − | out.push(group); | |
| 120 | − | continue; | |
| 121 | − | } | |
| 122 | − | number += 1; | |
| 123 | − | if (group) group.lines.push({ line, number }); | |
| 124 | − | else out.push({ kind: "line", line, number }); | |
| 125 | − | } | |
| 126 | − | return out; | |
| 127 | − | } | |
| 128 | − | ||
| 129 | 93 | const LINE_STYLE: Record<Line["kind"], string> = { | |
| 130 | 94 | text: "text-fg/85", | |
| 131 | 95 | error: "text-danger", | |
| ⋯ | |||
| 135 | 99 | command: "text-muted", | |
| 136 | 100 | }; | |
| 137 | 101 | ||
| 138 | − | function LogLine({ line, number }: { line: Line; number: number }) { | |
| 102 | + | function LogLine({ line, number, query = "" }: { line: Line; number: number; query?: string }) { | |
| 103 | + | const text = line.text.replace(/^(Error|Warning|Notice): /, ""); | |
| 139 | 104 | return ( | |
| 140 | 105 | <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}> | |
| 141 | 106 | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> | |
| 142 | 107 | <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}> | |
| 143 | 108 | {line.kind === "error" && <span className="font-semibold">Error: </span>} | |
| 144 | 109 | {line.kind === "warning" && <span className="font-semibold">Warning: </span>} | |
| 145 | − | {line.text.replace(/^(Error|Warning|Notice): /, "")} | |
| 110 | + | {query | |
| 111 | + | ? highlight(text, query).map((piece, index) => | |
| 112 | + | piece.match ? ( | |
| 113 | + | <mark key={index} className="rounded-sm bg-accent/30 text-fg"> | |
| 114 | + | {piece.text} | |
| 115 | + | </mark> | |
| 116 | + | ) : ( | |
| 117 | + | piece.text | |
| 118 | + | ), | |
| 119 | + | ) | |
| 120 | + | : text} | |
| 146 | 121 | </span> | |
| 147 | 122 | </div> | |
| 148 | 123 | ); | |
| 149 | 124 | } | |
| 150 | 125 | ||
| 151 | − | export function LogText({ text }: { text: string }) { | |
| 126 | + | /** | |
| 127 | + | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 128 | + | * opened, each match marked. | |
| 129 | + | */ | |
| 130 | + | export function LogText({ text, query = "" }: { text: string; query?: string }) { | |
| 152 | 131 | const parsed = useMemo(() => blocks(text), [text]); | |
| 132 | + | const found = useMemo(() => searchLog(text, query), [text, query]); | |
| 153 | 133 | if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; | |
| 134 | + | if (query.trim()) { | |
| 135 | + | return ( | |
| 136 | + | <div className="py-1 font-mono text-xs leading-5"> | |
| 137 | + | {found.map(({ line, number }) => ( | |
| 138 | + | <LogLine key={number} line={line} number={number} query={query} /> | |
| 139 | + | ))} | |
| 140 | + | </div> | |
| 141 | + | ); | |
| 142 | + | } | |
| 154 | 143 | return ( | |
| 155 | 144 | <div className="py-1 font-mono text-xs leading-5"> | |
| 156 | 145 | {parsed.map((block, index) => | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { WorkflowRun } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { badgeMarkdown, badgeState, badgeSvg, badgeUrl, textWidth } from "./badge.ts"; | |
| 7 | + | ||
| 8 | + | const run = (status: WorkflowRun["status"], conclusion: WorkflowRun["conclusion"]) => ({ status, conclusion }) as WorkflowRun; | |
| 9 | + | ||
| 10 | + | test("a badge says how the newest finished run went", () => { | |
| 11 | + | assert.equal(badgeState([]), "no status"); | |
| 12 | + | assert.equal(badgeState([run("in_progress", null), run("completed", "success")]), "passing"); | |
| 13 | + | assert.equal(badgeState([run("completed", "failure"), run("completed", "success")]), "failing"); | |
| 14 | + | assert.equal(badgeState([run("completed", "cancelled")]), "cancelled"); | |
| 15 | + | assert.equal(badgeState([run("completed", "skipped")]), "passing"); | |
| 16 | + | assert.equal(badgeState([run("queued", null)]), "no status"); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("the badge is an SVG with its label and state, escaped", () => { | |
| 20 | + | const svg = badgeSvg("CI <main>", "passing"); | |
| 21 | + | assert.match(svg, /^<svg xmlns="http:\/\/www.w3.org\/2000\/svg"/); | |
| 22 | + | assert.match(svg, /<title>CI <main>: passing<\/title>/); | |
| 23 | + | assert.match(svg, /fill="#2ea043"/); | |
| 24 | + | assert.doesNotMatch(svg, /<main>/); | |
| 25 | + | assert.ok(textWidth("failing") > textWidth("ill")); | |
| 26 | + | assert.match(badgeSvg("x".repeat(80), "failing"), /x{59}…/); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("its address and Markdown name the workflow file, and a branch or event when chosen", () => { | |
| 30 | + | assert.equal(badgeUrl("https://g1t.sh", "acme/web", "ci.yml"), "https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg"); | |
| 31 | + | assert.equal( | |
| 32 | + | badgeUrl("https://g1t.sh", "acme/web", "ci.yml", { branch: "release/1.x", event: "push" }), | |
| 33 | + | "https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg?branch=release%2F1.x&event=push", | |
| 34 | + | ); | |
| 35 | + | assert.equal( | |
| 36 | + | badgeMarkdown("https://g1t.sh", "acme/web", { name: "CI [main]", file: "ci.yml" }), | |
| 37 | + | "[](https://g1t.sh/acme/web/actions?workflow=ci.yml)", | |
| 38 | + | ); | |
| 39 | + | }); |
| 1 | + | /** | |
| 2 | + | * Workflow status badges: an SVG saying how a workflow's latest finished | |
| 3 | + | * run went, at `/{workspace}/{repo}/actions/workflows/{file}/badge.svg`, | |
| 4 | + | * and the Markdown that shows one. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | import type { WorkflowRun } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | export type BadgeState = "passing" | "failing" | "cancelled" | "no status"; | |
| 10 | + | ||
| 11 | + | const COLORS: Record<BadgeState, string> = { | |
| 12 | + | passing: "#2ea043", | |
| 13 | + | failing: "#cf222e", | |
| 14 | + | cancelled: "#6e7681", | |
| 15 | + | "no status": "#6e7681", | |
| 16 | + | }; | |
| 17 | + | const LABEL_COLOR = "#2b2a33"; | |
| 18 | + | ||
| 19 | + | /** What the newest finished run among `runs` (newest first) came to. */ | |
| 20 | + | export function badgeState(runs: WorkflowRun[]): BadgeState { | |
| 21 | + | const run = runs.find((candidate) => candidate.status === "completed"); | |
| 22 | + | switch (run?.conclusion) { | |
| 23 | + | case "success": | |
| 24 | + | case "skipped": | |
| 25 | + | return "passing"; | |
| 26 | + | case "failure": | |
| 27 | + | return "failing"; | |
| 28 | + | case "cancelled": | |
| 29 | + | return "cancelled"; | |
| 30 | + | default: | |
| 31 | + | return "no status"; | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** Roughly how wide `text` is in 11px Verdana, as badges are drawn. */ | |
| 36 | + | export function textWidth(text: string): number { | |
| 37 | + | let width = 0; | |
| 38 | + | for (const c of text) { | |
| 39 | + | if ("iljtfI.,:;'|!".includes(c)) width += 3.5; | |
| 40 | + | else if ("mwMW@%".includes(c)) width += 10.5; | |
| 41 | + | else if (c === " ") width += 3.8; | |
| 42 | + | else if (/[A-Z0-9]/.test(c)) width += 7.6; | |
| 43 | + | else width += 6.6; | |
| 44 | + | } | |
| 45 | + | return Math.ceil(width); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function escape(text: string): string { | |
| 49 | + | return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** A flat badge: `label` on dark gray, `state` in its color. */ | |
| 53 | + | export function badgeSvg(label: string, state: BadgeState): string { | |
| 54 | + | const shown = label.length > 60 ? `${label.slice(0, 59)}…` : label; | |
| 55 | + | const left = textWidth(shown) + 12; | |
| 56 | + | const right = textWidth(state) + 12; | |
| 57 | + | const width = left + right; | |
| 58 | + | const title = escape(`${shown}: ${state}`); | |
| 59 | + | const text = (x: number, value: string) => | |
| 60 | + | `<text x="${x}" y="15" fill="#010101" fill-opacity=".3">${escape(value)}</text><text x="${x}" y="14">${escape(value)}</text>`; | |
| 61 | + | return [ | |
| 62 | + | `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${title}">`, | |
| 63 | + | `<title>${title}</title>`, | |
| 64 | + | `<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>`, | |
| 65 | + | `<clipPath id="r"><rect width="${width}" height="20" rx="3" fill="#fff"/></clipPath>`, | |
| 66 | + | `<g clip-path="url(#r)"><rect width="${left}" height="20" fill="${LABEL_COLOR}"/><rect x="${left}" width="${right}" height="20" fill="${COLORS[state]}"/><rect width="${width}" height="20" fill="url(#s)"/></g>`, | |
| 67 | + | `<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">`, | |
| 68 | + | text(left / 2, shown), | |
| 69 | + | text(left + right / 2, state), | |
| 70 | + | `</g></svg>`, | |
| 71 | + | ].join(""); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** The badge's address, with its branch and event when chosen. */ | |
| 75 | + | export function badgeUrl(site: string, repo: string, file: string, options: { branch?: string; event?: string } = {}): string { | |
| 76 | + | const query = new URLSearchParams(); | |
| 77 | + | if (options.branch) query.set("branch", options.branch); | |
| 78 | + | if (options.event) query.set("event", options.event); | |
| 79 | + | const tail = query.size > 0 ? `?${query}` : ""; | |
| 80 | + | return `${site}/${repo}/actions/workflows/${encodeURIComponent(file)}/badge.svg${tail}`; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** Markdown for a badge that links to the workflow's runs. */ | |
| 84 | + | export function badgeMarkdown(site: string, repo: string, workflow: { name: string; file: string }, options: { branch?: string; event?: string } = {}): string { | |
| 85 | + | const alt = workflow.name.replace(/[[\]]/g, ""); | |
| 86 | + | const runs = `${site}/${repo}/actions?workflow=${encodeURIComponent(workflow.file)}`; | |
| 87 | + | return `[})](${runs})`; | |
| 88 | + | } |
| 1 | + | /** Answers for the log downloads: a file to save, or why there is none. */ | |
| 2 | + | ||
| 3 | + | export function refused(status: number, message: string): Response { | |
| 4 | + | return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } }); | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | export function attachment(body: BodyInit, type: string, file: string): Response { | |
| 8 | + | return new Response(body, { | |
| 9 | + | headers: { | |
| 10 | + | "content-type": type, | |
| 11 | + | "content-disposition": `attachment; filename="${file.replace(/["\\\r\n]/g, "")}"`, | |
| 12 | + | "cache-control": "no-store", | |
| 13 | + | }, | |
| 14 | + | }); | |
| 15 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { JobLogText } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { archiveFiles, blocks, fileName, highlight, searchLog } from "./log-lines.ts"; | |
| 7 | + | ||
| 8 | + | const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n"); | |
| 9 | + | ||
| 10 | + | test("a log reads as lines and folded groups, numbered past the group markers", () => { | |
| 11 | + | const parsed = blocks(LOG); | |
| 12 | + | assert.equal(parsed.length, 4); | |
| 13 | + | assert.deepEqual(parsed[1], { kind: "group", title: "Run cargo test", lines: [{ line: { kind: "text", text: "cargo test --locked" }, number: 2 }] }); | |
| 14 | + | assert.deepEqual(parsed[3], { kind: "line", line: { kind: "error", text: "Process completed with exit code 101." }, number: 4 }); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("search finds lines in any case, inside folded groups too", () => { | |
| 18 | + | assert.deepEqual( | |
| 19 | + | searchLog(LOG, "CARGO TEST").map((found) => found.number), | |
| 20 | + | [2], | |
| 21 | + | ); | |
| 22 | + | assert.deepEqual( | |
| 23 | + | searchLog(LOG, "test").map((found) => found.number), | |
| 24 | + | [1, 2, 3], | |
| 25 | + | ); | |
| 26 | + | assert.deepEqual(searchLog(LOG, " "), []); | |
| 27 | + | // The group's title is not a line of its own. | |
| 28 | + | assert.deepEqual(searchLog(LOG, "Run cargo"), []); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("matches are marked where they fall", () => { | |
| 32 | + | assert.deepEqual(highlight("Test a test", "test"), [ | |
| 33 | + | { text: "Test", match: true }, | |
| 34 | + | { text: " a ", match: false }, | |
| 35 | + | { text: "test", match: true }, | |
| 36 | + | ]); | |
| 37 | + | assert.deepEqual(highlight("plain", ""), [{ text: "plain", match: false }]); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("a run's archive has each job whole and a file per step, named as the API names them", () => { | |
| 41 | + | const job = (name: string, chunks: [number, string][], omitted = false): JobLogText => ({ | |
| 42 | + | jobId: "job_1", | |
| 43 | + | name, | |
| 44 | + | steps: [{ number: 1, name: "Run cargo test", status: "completed", conclusion: "success", startedAt: null, finishedAt: null }], | |
| 45 | + | chunks: chunks.map(([step, text], index) => ({ seq: index + 1, step, text })), | |
| 46 | + | done: true, | |
| 47 | + | omitted, | |
| 48 | + | }); | |
| 49 | + | const files = archiveFiles([job("test", [[0, "set up\n"], [1, "running\n"], [1, "ok\n"]]), job("deploy/x", [], true)]); | |
| 50 | + | assert.deepEqual( | |
| 51 | + | files.map((file) => file.path), | |
| 52 | + | ["1_test.txt", "test/0_Set up job.txt", "test/1_Run cargo test.txt", "2_deploy_x.txt"], | |
| 53 | + | ); | |
| 54 | + | assert.equal(files[0]!.text, "set up\nrunning\nok\n"); | |
| 55 | + | assert.equal(files[2]!.text, "running\nok\n"); | |
| 56 | + | assert.match(files[3]!.text, /left out/); | |
| 57 | + | assert.equal(fileName("build / a:b"), "build _ a_b"); | |
| 58 | + | assert.equal(fileName(".."), "job"); | |
| 59 | + | }); |
| 1 | + | /** | |
| 2 | + | * A job's log as GitHub shows one: lines, folded `##[group]`s, errors and | |
| 3 | + | * warnings marked; searching it; and the files of a run's log archive. | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | import type { JobLogText } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command"; | |
| 9 | + | export type Line = { kind: LineKind; text: string }; | |
| 10 | + | export type Block = | |
| 11 | + | | { kind: "line"; line: Line; number: number } | |
| 12 | + | | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; | |
| 13 | + | ||
| 14 | + | export function classify(raw: string): Line | "group-end" | { group: string } { | |
| 15 | + | if (raw.startsWith("##[group]")) return { group: raw.slice(9) }; | |
| 16 | + | if (raw.startsWith("##[endgroup]")) return "group-end"; | |
| 17 | + | for (const kind of ["error", "warning", "notice", "debug"] as const) { | |
| 18 | + | if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) }; | |
| 19 | + | } | |
| 20 | + | if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) }; | |
| 21 | + | return { kind: "text", text: raw }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ | |
| 25 | + | export function blocks(text: string): Block[] { | |
| 26 | + | const out: Block[] = []; | |
| 27 | + | let group: Extract<Block, { kind: "group" }> | null = null; | |
| 28 | + | let number = 0; | |
| 29 | + | for (const raw of text.split("\n")) { | |
| 30 | + | if (raw === "" && number === 0) continue; | |
| 31 | + | const line = classify(raw); | |
| 32 | + | if (line === "group-end") { | |
| 33 | + | group = null; | |
| 34 | + | continue; | |
| 35 | + | } | |
| 36 | + | if ("group" in line) { | |
| 37 | + | group = { kind: "group", title: line.group, lines: [] }; | |
| 38 | + | out.push(group); | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | number += 1; | |
| 42 | + | if (group) group.lines.push({ line, number }); | |
| 43 | + | else out.push({ kind: "line", line, number }); | |
| 44 | + | } | |
| 45 | + | return out; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */ | |
| 49 | + | export function searchLog(text: string, query: string): { line: Line; number: number }[] { | |
| 50 | + | const wanted = query.trim().toLowerCase(); | |
| 51 | + | if (!wanted) return []; | |
| 52 | + | const found: { line: Line; number: number }[] = []; | |
| 53 | + | for (const block of blocks(text)) { | |
| 54 | + | const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines; | |
| 55 | + | for (const entry of lines) { | |
| 56 | + | if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return found; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ | |
| 63 | + | export function highlight(text: string, query: string): { text: string; match: boolean }[] { | |
| 64 | + | const wanted = query.trim().toLowerCase(); | |
| 65 | + | if (!wanted) return [{ text, match: false }]; | |
| 66 | + | const lower = text.toLowerCase(); | |
| 67 | + | const out: { text: string; match: boolean }[] = []; | |
| 68 | + | let at = 0; | |
| 69 | + | for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) { | |
| 70 | + | if (found > at) out.push({ text: text.slice(at, found), match: false }); | |
| 71 | + | out.push({ text: text.slice(found, found + wanted.length), match: true }); | |
| 72 | + | at = found + wanted.length; | |
| 73 | + | } | |
| 74 | + | if (at < text.length) out.push({ text: text.slice(at), match: false }); | |
| 75 | + | return out; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** A name safe as a file name in an archive, as the API's archive names them. */ | |
| 79 | + | export function fileName(name: string): string { | |
| 80 | + | const cleaned = Array.from(name) | |
| 81 | + | .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c)) | |
| 82 | + | .slice(0, 100) | |
| 83 | + | .join("") | |
| 84 | + | .trim() | |
| 85 | + | .replace(/^\.+|\.+$/g, ""); | |
| 86 | + | return cleaned || "job"; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** A job's whole log as one text. */ | |
| 90 | + | export function jobText(job: JobLogText): string { | |
| 91 | + | if (job.omitted) return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n"; | |
| 92 | + | return job.chunks.map((chunk) => chunk.text).join(""); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * The files of a run's log archive: `{n}_{job}.txt` with each job's whole | |
| 97 | + | * log, and `{job}/{step}_{step name}.txt` for each of its steps. | |
| 98 | + | */ | |
| 99 | + | export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] { | |
| 100 | + | const files: { path: string; text: string }[] = []; | |
| 101 | + | jobs.forEach((job, index) => { | |
| 102 | + | const name = fileName(job.name); | |
| 103 | + | files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) }); | |
| 104 | + | if (job.omitted) return; | |
| 105 | + | const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b); | |
| 106 | + | for (const step of steps) { | |
| 107 | + | const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`); | |
| 108 | + | const text = job.chunks | |
| 109 | + | .filter((chunk) => chunk.step === step) | |
| 110 | + | .map((chunk) => chunk.text) | |
| 111 | + | .join(""); | |
| 112 | + | files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text }); | |
| 113 | + | } | |
| 114 | + | }); | |
| 115 | + | return files; | |
| 116 | + | } |
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.