Skip to content

Commit

API: run attempts, re-run a job, force-cancel, log downloads

GET …/actions/runs/{id}/attempts/{attempt}, POST …/actions/jobs/{job}/rerun and …/runs/{id}/force-cancel at GitHub's addresses; rerun takes job and debug (or enable_debug_logging), cancel takes force. GET …/runs/{id}/logs and …/attempts/{attempt}/logs answer a zip of every job's log, whole and by step; …/jobs/{job}/logs?format=text one job's as text. The workflow MCP tool's get_run, cancel and rerun take the same.

syntaqxcommitted Parent41c3ba5Browse files
9 files+408−150/9 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
798798 ("PUT", "enable") => "enable_workflow".to_owned(),
799799 ("PUT", "disable") => "disable_workflow".to_owned(),
800800 ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
801+ ("POST", "rerun") if route.path.contains("/jobs/:job/") => "rerun_job".to_owned(),
802+ ("POST", "force-cancel") => "force_cancel_workflow_run".to_owned(),
803+ ("GET", ":attempt") => "get_workflow_run_attempt".to_owned(),
801804 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
802805 ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
803806 // One repository's notifications, and an issue's subscription by
831834 "enable_workflow" => "Turn a workflow on",
832835 "disable_workflow" => "Turn a workflow off",
833836 "rerun_failed_jobs" => "Re-run failed jobs",
837+ "rerun_job" => "Re-run a job",
838+ "force_cancel_workflow_run" => "Force-cancel a workflow run",
839+ "get_workflow_run_attempt" => "Get a workflow run attempt",
834840 "update_actions_variable" => "Update a variable",
835841 "list_runs_of_workflow" => "List a workflow's runs",
836842 "list_repo_notifications" => "List a repository's notifications",
+26−8
14721472 "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."
14731473 }
14741474 Op::GetWorkflowRun => {
1475− "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."
1475+ "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."
14761476 }
14771477 Op::GetJobLogs => {
14781478 "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."
14801480 Op::DispatchWorkflow => {
14811481 "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."
14821482 }
1483− 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.",
1483+ Op::CancelWorkflowRun => {
1484+ "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."
1485+ }
14841486 Op::RerunWorkflowRun => {
1485− "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."
1487+ "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."
14861488 }
14871489 Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.",
14881490 Op::ListActionsSecrets => {
25292531 &["repo"],
25302532 ),
25312533 Op::GetWorkflowRun => object(
2532− json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }),
2534+ json!({
2535+ "repo": repo_schema(),
2536+ "id": { "type": "string", "description": "The run's id." },
2537+ "attempt": { "type": "integer", "description": "An earlier attempt, from 1. The latest if not given." },
2538+ }),
25332539 &["repo", "id"],
25342540 ),
25352541 Op::GetJobLogs => object(
25502556 &["repo", "workflow"],
25512557 ),
25522558 Op::CancelWorkflowRun => object(
2553− json!({ "repo": repo_schema(), "id": { "type": "string", "description": "The run's id." } }),
2559+ json!({
2560+ "repo": repo_schema(),
2561+ "id": { "type": "string", "description": "The run's id." },
2562+ "force": { "type": "boolean", "description": "Stop running jobs outright, without their cleanup steps." },
2563+ }),
25542564 &["repo", "id"],
25552565 ),
25562566 Op::RerunWorkflowRun => object(
25572567 json!({
25582568 "repo": repo_schema(),
2559− "id": { "type": "string", "description": "The run's id." },
2569+ "id": { "type": "string", "description": "The run's id. Not needed with `job`." },
25602570 "failed_only": { "type": "boolean", "description": "Only the jobs that did not succeed, and those that need them." },
2571+ "job": { "type": "string", "description": "One job to run again, by its id in the latest attempt, with the jobs that need it." },
2572+ "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." },
2573+ "enable_debug_logging": { "type": "boolean", "description": "The same as `debug`, by GitHub's name for it." },
25612574 }),
2562− &["repo", "id"],
2575+ &["repo"],
25632576 ),
25642577 Op::UpdateWorkflow => object(
25652578 json!({
46184631 )
46194632 .await
46204633 }
4621− Op::GetWorkflowRun => pass(actions, "run", &json!({ "repo": repo, "viewer": viewer, "id": text(input, "id") })).await,
4634+ Op::GetWorkflowRun => {
4635+ pass(actions, "run", &json!({ "repo": repo, "viewer": viewer, "id": text(input, "id"), "attempt": integer(input, "attempt") })).await
4636+ }
46224637 Op::GetJobLogs => {
46234638 pass(
46244639 actions,
46504665 "repo": repo,
46514666 "id": text(input, "id"),
46524667 "failed_only": input["failed_only"].as_bool() == Some(true),
4668+ "job": optional_text(input, "job"),
4669+ "debug": input["debug"].as_bool() == Some(true) || input["enable_debug_logging"].as_bool() == Some(true),
4670+ "force": input["force"].as_bool() == Some(true),
46534671 }),
46544672 )
46554673 .await
+68−1
26112611 "job": "test",
26122612 "message": "`services` containers (such as a database) are not started on g1t yet."
26132613 }
2614+ ],
2615+ "attempts": [
2616+ {
2617+ "attempt": 1,
2618+ "status": "completed",
2619+ "conclusion": "failure",
2620+ "actor": "syntaqx",
2621+ "debug": false,
2622+ "started_at": "2026-10-04T15:42:09.020Z",
2623+ "finished_at": "2026-10-04T15:44:31.877Z"
2624+ }
26142625 ]
2615− }
2626+ },
2627+ "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."
26162628 },
26172629 "get_job_logs": {
26182630 "params": {
27482760 "finished_at": null
27492761 }
27502762 },
2763+ "rerun_job": {
2764+ "params": {
2765+ "job": "job_01kpx7b3d0e4f8g2h6j0k4m8ns"
2766+ },
2767+ "request": {
2768+ "enable_debug_logging": true
2769+ },
2770+ "response": {
2771+ "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr",
2772+ "workflow_id": "wfl_01kpw2c5d8e1f4g7h0j3k6m9np",
2773+ "path": ".g1t/workflows/ci.yml",
2774+ "name": "CI",
2775+ "title": "Greeting should name the caller",
2776+ "number": 12,
2777+ "attempt": 2,
2778+ "event": "push",
2779+ "ref": "refs/heads/main",
2780+ "sha": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2781+ "pull": null,
2782+ "status": "queued",
2783+ "conclusion": null,
2784+ "error": null,
2785+ "actor": "syntaqx",
2786+ "created_at": "2026-10-04T15:42:07.318Z",
2787+ "started_at": null,
2788+ "finished_at": null
2789+ },
2790+ "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."
2791+ },
2792+ "force_cancel_workflow_run": {
2793+ "params": {
2794+ "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr"
2795+ },
2796+ "response": {
2797+ "id": "run_01kpx7b3c6d9e2f5g8h1j4k7mr",
2798+ "workflow_id": "wfl_01kpw2c5d8e1f4g7h0j3k6m9np",
2799+ "path": ".g1t/workflows/ci.yml",
2800+ "name": "CI",
2801+ "title": "Greeting should name the caller",
2802+ "number": 12,
2803+ "attempt": 1,
2804+ "event": "push",
2805+ "ref": "refs/heads/main",
2806+ "sha": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13",
2807+ "pull": null,
2808+ "status": "completed",
2809+ "conclusion": "cancelled",
2810+ "error": null,
2811+ "actor": "syntaqx",
2812+ "created_at": "2026-10-04T15:42:07.318Z",
2813+ "started_at": "2026-10-04T15:42:09.020Z",
2814+ "finished_at": "2026-10-04T15:43:02.411Z"
2815+ },
2816+ "notes": "Stops running jobs at once, without their `if: always()`, `cancelled()` or post steps. Use it when a job's cleanup hangs."
2817+ },
27512818 "update_workflow": {
27522819 "params": {
27532820 "workflow": "nightly.yml"
+21−0
678678 &[],
679679 ),
680680 route(
681+ "GET",
682+ "/repos/:owner/:name/actions/runs/:id/attempts/:attempt",
683+ Op::GetWorkflowRun,
684+ &[],
685+ ),
686+ route(
681687 "POST",
682688 "/repos/:owner/:name/actions/runs/:id/cancel",
683689 Op::CancelWorkflowRun,
685691 ),
686692 route(
687693 "POST",
694+ "/repos/:owner/:name/actions/runs/:id/force-cancel",
695+ Op::CancelWorkflowRun,
696+ &[],
697+ ),
698+ route(
699+ "POST",
688700 "/repos/:owner/:name/actions/runs/:id/rerun",
689701 Op::RerunWorkflowRun,
690702 &[],
696708 &[],
697709 ),
698710 route(
711+ "POST",
712+ "/repos/:owner/:name/actions/jobs/:job/rerun",
713+ Op::RerunWorkflowRun,
714+ &[],
715+ ),
716+ route(
699717 "GET",
700718 "/repos/:owner/:name/actions/jobs/:job/logs",
701719 Op::GetJobLogs,
10421060 if route.path.ends_with("/rerun-failed-jobs") {
10431061 input.insert("failed_only".to_owned(), Value::Bool(true));
10441062 }
1063+ if route.path.ends_with("/force-cancel") {
1064+ input.insert("force".to_owned(), Value::Bool(true));
1065+ }
10451066 // Unsaving and waking a thread are a DELETE of what PUT made.
10461067 if route.method == "DELETE" && route.path.ends_with("/saved") {
10471068 input.insert("saved".to_owned(), Value::Bool(false));
+3−3
210210 actions: &[
211211 a("list", Op::ListWorkflows, "Workflows on the default branch"),
212212 a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"),
213− a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps"),
213+ a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps; an earlier attempt with attempt"),
214214 a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"),
215215 a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"),
216− a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
217− a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
216+ a("cancel", Op::CancelWorkflowRun, "Cancel a run, letting its jobs clean up; force stops them outright"),
217+ a("rerun", Op::RerunWorkflowRun, "Run a finished run again: all, failed_only, or one job; debug for debug logging"),
218218 a("update", Op::UpdateWorkflow, "Turn a workflow on or off"),
219219 a("list_artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), "A repository's artifacts, newest first; or a run's with run_artifacts"),
220220 a("run_artifacts", Op::Artifacts(ArtifactsOp::ListRunArtifacts), "One run's artifacts"),
+3−3
412412 | --- | --- | --- | --- |
413413 | [`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` |
414414 | [`list_runs`](/reference/api/actions/list-runs-of-workflow/) | Runs, newest first; filter by `workflow`, `branch`, `event`, `pull` or `sha`. | `repo` | `workflows:read` |
415−| [`get_run`](/reference/api/actions/get-workflow-run/) | A run with its jobs, their steps and annotations. | `repo`, `id` | `workflows:read` |
415+| [`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` |
416416 | [`job_logs`](/reference/api/actions/get-job-logs/) | A job's log after `after`; `done` says if more will come. | `repo`, `job` | `workflows:read` |
417417 | [`dispatch`](/reference/api/actions/dispatch-workflow/) | Run a `workflow_dispatch` workflow on `ref` with `inputs`. Write role. | `repo`, `workflow` | `workflows:write` |
418−| [`cancel`](/reference/api/actions/cancel-workflow-run/) | Cancel a run. Write role. | `repo`, `id` | `workflows:write` |
419−| [`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` |
418+| [`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` |
419+| [`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` |
420420 | [`update`](/reference/api/actions/update-workflow/) | Turn a workflow on or off. Maintain role. | `repo`, `workflow`, `enabled` | `workflows:write` |
421421 | [`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` |
422422 | [`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.