Skip to content
1,489 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Cache hits are counted, not only their bytes1//! Runs one GitHub Actions job, as GitHub's runner would: its steps in
2//! order, each `run` in a shell and each `uses` as the action it names,
3//! with the `${{ }}` contexts, the `GITHUB_*` variables and files, and the
4//! workflow commands steps print. It reports every step and the log to
5//! g1t as it goes.
6//!
7//! Configuration comes from the environment: `G1T_API`, and `ACTIONS_JOB`
8//! and `ACTIONS_TOKEN`, the job and its own token. Everything else, the
9//! job's definition, its contexts and its secrets, is fetched with them.
10
11mod blobs;
Merge branch 'main' into rm-agents12mod broker;
Merge branch 'main' into actions-toolkit-oidc-artifacts13mod containers;
Cache hits are counted, not only their bytes14mod files;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R215mod glob;
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r916mod matchers;
Cache hits are counted, not only their bytes17mod paths;
18mod process;
19mod report;
20mod uses;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r921/// Also unpacks the plugins a run gets from Customize (customize.rs).
22pub(crate) mod zip;
Cache hits are counted, not only their bytes23
Merge branch 'main' into actions-toolkit-oidc-artifacts24use std::collections::{BTreeMap, BTreeSet};
Cache hits are counted, not only their bytes25use std::path::{Path, PathBuf};
26use std::process::Command;
27use std::time::{Duration, Instant};
28
29use anyhow::{Context, Result};
30use g1t_actions::events::WORKSPACE;
31use g1t_actions::expr::{self, Scope, Status};
32use serde_json::{Map, Value, json};
33
34use files::StepFiles;
35use process::{Commands, Ended};
36use report::{Api, Log};
37
38const TEMP: &str = "/home/runner/_temp";
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.39/// Set to `1` by the self-hosted runner: the job's token is spent on its
40/// spec, and the harness carries on with the one that replaces it
41/// (`report::Api::spec`). A `G1T_` variable, so no step sees it.
42pub(crate) const ROTATE_TOKEN: &str = "G1T_ROTATE_JOB_TOKEN";
Cache hits are counted, not only their bytes43
44/// Who is running steps: the job itself, or a composite action inside it.
45#[derive(Clone, Default)]
46pub(crate) struct Frame {
47 /// The `steps` context.
48 pub(crate) steps: Map<String, Value>,
49 /// A composite action's `inputs`, in place of the workflow's.
50 pub(crate) inputs: Option<Value>,
51 /// A composite action's folder, for `github.action_path`.
52 pub(crate) action_path: Option<String>,
53 /// Variables a composite action's caller set for its steps.
54 pub(crate) env: BTreeMap<String, String>,
55}
56
57/// A step run when the job's steps are done: an action's `post`, or
58/// saving the cache.
59pub(crate) struct Post {
60 pub(crate) name: String,
61 pub(crate) condition: String,
62 pub(crate) env: BTreeMap<String, String>,
63 pub(crate) run: PostRun,
64}
65
66pub(crate) enum PostRun {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r967 Node { action_dir: PathBuf, script: String, using: String },
Merge branch 'main' into actions-toolkit-oidc-artifacts68 /// A Docker action's `post-entrypoint`.
69 Docker(containers::DockerRun),
Cache hits are counted, not only their bytes70}
71
72pub(crate) struct Job {
73 pub(crate) log: Log,
74 pub(crate) spec: Value,
75 pub(crate) workspace: PathBuf,
76 pub(crate) temp: PathBuf,
77 /// This process's own variables, less its credentials, and GitHub's.
78 base_env: BTreeMap<String, String>,
79 /// Written to `GITHUB_ENV` by earlier steps.
80 added_env: BTreeMap<String, String>,
81 /// Written to `GITHUB_PATH` by earlier steps, newest first.
82 path_prepend: Vec<String>,
83 workflow_env: BTreeMap<String, String>,
84 job_env: BTreeMap<String, String>,
85 /// github, vars, secrets, inputs, matrix, needs, strategy, runner.
86 pub(crate) contexts: Map<String, Value>,
87 pub(crate) failed: bool,
88 pub(crate) posts: Vec<Post>,
89 step_names: Vec<String>,
90 deadline: Instant,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts91 /// The running step's `timeout-minutes`, as an instant: the nearest of
92 /// the step's own and those of the steps around it (a composite
93 /// action's step inside a `uses:` step). Every process a step starts
94 /// stops by it, as every one stops by the job's deadline.
95 step_deadline: Option<Instant>,
Cache hits are counted, not only their bytes96 debug: bool,
97 /// What the last Node process left, for the step that ran it.
98 pub(crate) last_node_outputs: BTreeMap<String, String>,
99 pub(crate) last_node_state: BTreeMap<String, String>,
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9100 /// What each action's `pre` saved, by its step's `GITHUB_ACTION`, for
101 /// the step's main and post.
102 pub(crate) pre_state: BTreeMap<String, BTreeMap<String, String>>,
Merge branch 'main' into actions-toolkit-oidc-artifacts103 /// The names of the sandbox's own variables, which a container does
104 /// not get.
105 host_env: BTreeSet<String>,
106 /// Whether this job has a Docker Engine of its own (g1t's machines).
107 pub(crate) docker_hosted: bool,
108 /// The job's network, once its containers have one.
109 pub(crate) network: Option<String>,
110 /// `services:`, by their names, and their containers' names.
111 pub(crate) services: Vec<(String, String)>,
112 /// `container:`, once started.
113 pub(crate) container: Option<containers::JobContainer>,
114 /// The `job` context's `container` and `services`.
115 pub(crate) job_context: Map<String, Value>,
116 /// Docker actions' images built in this job.
117 pub(crate) built_actions: containers::Built,
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.118 /// `RUNNER_TRACKING_ID`, which every process a step starts inherits.
119 tracking_id: String,
Merge branch 'main' into rm-agents120 /// Why the job's brokered secrets will not be filled in, when its
121 /// proxy could not start (broker.rs), for "Set up job".
122 broker_problem: Option<String>,
Cache hits are counted, not only their bytes123}
124
125fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
126 value
127 .and_then(Value::as_object)
128 .map(|map| map.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect())
129 .unwrap_or_default()
130}
131
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9132/// Where `name_steps` puts a step's `GITHUB_ACTION`.
133const ACTION_NAME: &str = "__g1t_action";
134
135/// Gives each step its `GITHUB_ACTION`, as GitHub's runner names them: its
136/// `id`, else `__run` for a script and `__owner_repo` for an action
137/// (`__self` for one in the repository), the second of a name `_2`, the
138/// third `_3`.
139pub(crate) fn name_steps(steps: &mut [Map<String, Value>]) {
140 let mut taken: BTreeSet<String> = steps.iter().filter_map(|step| step.get("id").map(expr::to_text)).collect();
141 for step in steps.iter_mut() {
142 let name = match step.get("id").map(expr::to_text) {
143 Some(id) => id,
144 None => {
145 let base = match step.get("uses").map(expr::to_text) {
146 None => "run".to_owned(),
147 Some(uses) if uses.starts_with("./") => "self".to_owned(),
148 Some(uses) => {
149 let name = uses.split('@').next().unwrap_or_default();
150 let name = name.strip_prefix("docker://").unwrap_or(name);
151 let repository: Vec<&str> = name.splitn(3, '/').take(2).collect();
152 repository.join("_").chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect()
153 }
154 };
155 let base = format!("__{base}");
156 let mut name = base.clone();
157 let mut n = 2;
158 while taken.contains(&name) {
159 name = format!("{base}_{n}");
160 n += 1;
161 }
162 taken.insert(name.clone());
163 name
164 }
165 };
166 step.insert(ACTION_NAME.into(), Value::String(name));
167 }
168}
169
Cache hits are counted, not only their bytes170/// A step's title when it has no name, as GitHub shows it.
171fn default_title(step: &Map<String, Value>) -> String {
172 if let Some(uses) = step.get("uses").and_then(Value::as_str) {
173 return format!("Run {uses}");
174 }
175 let run = step.get("run").map(expr::to_text).unwrap_or_default();
176 let first = run.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
177 format!("Run {first}")
178}
179
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.180/// What GitHub's runner sets on a step itself, which its `env:` list leaves out.
181const RUNNER_SET: [&str; 2] = ["GITHUB_ACTION", "GITHUB_ACTION_PATH"];
182
183/// The step's variables as GitHub lists them under `env:`: each name and
184/// its value (masked later, as every line is), a value of several lines
185/// with the rest indented beneath it.
186fn env_lines(env: &BTreeMap<String, String>) -> Vec<String> {
187 let listed: Vec<(&String, &String)> = env.iter().filter(|(name, _)| !RUNNER_SET.contains(&name.as_str())).collect();
188 if listed.is_empty() {
189 return Vec::new();
190 }
191 let mut lines = vec!["env:".to_owned()];
192 for (name, value) in listed {
193 let mut parts = value.split('\n');
194 lines.push(format!(" {name}: {}", parts.next().unwrap_or_default()));
195 lines.extend(parts.map(|part| format!(" {part}")));
196 }
197 lines
198}
199
200/// How GitHub's runner opens a `run` step, in a group that starts folded:
201/// `Run` and the script's first line, the whole script (in cyan, as
202/// GitHub draws it), the shell it runs in and the step's variables.
203pub(crate) fn script_header(script: &str, shell: &str, env: &BTreeMap<String, String>) -> Vec<String> {
204 let first = script.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
205 let mut lines = vec![format!("##[group]Run {first}")];
206 lines.extend(script.trim_matches(['\n', '\r']).lines().map(|line| format!("\u{1b}[36;1m{line}\u{1b}[0m")));
207 lines.push(format!("shell: {shell}"));
208 lines.extend(env_lines(env));
209 lines.push("##[endgroup]".into());
210 lines
211}
212
213/// How GitHub's runner opens a `uses:` step: `Run` and the action, its
214/// inputs under `with:` and the step's variables under `env:`.
215pub(crate) fn uses_header(uses: &str, with: &BTreeMap<String, String>, env: &BTreeMap<String, String>) -> Vec<String> {
216 let mut lines = vec![format!("##[group]Run {uses}")];
217 if !with.is_empty() {
218 lines.push("with:".into());
219 for (name, value) in with {
220 let mut parts = value.trim_end_matches('\n').split('\n');
221 lines.push(format!(" {name}: {}", parts.next().unwrap_or_default()));
222 lines.extend(parts.map(|part| format!(" {part}")));
223 }
224 }
225 lines.extend(env_lines(env));
226 lines.push("##[endgroup]".into());
227 lines
228}
229
Cache hits are counted, not only their bytes230impl Job {
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.231 /// A shell's whole path, found on the step's `PATH` as GitHub's runner
232 /// shows it (`/usr/bin/bash`), or its name when it is not there.
233 fn program_path(&self, program: &str) -> String {
234 if Path::new(program).is_absolute() {
235 return program.to_owned();
236 }
237 let path = self.base_env.get("PATH").cloned().unwrap_or_default();
238 let dirs = self.path_prepend.iter().map(String::as_str).chain(path.split(paths::PATH_SEPARATOR));
239 let names: Vec<String> = if cfg!(windows) { vec![format!("{program}.exe"), program.to_owned()] } else { vec![program.to_owned()] };
240 dirs.filter(|dir| !dir.is_empty())
241 .flat_map(|dir| names.iter().map(move |name| Path::new(dir).join(name)))
242 .find(|candidate| candidate.is_file())
243 .map_or_else(|| program.to_owned(), |found| found.display().to_string())
244 }
245
Cache hits are counted, not only their bytes246 pub(crate) fn base_env_value(&self, name: &str) -> Option<String> {
247 self.base_env.get(name).cloned()
248 }
249
Merge branch 'main' into actions-toolkit-oidc-artifacts250 /// What earlier steps added to `PATH`, newest first.
251 pub(crate) fn path_prepend_entries(&self) -> &[String] {
252 &self.path_prepend
253 }
254
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)255 /// How the job is going, for `success()`, `failure()`, `cancelled()`
256 /// and `always()`: once the run is cancelled, only steps that ask for
257 /// `always()` or `cancelled()` run.
Cache hits are counted, not only their bytes258 fn status(&self) -> Status {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)259 if report::cancelled() {
260 Status::Cancelled
261 } else if self.failed {
262 Status::Failure
263 } else {
264 Status::Success
265 }
266 }
267
268 /// `job.status`.
269 fn status_word(&self) -> &'static str {
270 if report::cancelled() {
271 "cancelled"
272 } else if self.failed {
273 "failure"
274 } else {
275 "success"
276 }
Cache hits are counted, not only their bytes277 }
278
279 /// The contexts an expression in a step can use.
280 pub(crate) fn contexts_for(&self, frame: &Frame, env: &BTreeMap<String, String>) -> Map<String, Value> {
281 let mut contexts = self.contexts.clone();
282 contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
283 contexts.insert("steps".into(), Value::Object(frame.steps.clone()));
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)284 contexts.insert("job".into(), containers::job_context(self.status_word(), &self.job_context));
Cache hits are counted, not only their bytes285 if let Some(inputs) = &frame.inputs {
286 contexts.insert("inputs".into(), inputs.clone());
287 }
288 if let Some(path) = &frame.action_path
289 && let Some(github) = contexts.get_mut("github")
290 {
291 github["action_path"] = Value::String(path.clone());
292 }
293 contexts
294 }
295
296 /// Runs `f` with a scope over these contexts.
297 pub(crate) fn with_scope<T>(&self, contexts: &Map<String, Value>, f: impl FnOnce(&Scope) -> T) -> T {
298 let workspace = self.workspace.clone();
299 let hash = move |patterns: &[String]| files::hash_files(&workspace, patterns);
300 let scope = Scope {
301 contexts,
302 status: self.status(),
303 hash_files: Some(&hash),
304 };
305 f(&scope)
306 }
307
308 /// The `env` context for a step: the workflow's, the job's, what earlier
309 /// steps wrote to `GITHUB_ENV`, and the frame's.
Merge branch 'main' into actions-toolkit-oidc-artifacts310 pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
Cache hits are counted, not only their bytes311 let mut env = self.added_env.clone();
312 env.extend(self.workflow_env.clone());
313 env.extend(self.job_env.clone());
314 env.extend(frame.env.clone());
315 env
316 }
317
318 /// What a process for a step is given.
319 pub(crate) fn process_env(&self, env: &BTreeMap<String, String>, files: &StepFiles) -> BTreeMap<String, String> {
320 let mut out = self.base_env.clone();
321 out.extend(env.clone());
322 for (name, value) in files.variables() {
323 out.insert(name.to_owned(), value);
324 }
325 if !self.path_prepend.is_empty() {
326 let current = out.get("PATH").cloned().unwrap_or_default();
327 let separator = paths::PATH_SEPARATOR;
328 out.insert("PATH".into(), format!("{}{separator}{current}", self.path_prepend.join(separator)));
329 }
330 out
331 }
332
333 /// Takes in what a step wrote to its files. Returns its outputs.
334 pub(crate) fn absorb(&mut self, files: &StepFiles, commands: &Commands) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
335 let mut outputs: BTreeMap<String, String> = commands.outputs.clone();
336 match files::key_values(&StepFiles::read(&files.output)) {
337 Ok(values) => outputs.extend(values),
338 Err(problem) => self.log.line(&format!("##[error]$GITHUB_OUTPUT: {problem}")),
339 }
340 match files::key_values(&StepFiles::read(&files.env)) {
341 Ok(values) => {
342 for (name, value) in values {
343 if name.starts_with("GITHUB_") || name == "NODE_OPTIONS" {
344 self.log.line(&format!("##[warning]{name} cannot be set through $GITHUB_ENV."));
345 continue;
346 }
347 self.added_env.insert(name, value);
348 }
349 }
350 Err(problem) => self.log.line(&format!("##[error]$GITHUB_ENV: {problem}")),
351 }
352 for line in StepFiles::read(&files.path).lines().map(str::trim).filter(|l| !l.is_empty()) {
353 self.path_prepend.insert(0, line.to_owned());
354 }
355 let mut state = commands.state.clone();
356 if let Ok(values) = files::key_values(&StepFiles::read(&files.state)) {
357 state.extend(values);
358 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)359 // The step's job summary, for the run's page (report.rs).
360 self.log.summary(&StepFiles::read(&files.summary));
Cache hits are counted, not only their bytes361 (outputs, state)
362 }
363
364 pub(crate) fn remaining_time(&self) -> Duration {
365 self.remaining()
366 }
367
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts368 /// What is left for the running step: its `timeout-minutes`, within
369 /// the job's.
Cache hits are counted, not only their bytes370 fn remaining(&self) -> Duration {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts371 let until = self.step_deadline.map_or(self.deadline, |step| step.min(self.deadline));
372 until.saturating_duration_since(Instant::now())
373 }
374
375 /// What is left of the job's own time, whatever the step's.
376 fn job_remaining(&self) -> Duration {
Cache hits are counted, not only their bytes377 self.deadline.saturating_duration_since(Instant::now())
378 }
379
380 /// Runs a shell script for a `run` step.
381 pub(crate) fn run_script(
382 &mut self,
383 script: &str,
384 shell: Option<&str>,
385 working_directory: Option<&str>,
386 env: &BTreeMap<String, String>,
387 timeout: Duration,
388 ) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) {
389 crate::abuse::touch();
390 // Mining is never a workflow's job (abuse.rs).
391 if let Some(miner) = crate::abuse::miner_in(script) {
392 self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run."));
393 return (false, BTreeMap::new(), BTreeMap::new());
394 }
395 let id = format!("{:x}", rand_id());
396 let shell = shell.map(str::trim).filter(|s| !s.is_empty());
397 let (program, args, extension): (String, Vec<String>, &str) = match shell {
398 // A self-hosted Windows runner, as GitHub's: PowerShell.
399 None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"),
Merge branch 'main' into actions-toolkit-oidc-artifacts400 // A job container without bash, as GitHub's runner does.
401 None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
Cache hits are counted, not only their bytes402 None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
403 Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
404 Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9405 Some("python") => (self.python_program(), vec!["{0}".into()], "py"),
Cache hits are counted, not only their bytes406 Some("pwsh") if cfg!(windows) || program_exists("pwsh") => ("pwsh".into(), powershell_args(), "ps1"),
407 Some("powershell") if cfg!(windows) => ("powershell".into(), powershell_args(), "ps1"),
408 Some("cmd") if cfg!(windows) => (
409 "cmd".into(),
410 vec!["/D".into(), "/E:ON".into(), "/V:OFF".into(), "/S".into(), "/C".into(), "CALL \"{0}\"".into()],
411 "cmd",
412 ),
413 Some(other @ ("pwsh" | "powershell" | "cmd")) => {
414 self.log.line(&format!(
415 "##[error]`shell: {other}` needs Windows or PowerShell, which g1t's Linux runners do not have. A self-hosted Windows runner can run it: `runs-on: [self-hosted, windows]`."
416 ));
417 return (false, BTreeMap::new(), BTreeMap::new());
418 }
419 Some(custom) => {
420 let mut parts = custom.split_whitespace().map(str::to_owned);
421 let program = parts.next().unwrap_or_default();
422 let mut args: Vec<String> = parts.collect();
423 if !args.iter().any(|a| a.contains("{0}")) {
424 args.push("{0}".into());
425 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9426 let extension = script_extension(&program);
427 (program, args, extension)
Cache hits are counted, not only their bytes428 }
429 };
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9430 let script_path = self.temp.join(if extension.is_empty() { id.clone() } else { format!("{id}.{extension}") });
431 if let Err(error) = std::fs::write(&script_path, script_contents(&program, script)) {
Cache hits are counted, not only their bytes432 self.log.line(&format!("##[error]Could not write the script: {error}"));
433 return (false, BTreeMap::new(), BTreeMap::new());
434 }
435 let files = match StepFiles::new(&self.temp, &id) {
436 Ok(files) => files,
437 Err(error) => {
438 self.log.line(&format!("##[error]Could not make the step's files: {error}"));
439 return (false, BTreeMap::new(), BTreeMap::new());
440 }
441 };
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.442 // GitHub's opening of a `run` step, folded: the script, the shell
443 // it runs in (the program's whole path) and the step's variables.
444 let shown_program = if self.container.is_some() { program.clone() } else { self.program_path(&program) };
445 for line in script_header(script, &format!("{shown_program} {}", args.join(" ")), env) {
446 self.log.line(&line);
447 }
Cache hits are counted, not only their bytes448 let args: Vec<String> = args.iter().map(|a| a.replace("{0}", &paths::shown(&script_path))).collect();
449 let dir = match working_directory {
450 Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
451 Some(dir) => self.workspace.join(dir),
452 None => self.workspace.clone(),
453 };
Merge branch 'main' into actions-toolkit-oidc-artifacts454 let full = self.process_env(env, &files);
455 let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| {
456 let inside = self.container_env(env, full.clone(), &image_path);
457 self.in_container(&program, &args, &dir, inside)
458 });
459 let command = match in_container {
460 Some(command) => command,
461 None => {
462 let mut command = Command::new(&program);
463 command.args(&args).current_dir(&dir).env_clear().envs(full);
464 command
465 }
466 };
Cache hits are counted, not only their bytes467 let mut commands = Commands {
468 debug: self.debug,
469 ..Commands::default()
470 };
471 let ended = process::run(command, timeout.min(self.remaining()), &mut self.log, &mut commands);
472 let ok = match ended {
473 Ok(Ended::Exited(0)) => true,
474 Ok(Ended::Exited(code)) => {
475 self.log.line(&format!("##[error]Process completed with exit code {code}."));
476 false
477 }
478 Ok(Ended::TimedOut) => {
479 self.log.line("##[error]The step ran past its time limit and was stopped.");
480 false
481 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)482 Ok(Ended::Cancelled) => false,
Cache hits are counted, not only their bytes483 Err(error) => {
484 self.log.line(&format!("##[error]{program} could not be started: {error}"));
485 false
486 }
487 };
488 let (outputs, state) = self.absorb(&files, &commands);
489 (ok, outputs, state)
490 }
491
492 /// Runs one step of a frame. Returns whether it succeeded (its
493 /// conclusion). `number` is the step the log belongs to.
494 pub(crate) fn step(&mut self, frame: &mut Frame, step: &Map<String, Value>, number: u32, report: bool, defaults: &Map<String, Value>) -> bool {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)495 // A cancellation heard during an earlier step (which it stopped) or
496 // since: from here on, only cleanup steps run, and none is stopped
497 // for it again.
498 if report::cancelled() {
499 report::take_cancel();
500 }
Cache hits are counted, not only their bytes501 let env_before = self.env_context(frame);
502 let contexts = self.contexts_for(frame, &env_before);
503 let title = match step.get("name").map(expr::to_text) {
504 Some(name) => self.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
505 None => default_title(step),
506 };
507 let condition = step.get("if").map(expr::to_text).unwrap_or_default();
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)508 let read = self.with_scope(&contexts, |scope| expr::condition(&condition, scope));
509 // Debug logging: how the step's `if` read, as GitHub's runner says.
510 if self.debug {
511 let shown = if condition.trim().is_empty() { "success()" } else { condition.trim() };
512 self.log.line(&format!("##[debug]Evaluating condition for step: '{title}'"));
513 self.log.line(&format!("##[debug]Evaluating: {shown}"));
514 match &read {
515 Ok(result) => self.log.line(&format!("##[debug]Result: {result}")),
516 Err(problem) => self.log.line(&format!("##[debug]Failed: {problem}")),
517 }
518 }
519 let run_it = match read {
Cache hits are counted, not only their bytes520 Ok(run_it) => run_it,
521 Err(problem) => {
522 self.log.line(&format!("##[error]The step's `if` does not read: {problem}"));
523 self.failed = true;
524 if report {
525 self.log.step_state(number, &title, "completed", Some("failure"));
526 }
527 return false;
528 }
529 };
530 let id = step.get("id").map(expr::to_text);
531 if !run_it {
532 if let Some(id) = &id {
533 frame.steps.insert(id.clone(), json!({ "outputs": {}, "outcome": "skipped", "conclusion": "skipped" }));
534 }
535 if report {
536 self.log.step_state(number, &title, "completed", Some("skipped"));
537 }
538 return true;
539 }
540 if report {
541 self.log.step(number);
542 self.log.step_state(number, &title, "in_progress", None);
543 }
544
545 // The step's own env, read with the contexts before it.
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9546 let env = self.step_env(step, &contexts, env_before.clone());
547 let action_name = step.get(ACTION_NAME).map(expr::to_text).or_else(|| id.clone()).unwrap_or_else(|| "__run".into());
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts548 let minutes = step
Cache hits are counted, not only their bytes549 .get("timeout-minutes")
550 .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts551 .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok()));
552 let timeout = step_timeout(minutes);
553 // Every step stops at its own `timeout-minutes`, `run` or `uses`: a
554 // `uses:` step's action, and every process it starts, included.
555 let outer_deadline = self.step_deadline;
556 self.step_deadline = step_deadline(outer_deadline, Instant::now(), minutes);
Cache hits are counted, not only their bytes557 let continue_on_error = step
558 .get("continue-on-error")
559 .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
560 .is_some_and(|v| expr::truthy(&v));
561
562 let (ok, outputs) = if let Some(run) = step.get("run").map(expr::to_text) {
563 let script = match self.with_scope(&contexts, |scope| expr::interpolate(&run, scope)) {
564 Ok(script) => script,
565 Err(problem) => {
566 self.log.line(&format!("##[error]The script does not read: {problem}"));
567 String::new()
568 }
569 };
570 let shell = step
571 .get("shell")
572 .map(expr::to_text)
573 .or_else(|| defaults.get("shell").map(expr::to_text));
574 if frame.action_path.is_some() && shell.is_none() {
575 self.log.line("##[error]A composite action's `run` steps need a `shell`.");
576 (false, BTreeMap::new())
577 } else {
578 let working_directory = step
579 .get("working-directory")
580 .or_else(|| defaults.get("working-directory"))
581 .map(|v| self.with_scope(&contexts, |scope| expr::interpolate(&expr::to_text(v), scope)).unwrap_or_else(|_| expr::to_text(v)));
582 let mut env = env;
583 if let Some(path) = &frame.action_path {
584 env.insert("GITHUB_ACTION_PATH".into(), path.clone());
585 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9586 env.insert("GITHUB_ACTION".into(), action_name.clone());
Cache hits are counted, not only their bytes587 let (ok, outputs, _) = self.run_script(&script, shell.as_deref(), working_directory.as_deref(), &env, timeout);
588 (ok, outputs)
589 }
590 } else if let Some(uses) = step.get("uses").map(expr::to_text) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9591 let with = self.step_with(step, &contexts);
592 self.uses(&uses, &with, &env, frame, &title, &action_name, timeout)
Cache hits are counted, not only their bytes593 } else {
594 self.log.line("##[error]A step needs `run` or `uses`.");
595 (false, BTreeMap::new())
596 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts597 let own_deadline = self.step_deadline.filter(|_| self.step_deadline != outer_deadline);
598 self.step_deadline = outer_deadline;
599 let ok = if own_deadline.is_some_and(|until| Instant::now() >= until) {
600 self.log.line(&format!(
601 "##[error]The step ran past its timeout-minutes ({}) and was stopped.",
602 minutes.unwrap_or_default()
603 ));
604 false
605 } else {
606 ok
607 };
Cache hits are counted, not only their bytes608
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)609 // A step the cancellation stopped ends cancelled, not failed.
610 let stopped = !ok && report::cancelled();
611 let outcome = if ok {
612 "success"
613 } else if stopped {
614 "cancelled"
615 } else {
616 "failure"
617 };
618 let conclusion = if ok || continue_on_error {
619 "success"
620 } else {
621 outcome
622 };
623 if !ok && continue_on_error && !stopped {
Cache hits are counted, not only their bytes624 self.log.line("##[warning]The step failed, and `continue-on-error` lets the job go on.");
625 }
626 if let Some(id) = &id {
627 let outputs: Map<String, Value> = outputs.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
628 frame.steps.insert(id.clone(), json!({ "outputs": outputs, "outcome": outcome, "conclusion": conclusion }));
629 }
630 if conclusion == "failure" {
631 self.failed = true;
632 }
633 if report {
634 self.log.step_state(number, &title, "completed", Some(conclusion));
635 }
636 conclusion == "success"
637 }
638
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9639 /// A step's own `env` over `base`, read with `contexts`.
640 fn step_env(&self, step: &Map<String, Value>, contexts: &Map<String, Value>, mut base: BTreeMap<String, String>) -> BTreeMap<String, String> {
641 if let Some(Value::Object(step_env)) = step.get("env") {
642 for (name, value) in step_env {
643 let value = self.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
644 base.insert(name.clone(), expr::to_text(&value));
645 }
646 }
647 base
648 }
649
650 /// A `uses:` step's `with`, read with `contexts`.
651 fn step_with(&self, step: &Map<String, Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
652 match step.get("with") {
653 Some(Value::Object(with)) => with
654 .iter()
655 .map(|(k, v)| {
656 let value = self.with_scope(contexts, |scope| expr::interpolate_value(v, scope)).unwrap_or(Value::Null);
657 (k.clone(), expr::to_text(&value))
658 })
659 .collect(),
660 _ => BTreeMap::new(),
661 }
662 }
663
Cache hits are counted, not only their bytes664 fn report_steps(&self) {
665 self.log.steps(&self.step_names);
666 }
667}
668
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts669/// How long a step's processes may run: its `timeout-minutes`, or six
670/// hours (the job's own limit still applies).
671fn step_timeout(minutes: Option<f64>) -> Duration {
672 match minutes {
673 Some(minutes) if minutes.is_finite() && minutes > 0.0 => Duration::from_secs_f64(minutes * 60.0),
674 Some(_) => Duration::ZERO,
675 None => Duration::from_secs(6 * 3600),
676 }
677}
678
679/// When a step must end by: its `timeout-minutes` from `now`, or `outer`
680/// (the deadline of the `uses:` step it runs inside, for a composite
681/// action's step), whichever is nearer. None when neither sets one.
682fn step_deadline(outer: Option<Instant>, now: Instant, minutes: Option<f64>) -> Option<Instant> {
683 let own = minutes.map(|minutes| now + step_timeout(Some(minutes)));
684 match (outer, own) {
685 (Some(outer), Some(own)) => Some(outer.min(own)),
686 (outer, own) => outer.or(own),
687 }
688}
689
Cache hits are counted, not only their bytes690/// PowerShell on Windows: `pwsh` (PowerShell 7) if it is installed, as on
691/// GitHub's Windows runners, else Windows PowerShell.
692fn windows_powershell() -> String {
693 if program_exists("pwsh") { "pwsh".into() } else { "powershell".into() }
694}
695
696/// How GitHub runs a PowerShell step: the script, stopping at the first error.
697fn powershell_args() -> Vec<String> {
698 vec!["-NoLogo".into(), "-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), ". '{0}'".into()]
699}
700
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9701/// The name of a shell's program, without its folder or `.exe`.
702fn program_name(program: &str) -> &str {
703 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
704 name.strip_suffix(".exe").or_else(|| name.strip_suffix(".EXE")).unwrap_or(name)
705}
706
707/// The extension GitHub's runner gives a script, by its shell's program:
708/// none for a program it does not know.
709fn script_extension(program: &str) -> &'static str {
710 match program_name(program) {
711 "bash" | "sh" => "sh",
712 "pwsh" | "powershell" => "ps1",
713 "cmd" => "cmd",
714 "python" => "py",
715 _ => "",
716 }
717}
718
719/// A script as GitHub's runner writes it: for PowerShell, stopping at the
720/// first error and exiting with the last program's exit code.
721fn script_contents(program: &str, script: &str) -> String {
722 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
723 match program_name(program) {
724 "pwsh" | "powershell" => format!(
725 "$ErrorActionPreference = 'stop'{newline}{script}{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}"
726 ),
727 _ => script.to_owned(),
728 }
729}
730
731/// Whether `program` is a file in one of `path`'s folders (or, on
732/// Windows, `program.exe`), without running it.
733fn on_path(program: &str, path: &str) -> bool {
734 let names = if cfg!(windows) { vec![program.to_owned(), format!("{program}.exe")] } else { vec![program.to_owned()] };
735 path.split(paths::PATH_SEPARATOR).filter(|dir| !dir.is_empty()).any(|dir| names.iter().any(|name| Path::new(dir).join(name).is_file()))
736}
737
738impl Job {
739 /// `shell: python`'s program: `python`, as on GitHub, where there is
740 /// one, else `python3` (Debian has no `python`).
741 fn python_program(&self) -> String {
742 if let Some(container) = &self.container {
743 return container.python.into();
744 }
745 let path = std::iter::once(self.base_env.get("PATH").cloned().unwrap_or_default());
746 let path: Vec<String> = self.path_prepend.iter().cloned().chain(path).collect();
747 if on_path("python", &path.join(paths::PATH_SEPARATOR)) { "python".into() } else { "python3".into() }
748 }
749}
750
Cache hits are counted, not only their bytes751/// Whether `program` is on `PATH`.
752fn program_exists(program: &str) -> bool {
753 Command::new(program)
754 .arg(if program == "cmd" { "/C" } else { "-Version" })
755 .stdout(std::process::Stdio::null())
756 .stderr(std::process::Stdio::null())
757 .status()
758 .is_ok()
759}
760
761/// An id for files, unique enough within one job.
762fn rand_id() -> u64 {
763 use std::sync::atomic::{AtomicU64, Ordering};
764 static NEXT: AtomicU64 = AtomicU64::new(1);
765 let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
766 nanos ^ (NEXT.fetch_add(1, Ordering::Relaxed) << 48)
767}
768
769fn interpolated_map(job: &Job, value: Option<&Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
770 let mut out = BTreeMap::new();
771 if let Some(Value::Object(map)) = value {
772 for (name, value) in map {
773 let value = job.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
774 out.insert(name.clone(), expr::to_text(&value));
775 }
776 }
777 out
778}
779
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)780/// Whether `::debug::` lines are shown and steps' conditions explained:
781/// `ACTIONS_STEP_DEBUG` as a secret or a variable set to `true`, or a
782/// re-run with debug logging, which sets it and `RUNNER_DEBUG=1` among the
783/// job's variables.
784fn step_debug(contexts: &Map<String, Value>, variables: &Value) -> bool {
785 let named = |name: &str| {
786 contexts
787 .get("secrets")
788 .and_then(|s| s.get(name))
789 .or_else(|| contexts.get("vars").and_then(|v| v.get(name)))
790 .or_else(|| variables.get(name))
791 .is_some_and(|v| expr::to_text(v).eq_ignore_ascii_case("true"))
792 };
793 named("ACTIONS_STEP_DEBUG") || variables.get("RUNNER_DEBUG").is_some_and(|v| expr::to_text(v) == "1")
794}
795
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'796fn setup(spec: Value, api: Api) -> Result<Job> {
797 setup_in(spec, api, paths::under_home(WORKSPACE), paths::under_home(TEMP))
798}
799
800/// [`setup`] with the workspace and temporary folder given: a test's own.
801fn setup_in(mut spec: Value, api: Api, workspace: PathBuf, temp: PathBuf) -> Result<Job> {
Cache hits are counted, not only their bytes802 let masks: Vec<String> = spec["masks"].as_array().map(|m| m.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
Merge branch 'main' into rm-agents803 // With the token the spec came with: the one the job started with is spent.
804 let relay = broker::Relay::new(&api);
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9805 let mut log = Log::new(api, masks);
806 // Variables GitHub sets that a g1t from before them does not send.
807 default_variables(&mut spec);
Cache hits are counted, not only their bytes808 // On a self-hosted runner's own machine, GitHub's layout lives in a
809 // folder of the runner's (paths.rs).
810 for part in ["variables", "github"] {
811 paths::relocate(&mut spec[part]);
812 }
813 paths::relocate(&mut spec["contexts"]["runner"]);
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9814 log.matchers.workspace = workspace.clone();
Cache hits are counted, not only their bytes815 std::fs::create_dir_all(&workspace).context("could not make the workspace")?;
816 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
817 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
818
Merge branch 'main' into rm-agents819 let mut host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
Merge branch 'main' into actions-toolkit-oidc-artifacts820 // Docker of the job's own, on g1t's machines (crate::docker).
821 let docker_hosted = cfg!(target_os = "linux")
822 && std::env::var("G1T_DOCKER").as_deref() == Ok("on")
823 && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted");
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9824 // This process's environment, less what only it should see. `ImageOS`
825 // and `ImageVersion` come with it when the machine's image sets them,
826 // as GitHub's images do; nothing makes them up otherwise.
Cache hits are counted, not only their bytes827 let mut base_env: BTreeMap<String, String> =
828 std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
829 base_env.insert("HOME".into(), std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()));
830 base_env.extend(text_map(spec.get("variables")));
831 base_env.insert("GITHUB_EVENT_PATH".into(), paths::shown(&temp.join("event.json")));
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.832 // As GitHub's runner marks what a job starts, so "Complete job" finds
833 // what its steps left running (process::orphans).
834 let tracking_id = format!("github_{:016x}{:016x}", rand_id(), rand_id());
835 base_env.insert("RUNNER_TRACKING_ID".into(), tracking_id.clone());
Cache hits are counted, not only their bytes836
837 let mut contexts: Map<String, Value> = spec["contexts"].as_object().cloned().unwrap_or_default();
838 contexts.insert("github".into(), spec["github"].clone());
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)839 let debug = step_debug(&contexts, &spec["variables"]);
Cache hits are counted, not only their bytes840
841 // `timeoutMinutes` is how the API spelled it before its bodies were
842 // `snake_case`.
843 let timeout = spec["timeout_minutes"]
844 .as_u64()
845 .or_else(|| spec["timeoutMinutes"].as_u64())
846 .unwrap_or(60);
Merge branch 'main' into rm-agents847
848 // Brokered secrets on a self-hosted runner (broker.rs): the steps'
849 // HTTPS goes through the job's proxy, which passes requests carrying a
850 // stand-in to g1t. A container a step starts does not get these, as it
851 // gets none of this process's own variables.
852 let stand_ins = broker::stand_ins(&spec);
853 let mut broker_problem = None;
854 if !stand_ins.is_empty() {
855 match broker::start(stand_ins, relay, &temp.join("g1t-broker"), &base_env, Duration::from_secs(timeout * 60)) {
856 Ok((_proxy, vars)) => {
857 host_env.extend(broker::names(&vars));
858 base_env.extend(vars);
859 }
860 Err(problem) => broker_problem = Some(format!("{problem:#}")),
861 }
862 }
Cache hits are counted, not only their bytes863 let mut job = Job {
864 log,
865 spec,
866 workspace,
867 temp,
868 base_env,
869 added_env: BTreeMap::new(),
870 path_prepend: Vec::new(),
871 workflow_env: BTreeMap::new(),
872 job_env: BTreeMap::new(),
873 contexts,
874 failed: false,
875 posts: Vec::new(),
876 step_names: Vec::new(),
877 deadline: Instant::now() + Duration::from_secs(timeout * 60),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts878 step_deadline: None,
Cache hits are counted, not only their bytes879 debug,
880 last_node_outputs: BTreeMap::new(),
881 last_node_state: BTreeMap::new(),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9882 pre_state: BTreeMap::new(),
Merge branch 'main' into actions-toolkit-oidc-artifacts883 host_env,
884 docker_hosted,
885 network: None,
886 services: Vec::new(),
887 container: None,
888 job_context: Map::new(),
889 built_actions: containers::Built::new(),
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.890 tracking_id,
Merge branch 'main' into rm-agents891 broker_problem,
Cache hits are counted, not only their bytes892 };
893
894 // The workflow's env reads github, secrets, inputs and vars; the job's
895 // also its matrix, needs and strategy.
896 let mut contexts = job.contexts.clone();
897 contexts.insert("env".into(), json!({}));
898 job.workflow_env = interpolated_map(&job, job.spec["workflow"].get("env"), &contexts);
899 contexts.insert("env".into(), Value::Object(job.workflow_env.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
900 job.job_env = interpolated_map(&job, job.spec["spec"].get("env"), &contexts);
901 Ok(job)
902}
903
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9904/// `RUNNER_WORKSPACE` and `GITHUB_REPOSITORY_OWNER_ID` (with
905/// `github.repository_owner_id`), when the spec does not have them: the
906/// folder the workspace is in, and the owner's id from the event.
907fn default_variables(spec: &mut Value) {
908 if spec["variables"].is_object() && spec["variables"].get("RUNNER_WORKSPACE").is_none() {
909 spec["variables"]["RUNNER_WORKSPACE"] = json!(g1t_actions::events::RUNNER_WORKSPACE);
910 }
911 let owner_id = g1t_actions::events::owner_id(&spec["event"]);
912 if let Some(owner_id) = owner_id.filter(|id| !id.is_empty()) {
913 if spec["variables"].is_object() && spec["variables"].get("GITHUB_REPOSITORY_OWNER_ID").and_then(Value::as_str).is_none_or(str::is_empty) {
914 spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"] = json!(owner_id);
915 }
916 if spec["github"].is_object() && spec["github"].get("repository_owner_id").and_then(Value::as_str).is_none_or(str::is_empty) {
917 spec["github"]["repository_owner_id"] = json!(owner_id);
918 }
919 }
920}
921
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.922/// What "Set up job" says of the runner and the machine it runs on.
923#[derive(Default)]
924pub(crate) struct Machine {
925 pub(crate) version: String,
926 pub(crate) self_hosted: bool,
927 pub(crate) runner_name: String,
928 /// `RUNNER_OS` and `RUNNER_ARCH`.
929 pub(crate) os: String,
930 pub(crate) arch: String,
931 /// `/etc/os-release`, where there is one.
932 pub(crate) os_release: Option<String>,
933 /// `ImageOS` and `ImageVersion`, when the machine's image sets them.
934 pub(crate) image: Option<(String, String)>,
935 pub(crate) cpus: usize,
936 pub(crate) memory_bytes: Option<u64>,
937 /// The job token's permissions, by GitHub's names.
938 pub(crate) permissions: Vec<(String, String)>,
939}
940
941impl Machine {
942 fn here(spec: &Value, base_env: &BTreeMap<String, String>) -> Machine {
943 let variables = &spec["variables"];
944 let text = |name: &str| variables[name].as_str().unwrap_or_default().to_owned();
945 let self_hosted = variables["RUNNER_ENVIRONMENT"] == "self-hosted";
946 let image = match (base_env.get("ImageOS"), base_env.get("ImageVersion")) {
947 (None, None) => None,
948 (os, version) => Some((os.cloned().unwrap_or_default(), version.cloned().unwrap_or_default())),
949 };
950 let permissions = spec
951 .get("permissions")
952 .and_then(Value::as_object)
953 .map(|map| map.iter().map(|(name, access)| (name.clone(), expr::to_text(access))).collect())
954 .unwrap_or_default();
955 Machine {
956 version: env!("CARGO_PKG_VERSION").to_owned(),
957 self_hosted,
958 runner_name: if self_hosted { text("RUNNER_NAME") } else { "g1t".to_owned() },
959 os: text("RUNNER_OS"),
960 arch: text("RUNNER_ARCH"),
961 os_release: std::fs::read_to_string("/etc/os-release").ok(),
962 image,
963 cpus: std::thread::available_parallelism().map_or(1, usize::from),
964 memory_bytes: memory_limit(
965 std::fs::read_to_string("/sys/fs/cgroup/memory.max").ok().as_deref(),
966 std::fs::read_to_string("/proc/meminfo").ok().as_deref(),
967 ),
968 permissions,
969 }
970 }
971}
972
973/// The memory a job has: its cgroup's limit, else the machine's.
974fn memory_limit(cgroup_max: Option<&str>, meminfo: Option<&str>) -> Option<u64> {
975 let total = meminfo.and_then(|info| {
976 info.lines().find_map(|line| line.strip_prefix("MemTotal:")).and_then(|rest| rest.trim().trim_end_matches("kB").trim().parse::<u64>().ok()).map(|kb| kb * 1024)
977 });
978 let limit = cgroup_max.and_then(|max| max.trim().parse::<u64>().ok());
979 match (limit, total) {
980 (Some(limit), Some(total)) => Some(limit.min(total)),
981 (limit, total) => limit.or(total),
982 }
983}
984
985/// `pull-requests` as GitHub's log names it: `PullRequests`.
986fn permission_title(name: &str) -> String {
987 name.split(['-', '_'])
988 .map(|word| {
989 let mut chars = word.chars();
990 chars.next().map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default()
991 })
992 .collect()
993}
994
995/// GitHub's "Set up job", before the actions are downloaded: the runner's
996/// version and name, the operating system, the image, the machine, what
997/// the job's token may do and where its secrets come from.
998pub(crate) fn setup_header(machine: &Machine) -> Vec<String> {
999 let mut lines = vec![format!("Current runner version: '{}'", machine.version), format!("Runner name: '{}'", machine.runner_name)];
1000 lines.push("##[group]Operating System".into());
1001 let release = machine.os_release.as_deref().unwrap_or_default();
1002 let field = |key: &str| {
1003 release.lines().find_map(|line| line.strip_prefix(key).and_then(|rest| rest.strip_prefix('='))).map(|value| value.trim().trim_matches('"').to_owned()).filter(|v| !v.is_empty())
1004 };
1005 match field("NAME") {
1006 Some(name) => {
1007 lines.push(name);
1008 lines.extend(field("VERSION").or_else(|| field("VERSION_ID")));
1009 }
1010 None => lines.push(format!("{} {}", machine.os, machine.arch).trim().to_owned()),
1011 }
1012 lines.push("##[endgroup]".into());
1013 if machine.image.is_some() || !machine.self_hosted {
1014 lines.push("##[group]Runner Image".into());
1015 if let Some((os, version)) = &machine.image {
1016 if !os.is_empty() {
1017 lines.push(format!("Image: {os}"));
1018 }
1019 if !version.is_empty() {
1020 lines.push(format!("Version: {version}"));
1021 }
1022 }
1023 if !machine.self_hosted {
1024 lines.push("Included Software: Node 24, Python 3, Go, Rust".into());
1025 }
1026 lines.push("##[endgroup]".into());
1027 }
1028 lines.push("##[group]Machine".into());
1029 lines.push(if machine.self_hosted { "Self-hosted".into() } else { "Hosted by g1t".to_owned() });
1030 lines.push(format!("Architecture: {}", machine.arch));
1031 lines.push(format!("CPUs: {}", machine.cpus));
1032 if let Some(bytes) = machine.memory_bytes {
1033 let gib = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
1034 let shown = if (gib - gib.round()).abs() < 0.05 { format!("{}", gib.round()) } else { format!("{gib:.1}") };
1035 lines.push(format!("Memory: {shown} GB"));
1036 }
1037 lines.push("##[endgroup]".into());
1038 let granted: Vec<&(String, String)> = machine.permissions.iter().filter(|(_, access)| access != "none" && !access.is_empty()).collect();
1039 if !granted.is_empty() {
1040 lines.push("##[group]G1T_TOKEN Permissions".into());
1041 lines.extend(granted.iter().map(|(name, access)| format!("{}: {access}", permission_title(name))));
1042 lines.push("##[endgroup]".into());
1043 }
1044 lines.push("Secret source: Actions".into());
1045 lines.push("Prepare workflow directory".into());
1046 lines
1047}
1048
Cache hits are counted, not only their bytes1049/// The job's `defaults.run`, its own over the workflow's.
1050fn run_defaults(spec: &Value) -> Map<String, Value> {
1051 let mut defaults = spec["workflow"]["defaults"]["run"].as_object().cloned().unwrap_or_default();
1052 if let Some(own) = spec["spec"]["defaults"]["run"].as_object() {
1053 defaults.extend(own.clone());
1054 }
1055 defaults
1056}
1057
1058fn run_job(job: &mut Job) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91059 let mut steps: Vec<Map<String, Value>> = job.spec["spec"]["steps"]
Cache hits are counted, not only their bytes1060 .as_array()
1061 .map(|steps| steps.iter().filter_map(|s| s.as_object().cloned()).collect())
1062 .unwrap_or_default();
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91063 name_steps(&mut steps);
Cache hits are counted, not only their bytes1064 let defaults = run_defaults(&job.spec);
1065
1066 // Step names as they read before anything has run.
1067 let frame = Frame::default();
1068 let env = job.env_context(&frame);
1069 let contexts = job.contexts_for(&frame, &env);
1070 job.step_names = steps
1071 .iter()
1072 .map(|step| match step.get("name").map(expr::to_text) {
1073 Some(name) => job.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
1074 None => default_title(step),
1075 })
1076 .collect();
1077 job.report_steps();
1078
1079 job.log.step(0);
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1080 // GitHub's "Set up job": the runner, the machine, the token, then the
1081 // actions the job uses.
1082 for line in setup_header(&Machine::here(&job.spec, &job.base_env)) {
1083 job.log.line(&line);
Cache hits are counted, not only their bytes1084 }
1085 if let Some(Value::Object(matrix)) = job.contexts.get("matrix")
1086 && !matrix.is_empty()
1087 {
1088 job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
1089 }
Merge branch 'main' into actions-toolkit-oidc-artifacts1090 if job.docker_hosted {
1091 let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host);
1092 let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty());
1093 let options = crate::docker::engine::Options { registry: registry.zip(token) };
1094 if let Err(problem) = crate::docker::engine::enable(options) {
1095 job.log.line(&format!("##[warning]Docker is not available in this job: {problem}"));
1096 job.docker_hosted = false;
1097 }
1098 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91099 // A self-hosted runner's word on how this job was run (selfhosted/exec.rs).
1100 if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") {
1101 job.log.line(&format!("##[warning]{notice}"));
1102 }
Merge branch 'main' into rm-agents1103 if let Some(problem) = job.broker_problem.take() {
1104 job.log.line(&format!("##[warning]This job's brokered secrets will not be filled in: {problem}"));
1105 }
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'1106 // Actions first, then containers, as GitHub's "Set up job" and
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91107 // "Initialize containers". Each action's `pre` (read from the actions
1108 // just downloaded) is a step of its own, "Pre <step>", before the
1109 // job's first step.
1110 let prepared = job.prepare_actions(&steps);
1111 let pres = if prepared { job.pre_steps(&steps) } else { Vec::new() };
1112 if !pres.is_empty() {
1113 let mut names: Vec<String> = pres.iter().map(|(index, _)| format!("Pre {}", job.step_names[*index])).collect();
1114 names.extend(job.step_names.iter().cloned());
1115 job.step_names = names;
1116 job.report_steps();
1117 }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1118 if prepared {
1119 job.log.line(&format!("Complete job name: {}", job.spec["name"].as_str().unwrap_or_default()));
1120 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91121 let containers_started = prepared && job.start_containers();
Cache hits are counted, not only their bytes1122 job.log.flush();
1123
1124 let mut frame = Frame::default();
Merge branch 'main' into actions-toolkit-oidc-artifacts1125 if !containers_started {
1126 job.failed = true;
1127 for (index, name) in job.step_names.clone().iter().enumerate() {
1128 job.log.step_state(index as u32 + 1, name, "completed", Some("skipped"));
1129 }
1130 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91131 for (index, (_, pre)) in pres.iter().enumerate().filter(|_| containers_started) {
1132 let number = index as u32 + 1;
1133 let name = job.step_names[index].clone();
1134 if report::cancelled() {
1135 report::take_cancel();
1136 }
1137 if !job.pre_runs(pre) {
1138 job.log.step_state(number, &name, "completed", Some("skipped"));
1139 continue;
1140 }
1141 job.log.step(number);
1142 job.log.step_state(number, &name, "in_progress", None);
1143 let ok = job.run_pre(pre);
1144 if !ok {
1145 job.failed = true;
1146 }
1147 let conclusion = if ok {
1148 "success"
1149 } else if report::cancelled() {
1150 "cancelled"
1151 } else {
1152 "failure"
1153 };
1154 job.log.step_state(number, &name, "completed", Some(conclusion));
1155 }
1156 let offset = pres.len() as u32;
Merge branch 'main' into actions-toolkit-oidc-artifacts1157 for (index, step) in steps.iter().enumerate().filter(|_| containers_started) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91158 job.step(&mut frame, step, offset + index as u32 + 1, true, &defaults);
Merge branch 'main' into actions-toolkit-oidc-artifacts1159 job.log_docker_notes();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1160 if job.job_remaining().is_zero() {
Cache hits are counted, not only their bytes1161 job.log.line("##[error]The job ran past its time limit.");
1162 job.failed = true;
1163 break;
1164 }
1165 }
1166
1167 // Post steps, last registered first.
1168 let posts: Vec<Post> = std::mem::take(&mut job.posts);
1169 for post in posts.into_iter().rev() {
1170 let number = job.step_names.len() as u32 + 1;
1171 job.step_names.push(post.name.clone());
1172 job.report_steps();
1173 let contexts = job.contexts_for(&frame, &job.env_context(&frame));
1174 let run_it = job.with_scope(&contexts, |scope| expr::condition(&post.condition, scope)).unwrap_or(true);
1175 if !run_it {
1176 job.log.step_state(number, &post.name, "completed", Some("skipped"));
1177 continue;
1178 }
1179 job.log.step(number);
1180 job.log.step_state(number, &post.name, "in_progress", None);
1181 let ok = match &post.run {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91182 PostRun::Node { action_dir, script, using } => job.run_node(action_dir, script, &post.env, using),
Merge branch 'main' into actions-toolkit-oidc-artifacts1183 PostRun::Docker(run) => job.run_docker(run).0,
Cache hits are counted, not only their bytes1184 };
1185 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
1186 if !ok {
1187 job.failed = true;
1188 }
1189 }
1190
Merge branch 'main' into actions-toolkit-oidc-artifacts1191 // GitHub's "Stop containers": services' logs, and everything removed.
1192 if job.has_containers() {
1193 let number = job.step_names.len() as u32 + 1;
1194 job.step_names.push("Stop containers".into());
1195 job.report_steps();
1196 job.log.step(number);
1197 job.log.step_state(number, "Stop containers", "in_progress", None);
1198 job.stop_containers();
1199 job.log.step_state(number, "Stop containers", "completed", Some("success"));
1200 }
1201
Cache hits are counted, not only their bytes1202 // The job's outputs, read now that every step has run.
1203 let env = job.env_context(&frame);
1204 let contexts = job.contexts_for(&frame, &env);
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1205 // GitHub's "Complete job": the job's outputs set, and whatever its
1206 // steps left running stopped.
1207 let number = job.step_names.len() as u32 + 1;
1208 job.step_names.push("Complete job".into());
1209 job.report_steps();
1210 job.log.step(number);
1211 job.log.step_state(number, "Complete job", "in_progress", None);
Cache hits are counted, not only their bytes1212 let mut outputs = Map::new();
1213 if let Some(Value::Object(declared)) = job.spec["spec"].get("outputs") {
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1214 job.log.line("Evaluate and set job outputs");
Cache hits are counted, not only their bytes1215 for (name, value) in declared {
1216 let value = job.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1217 job.log.line(&format!("Set output '{name}'"));
Cache hits are counted, not only their bytes1218 outputs.insert(name.clone(), Value::String(expr::to_text(&value)));
1219 }
1220 }
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1221 let outputs = job.log.withhold_secrets(&outputs);
1222 job.log.line("Cleaning up orphan processes");
1223 let orphans = process::orphans(&job.tracking_id);
1224 for (pid, name) in &orphans {
1225 job.log.line(&format!("Terminate orphan process: pid ({pid}) ({name})"));
1226 }
1227 process::kill_orphans(&orphans);
1228 job.log.step_state(number, "Complete job", "completed", Some("success"));
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1229 // Cancelled, the job ends so however its cleanup went (and g1t holds
1230 // to that whatever it is told).
1231 let conclusion = if report::cancelled() {
1232 "cancelled"
1233 } else if job.failed {
1234 "failure"
1235 } else {
1236 "success"
1237 };
Cache hits are counted, not only their bytes1238 job.log.done(conclusion, &outputs, None);
1239}
1240
1241pub(crate) fn main() -> i32 {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1242 let mut api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
Cache hits are counted, not only their bytes1243 (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
1244 _ => {
1245 eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
1246 return 2;
1247 }
1248 };
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1249 // A self-hosted runner asks for the token to be spent on the spec
1250 // (selfhosted/exec.rs): there, the token it was given sits where the
1251 // computer can see it (a Windows job's script, a container's
1252 // environment). g1t's own sandboxes keep theirs to report a sandbox
1253 // that stopped, and do not ask.
1254 let rotate = std::env::var(ROTATE_TOKEN).is_ok_and(|v| v == "1");
Merge branch 'main' into rm-agents1255 // Only a self-hosted runner's harness brokers: g1t's own sandboxes
1256 // have their requests filled in as they leave.
1257 let brokers = rotate && broker::possible(|name| std::env::var(name).ok());
1258 let spec = match api.spec(rotate, brokers) {
Cache hits are counted, not only their bytes1259 Ok(spec) => spec,
1260 Err(error) => {
1261 eprintln!("g1t-runner: could not fetch the job: {error:#}");
1262 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not fetch the job: {error}") }));
1263 return 1;
1264 }
1265 };
1266 let reporter = Api {
1267 base: api.base.clone(),
1268 job: api.job.clone(),
1269 token: api.token.clone(),
1270 };
1271 let mut job = match setup(spec, reporter) {
1272 Ok(job) => job,
1273 Err(error) => {
1274 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not set up: {error:#}") }));
1275 return 1;
1276 }
1277 };
1278 run_job(&mut job);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1279 if job.failed || report::cancelled() { 1 } else { 0 }
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284 use serde_json::{Map, Value, json};
1285
Runner: one tests module after merging step debug and step timeouts1286 use super::*;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1287
1288 #[test]
A running job's log is live, as on GitHub. A step's output reaches its run's page within about two seconds: the runner sends what is printed every second, and the page asks every second while a step runs, every three while none does; an answer that fails (a 429, a 5xx, the network) is asked again after a wait that doubles to half a minute, or as long as Retry-After says, where before one such answer stopped the log for good and the step said "No output yet." until the page was reloaded, and a signed-out viewer's run page, refreshing beside its log, ran into the 30-a-minute limit within a minute; the live log now counts as a page, and only log downloads as costly. A hidden tab stops asking and catches up when shown, and the page says when it is reconnecting. The running step opens by itself, its timer counts, its log follows the newest line while its end is on screen and offers Jump to latest once the viewer scrolls up, a step that has printed nothing for a while says "Running · no output for 2m 10s", and the job's header names the step it is on. Colour that tools print (bold, dim, italic, underline, the 16 colours as theme tokens for light and dark, 256 colours and 24-bit colour, text and background) is drawn as printed, other escape codes are dropped and links keep their text, and search reads the plain text and keeps the colours around what it marks. Every line is stamped with the time it was printed, as GitHub's raw logs are, so log.txt and the run's zip have a time at the start of each line, and a clock button beside Search logs shows the times, remembered by the browser. Set up job now gives the runner's version and name, the operating system, the runner image, the machine's architecture, CPUs and memory, the token's permissions under G1T_TOKEN Permissions, the secret source and the actions it downloads; each run step opens with GitHub's folded Run group, the script in cyan, the shell's whole path and the step's env, each uses step with its with inputs (an action's defaults among them) and env; cache and artifact transfers say how far they have got every five seconds; and a last Complete job step sets the job's outputs and stops what the steps left running, found by RUNNER_TRACKING_ID as GitHub's runner finds it. Two batches of a job's log written at once can no longer reach for the same seq and lose one. The Actions guide and the rate limits page say so.1289 fn set_up_job_says_what_runs_the_job_as_github_does() {
1290 let machine = Machine {
1291 version: "0.2.0".into(),
1292 runner_name: "g1t".into(),
1293 os: "Linux".into(),
1294 arch: "X64".into(),
1295 os_release: Some("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"12\"\nVERSION=\"12 (bookworm)\"\n".into()),
1296 image: Some(("ubuntu24".into(), "20261005.1".into())),
1297 cpus: 4,
1298 memory_bytes: Some(12 * 1024 * 1024 * 1024),
1299 permissions: vec![("contents".into(), "read".into()), ("id-token".into(), "none".into()), ("metadata".into(), "read".into()), ("pull-requests".into(), "write".into())],
1300 ..Machine::default()
1301 };
1302 let text = setup_header(&machine).join("\n");
1303 assert_eq!(
1304 text,
1305 "Current runner version: '0.2.0'\nRunner name: 'g1t'\n\
1306 ##[group]Operating System\nDebian GNU/Linux\n12 (bookworm)\n##[endgroup]\n\
1307 ##[group]Runner Image\nImage: ubuntu24\nVersion: 20261005.1\nIncluded Software: Node 24, Python 3, Go, Rust\n##[endgroup]\n\
1308 ##[group]Machine\nHosted by g1t\nArchitecture: X64\nCPUs: 4\nMemory: 12 GB\n##[endgroup]\n\
1309 ##[group]G1T_TOKEN Permissions\nContents: read\nMetadata: read\nPullRequests: write\n##[endgroup]\n\
1310 Secret source: Actions\nPrepare workflow directory"
1311 );
1312 // A self-hosted machine without an image of GitHub's, or an os-release.
1313 let own = Machine { version: "0.2.0".into(), self_hosted: true, runner_name: "build-box".into(), os: "macOS".into(), arch: "ARM64".into(), cpus: 8, ..Machine::default() };
1314 let text = setup_header(&own).join("\n");
1315 assert!(text.contains("Runner name: 'build-box'\n##[group]Operating System\nmacOS ARM64\n##[endgroup]\n##[group]Machine\nSelf-hosted"), "{text}");
1316 assert!(!text.contains("Runner Image") && !text.contains("Memory") && !text.contains("Permissions"), "{text}");
1317 assert_eq!(memory_limit(Some("max\n"), Some("MemTotal: 16384000 kB\n")), Some(16_384_000 * 1024));
1318 assert_eq!(memory_limit(Some("4294967296\n"), Some("MemTotal: 16384000 kB\n")), Some(4_294_967_296));
1319 assert_eq!(memory_limit(None, None), None);
1320 assert_eq!(permission_title("security-events"), "SecurityEvents");
1321 }
1322
1323 #[test]
1324 fn a_run_step_opens_with_its_script_shell_and_variables_folded() {
1325 let env: BTreeMap<String, String> = [("CARGO_TERM_COLOR", "always"), ("GITHUB_ACTION", "__run"), ("KEY", "line one\nline two")]
1326 .into_iter()
1327 .map(|(k, v)| (k.to_owned(), v.to_owned()))
1328 .collect();
1329 let lines = script_header("\n node build.mjs --all\necho done\n", "/usr/bin/bash -e {0}", &env);
1330 assert_eq!(
1331 lines,
1332 [
1333 "##[group]Run node build.mjs --all",
1334 "\u{1b}[36;1m node build.mjs --all\u{1b}[0m",
1335 "\u{1b}[36;1mecho done\u{1b}[0m",
1336 "shell: /usr/bin/bash -e {0}",
1337 "env:",
1338 " CARGO_TERM_COLOR: always",
1339 " KEY: line one",
1340 " line two",
1341 "##[endgroup]",
1342 ]
1343 );
1344 // No variables: no `env:`.
1345 assert!(!script_header("make", "sh -e {0}", &BTreeMap::new()).contains(&"env:".to_owned()));
1346 let with: BTreeMap<String, String> = [("path", "~/.cargo\ntarget\n"), ("key", "k")].into_iter().map(|(k, v)| (k.to_owned(), v.to_owned())).collect();
1347 assert_eq!(
1348 uses_header("actions/cache@v4", &with, &BTreeMap::new()),
1349 ["##[group]Run actions/cache@v4", "with:", " key: k", " path: ~/.cargo", " target", "##[endgroup]"]
1350 );
1351 }
1352
1353 #[test]
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1354 fn debug_logging_comes_from_a_secret_a_variable_or_a_debug_rerun() {
1355 let contexts = |value: Value| -> Map<String, Value> { serde_json::from_value(value).unwrap() };
1356 let none = json!({});
1357 assert!(!step_debug(&contexts(json!({ "secrets": {}, "vars": {} })), &none));
1358 assert!(step_debug(&contexts(json!({ "secrets": { "ACTIONS_STEP_DEBUG": "true" } })), &none));
1359 assert!(step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "TRUE" } })), &none));
1360 assert!(!step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "false" } })), &none));
1361 // A re-run with debug logging sets these among the job's variables.
1362 assert!(step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "1" })));
1363 assert!(step_debug(&contexts(json!({})), &json!({ "ACTIONS_STEP_DEBUG": "true" })));
1364 assert!(!step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "0" })));
1365 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1366
1367 #[test]
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91368 fn scripts_get_githubs_extension_for_their_shell() {
1369 assert_eq!(script_extension("bash"), "sh");
1370 assert_eq!(script_extension("/usr/bin/sh"), "sh");
1371 assert_eq!(script_extension("pwsh"), "ps1");
1372 assert_eq!(script_extension("C:\\Windows\\powershell.exe"), "ps1");
1373 assert_eq!(script_extension("cmd"), "cmd");
1374 assert_eq!(script_extension("python"), "py");
1375 // A program GitHub does not know: no extension.
1376 assert_eq!(script_extension("perl"), "");
1377 assert_eq!(script_extension("python3"), "");
1378 }
1379
1380 #[test]
1381 fn powershell_scripts_stop_at_errors_and_keep_the_exit_code() {
1382 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
1383 assert_eq!(
1384 script_contents("pwsh", "Write-Output hi"),
1385 format!("$ErrorActionPreference = 'stop'{newline}Write-Output hi{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}")
1386 );
1387 assert!(script_contents("powershell.exe", "x").starts_with("$ErrorActionPreference = 'stop'"));
1388 assert_eq!(script_contents("bash", "echo hi"), "echo hi");
1389 }
1390
1391 #[test]
1392 fn programs_are_found_on_path_without_running_them() {
1393 let dir = std::env::temp_dir().join(format!("g1t-on-path-{}", std::process::id()));
1394 std::fs::create_dir_all(&dir).unwrap();
1395 std::fs::write(dir.join("python"), "").unwrap();
1396 let path = format!("/nowhere{}{}", paths::PATH_SEPARATOR, dir.display());
1397 assert!(on_path("python", &path));
1398 assert!(!on_path("python9", &path));
1399 assert!(!on_path("python", ""));
1400 let _ = std::fs::remove_dir_all(&dir);
1401 }
1402
1403 #[test]
1404 fn steps_are_named_for_github_action_as_github_names_them() {
1405 let mut steps: Vec<Map<String, Value>> = serde_json::from_value(json!([
1406 { "run": "a" },
1407 { "run": "b" },
1408 { "id": "build", "run": "c" },
1409 { "uses": "actions/setup-node@v4" },
1410 { "uses": "actions/cache/restore@v4" },
1411 { "uses": "actions/setup-node@v4" },
1412 { "uses": "./.github/actions/x" },
1413 { "run": "d" },
1414 { "id": "__run_4", "run": "e" },
1415 { "run": "f" },
1416 ]))
1417 .unwrap();
1418 name_steps(&mut steps);
1419 let names: Vec<String> = steps.iter().map(|s| expr::to_text(&s[ACTION_NAME])).collect();
1420 assert_eq!(
1421 names,
1422 ["__run", "__run_2", "build", "__actions_setup-node", "__actions_cache", "__actions_setup-node_2", "__self", "__run_3", "__run_4", "__run_5"]
1423 );
1424 }
1425
1426 #[test]
1427 fn variables_a_spec_lacks_are_filled_in() {
1428 let mut spec = json!({
1429 "variables": { "CI": "true" },
1430 "github": { "repository_owner_id": "" },
1431 "event": { "repository": { "owner": { "id": 7 } } },
1432 });
1433 default_variables(&mut spec);
1434 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/home/runner/work");
1435 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "7");
1436 assert_eq!(spec["github"]["repository_owner_id"], "7");
1437 // What g1t sent stands.
1438 let mut spec = json!({ "variables": { "RUNNER_WORKSPACE": "/w", "GITHUB_REPOSITORY_OWNER_ID": "9" }, "github": {}, "event": {} });
1439 default_variables(&mut spec);
1440 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/w");
1441 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "9");
1442 }
1443
1444 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1445 fn a_steps_timeout_is_its_own_or_the_nearer_one_around_it() {
1446 let now = Instant::now();
1447 // No timeout-minutes anywhere: only the job's deadline applies.
1448 assert_eq!(step_deadline(None, now, None), None);
1449 // A step's own, in minutes, fractions included.
1450 assert_eq!(step_deadline(None, now, Some(1.5)), Some(now + Duration::from_secs(90)));
1451 // A composite action's step inside a `uses:` step with its own:
1452 // the nearer of the two.
1453 let outer = now + Duration::from_secs(60);
1454 assert_eq!(step_deadline(Some(outer), now, Some(10.0)), Some(outer));
1455 assert_eq!(step_deadline(Some(outer), now, Some(0.5)), Some(now + Duration::from_secs(30)));
1456 assert_eq!(step_deadline(Some(outer), now, None), Some(outer));
1457 // Zero or less is a time already up.
1458 assert_eq!(step_deadline(None, now, Some(0.0)), Some(now));
1459 assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600));
1460 assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO);
1461 }
Merge the OIDC docs: deploying to AWS, Google Cloud or Azure with a job's short-lived identity token now says which of the cloud's hosts to allow under Workflow-only domains, for the deploying workflow and environment, since a job on g1t's machines reaches only the hosts its project allows; and tests for the audience a job asks for and for the token variables reaching each step.1462
1463 /// `core.getIDToken()` reads the OIDC variables from the step's own
1464 /// environment, so they have to reach every step's process as the API
1465 /// put them in the spec: the URL untouched by path relocation, and the
1466 /// token not dropped with the runner's own credentials.
1467 #[test]
1468 fn a_steps_process_gets_the_oidc_request_variables() {
1469 let dir = std::env::temp_dir().join(format!("g1t-oidc-env-{}-{}", std::process::id(), rand_id()));
1470 let url = "https://api.g1t.sh/actions/oidc/token?api-version=2.0";
1471 let spec = json!({
1472 "variables": {
1473 "ACTIONS_RUNTIME_TOKEN": "h.p.s",
1474 "ACTIONS_ID_TOKEN_REQUEST_URL": url,
1475 "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "h.p.s",
1476 },
1477 "github": {},
1478 "event": {},
1479 "masks": ["h.p.s"],
1480 });
1481 let job = setup_in(spec, Api::offline(), dir.join("work"), dir.join("temp")).unwrap();
1482 let files = StepFiles::new(&job.temp, "oidc").unwrap();
1483 let env = job.process_env(&BTreeMap::new(), &files);
1484 assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_URL").map(String::as_str), Some(url));
1485 assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map(String::as_str), Some("h.p.s"));
1486 assert_eq!(env.get("ACTIONS_RUNTIME_TOKEN").map(String::as_str), Some("h.p.s"));
1487 let _ = std::fs::remove_dir_all(&dir);
1488 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1489}

This file's history is long; its oldest lines are credited to the oldest commit read.