Skip to content

Commit

Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)

syntaqxcommitted Parents5501f816347b63Browse files
38 files+887−610/38 viewed
+9−0
1414 mod checks;
1515 mod deployments;
1616 mod deploy_keys;
17+mod logs;
1718 mod mcp;
1819 mod notifications;
1920 mod oauth;
642643 return mcp::handle(request, &services, &viewer).await;
643644 }
644645
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+
645654 match (method, path.trim_end_matches('/')) {
646655 ("GET", "") => return reply(&index(&services.addresses)),
647656 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
+272−0
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(&central);
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+}
+6−0
808808 ("PUT", "enable") => "enable_workflow".to_owned(),
809809 ("PUT", "disable") => "disable_workflow".to_owned(),
810810 ("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(),
811814 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
812815 ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
813816 // One repository's notifications, and an issue's subscription by
841844 "enable_workflow" => "Turn a workflow on",
842845 "disable_workflow" => "Turn a workflow off",
843846 "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",
844850 "update_actions_variable" => "Update a variable",
845851 "list_runs_of_workflow" => "List a workflow's runs",
846852 "list_repo_notifications" => "List a repository's notifications",
+26−8
15011501 "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."
15021502 }
15031503 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."
15051505 }
15061506 Op::GetJobLogs => {
15071507 "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."
15091509 Op::DispatchWorkflow => {
15101510 "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."
15111511 }
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+ }
15131515 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."
15151517 }
15161518 Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.",
15171519 Op::ListActionsSecrets => {
25772579 &["repo"],
25782580 ),
25792581 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+ }),
25812587 &["repo", "id"],
25822588 ),
25832589 Op::GetJobLogs => object(
25982604 &["repo", "workflow"],
25992605 ),
26002606 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+ }),
26022612 &["repo", "id"],
26032613 ),
26042614 Op::RerunWorkflowRun => object(
26052615 json!({
26062616 "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`." },
26082618 "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." },
26092622 }),
2610− &["repo", "id"],
2623+ &["repo"],
26112624 ),
26122625 Op::UpdateWorkflow => object(
26132626 json!({
47164729 )
47174730 .await
47184731 }
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+ }
47204735 Op::GetJobLogs => {
47214736 pass(
47224737 actions,
47484763 "repo": repo,
47494764 "id": text(input, "id"),
47504765 "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),
47514769 }),
47524770 )
47534771 .await
+68−1
27442744 "job": "test",
27452745 "message": "`services` containers (such as a database) are not started on g1t yet."
27462746 }
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+ }
27472758 ]
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."
27492761 },
27502762 "get_job_logs": {
27512763 "params": {
28812893 "finished_at": null
28822894 }
28832895 },
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+ },
28842951 "update_workflow": {
28852952 "params": {
28862953 "workflow": "nightly.yml"
+21−0
680680 &[],
681681 ),
682682 route(
683+ "GET",
684+ "/repos/:owner/:name/actions/runs/:id/attempts/:attempt",
685+ Op::GetWorkflowRun,
686+ &[],
687+ ),
688+ route(
683689 "POST",
684690 "/repos/:owner/:name/actions/runs/:id/cancel",
685691 Op::CancelWorkflowRun,
687693 ),
688694 route(
689695 "POST",
696+ "/repos/:owner/:name/actions/runs/:id/force-cancel",
697+ Op::CancelWorkflowRun,
698+ &[],
699+ ),
700+ route(
701+ "POST",
690702 "/repos/:owner/:name/actions/runs/:id/rerun",
691703 Op::RerunWorkflowRun,
692704 &[],
698710 &[],
699711 ),
700712 route(
713+ "POST",
714+ "/repos/:owner/:name/actions/jobs/:job/rerun",
715+ Op::RerunWorkflowRun,
716+ &[],
717+ ),
718+ route(
701719 "GET",
702720 "/repos/:owner/:name/actions/jobs/:job/logs",
703721 Op::GetJobLogs,
10541072 if route.path.ends_with("/rerun-failed-jobs") {
10551073 input.insert("failed_only".to_owned(), Value::Bool(true));
10561074 }
1075+ if route.path.ends_with("/force-cancel") {
1076+ input.insert("force".to_owned(), Value::Bool(true));
1077+ }
10571078 // Unsaving and waking a thread are a DELETE of what PUT made.
10581079 if route.method == "DELETE" && route.path.ends_with("/saved") {
10591080 input.insert("saved".to_owned(), Value::Bool(false));
+3−3
214214 actions: &[
215215 a("list", Op::ListWorkflows, "Workflows on the default branch"),
216216 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"),
218218 a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"),
219219 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"),
222222 a("update", Op::UpdateWorkflow, "Turn a workflow on or off"),
223223 a("list_artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), "A repository's artifacts, newest first; or a run's with run_artifacts"),
224224 a("run_artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), "One run's artifacts"),
+133−6
4141 | Composite actions | The same. |
4242 | 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). |
4343 | `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). |
4545 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run, and [masked](#masking-secrets) values stay hidden. |
4646 | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is [the job's own token](#the-jobs-token); `GITHUB_TOKEN` is its alias. |
4747 | `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. |
729729
730730 A run's page shows its jobs, each job's steps, and their logs as they are
731731 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 `***`.
734733
735734 The start of each job's log lists what its [token](#the-jobs-token) may do.
736735
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+[![CI](https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg)](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+
737862 ### Masking secrets
738863
739864 Every secret's value is replaced with `***` wherever a job prints it, and
11101235 | `list` | `GET /repos/{owner}/{repo}/actions/workflows` |
11111236 | `list_runs` | `GET /repos/{owner}/{repo}/actions/runs`, with `workflow`, `branch`, `event`, `pull`, `head_sha` |
11121237 | `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 |
11141241 | `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`) |
11171244 | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` |
11181245 | `approve_run` | `POST /repos/{owner}/{repo}/actions/runs/{id}/approve` |
11191246 | `pending_deployments` | `GET /repos/{owner}/{repo}/actions/runs/{id}/pending_deployments` |
+3−3
416416 | --- | --- | --- | --- |
417417 | [`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` |
418418 | [`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` |
420420 | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`; `done` says if more will come. | `repo`, `job` | `workflows:read` |
421421 | [`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` |
424424 | [`update`](/reference/api/actions/update-workflow/) | Turn a workflow on or off. Maintain role. | `repo`, `workflow`, `enabled` | `workflows:write` |
425425 | [`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` |
426426 | [`run_artifacts`](/reference/api/actions/list-workflow-run-artifacts/) | One run's artifacts; `name`. | `repo`, `id` (the run) | `workflows:read` |
+0−0

Binary or large file; its contents are not shown.

+29−40
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1111
1212 import { Hint } from "./ui/hint";
13+import { type Line, blocks, highlight, searchLog } from "../lib/log-lines";
1314
1415 /**
1516 * Where a run, job or step stands. `of` says which: a run's `pending` waits
8788 const pull = /^refs\/pull\/(\d+)\//.exec(ref);
8889 if (pull) return `#${pull[1]}`;
8990 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 };
10391 }
10492
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−
12993 const LINE_STYLE: Record<Line["kind"], string> = {
13094 text: "text-fg/85",
13195 error: "text-danger",
13599 command: "text-muted",
136100 };
137101
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): /, "");
139104 return (
140105 <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}>
141106 <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span>
142107 <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}>
143108 {line.kind === "error" && <span className="font-semibold">Error: </span>}
144109 {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}
146121 </span>
147122 </div>
148123 );
149124 }
150125
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 }) {
152131 const parsed = useMemo(() => blocks(text), [text]);
132+ const found = useMemo(() => searchLog(text, query), [text, query]);
153133 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+ }
154143 return (
155144 <div className="py-1 font-mono text-xs leading-5">
156145 {parsed.map((block, index) =>
+39−0
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 &lt;main&gt;: 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+ "[![CI main](https://g1t.sh/acme/web/actions/workflows/ci.yml/badge.svg)](https://g1t.sh/acme/web/actions?workflow=ci.yml)",
38+ );
39+});
+88−0
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 `[![${alt}](${badgeUrl(site, repo, workflow.file, options)})](${runs})`;
88+}
+15−0
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+}
+59−0
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+});
+116−0
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+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.