Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,423 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.

g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1//! 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 actions-toolkit-oidc-artifacts12mod containers;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent13mod files;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R214mod glob;
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r915mod matchers;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent16mod paths;
17mod process;
18mod report;
19mod uses;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r920/// Also unpacks the plugins a run gets from Customize (customize.rs).
21pub(crate) mod zip;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent22
Merge branch 'main' into actions-toolkit-oidc-artifacts23use std::collections::{BTreeMap, BTreeSet};
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::time::{Duration, Instant};
27
28use anyhow::{Context, Result};
29use g1t_actions::events::WORKSPACE;
30use g1t_actions::expr::{self, Scope, Status};
31use serde_json::{Map, Value, json};
32
33use files::StepFiles;
34use process::{Commands, Ended};
35use report::{Api, Log};
36
37const TEMP: &str = "/home/runner/_temp";
38
39/// Who is running steps: the job itself, or a composite action inside it.
40#[derive(Clone, Default)]
41pub(crate) struct Frame {
42 /// The `steps` context.
43 pub(crate) steps: Map<String, Value>,
44 /// A composite action's `inputs`, in place of the workflow's.
45 pub(crate) inputs: Option<Value>,
46 /// A composite action's folder, for `github.action_path`.
47 pub(crate) action_path: Option<String>,
48 /// Variables a composite action's caller set for its steps.
49 pub(crate) env: BTreeMap<String, String>,
50}
51
52/// A step run when the job's steps are done: an action's `post`, or
53/// saving the cache.
54pub(crate) struct Post {
55 pub(crate) name: String,
56 pub(crate) condition: String,
57 pub(crate) env: BTreeMap<String, String>,
58 pub(crate) run: PostRun,
59}
60
61pub(crate) enum PostRun {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r962 Node { action_dir: PathBuf, script: String, using: String },
Merge branch 'main' into actions-toolkit-oidc-artifacts63 /// A Docker action's `post-entrypoint`.
64 Docker(containers::DockerRun),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent65}
66
67pub(crate) struct Job {
68 pub(crate) log: Log,
69 pub(crate) spec: Value,
70 pub(crate) workspace: PathBuf,
71 pub(crate) temp: PathBuf,
72 /// This process's own variables, less its credentials, and GitHub's.
73 base_env: BTreeMap<String, String>,
74 /// Written to `GITHUB_ENV` by earlier steps.
75 added_env: BTreeMap<String, String>,
76 /// Written to `GITHUB_PATH` by earlier steps, newest first.
77 path_prepend: Vec<String>,
78 workflow_env: BTreeMap<String, String>,
79 job_env: BTreeMap<String, String>,
80 /// github, vars, secrets, inputs, matrix, needs, strategy, runner.
81 pub(crate) contexts: Map<String, Value>,
82 pub(crate) failed: bool,
83 pub(crate) posts: Vec<Post>,
84 step_names: Vec<String>,
85 deadline: Instant,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts86 /// The running step's `timeout-minutes`, as an instant: the nearest of
87 /// the step's own and those of the steps around it (a composite
88 /// action's step inside a `uses:` step). Every process a step starts
89 /// stops by it, as every one stops by the job's deadline.
90 step_deadline: Option<Instant>,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent91 debug: bool,
92 /// What the last Node process left, for the step that ran it.
93 pub(crate) last_node_outputs: BTreeMap<String, String>,
94 pub(crate) last_node_state: BTreeMap<String, String>,
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r995 /// What each action's `pre` saved, by its step's `GITHUB_ACTION`, for
96 /// the step's main and post.
97 pub(crate) pre_state: BTreeMap<String, BTreeMap<String, String>>,
Merge branch 'main' into actions-toolkit-oidc-artifacts98 /// The names of the sandbox's own variables, which a container does
99 /// not get.
100 host_env: BTreeSet<String>,
101 /// Whether this job has a Docker Engine of its own (g1t's machines).
102 pub(crate) docker_hosted: bool,
103 /// The job's network, once its containers have one.
104 pub(crate) network: Option<String>,
105 /// `services:`, by their names, and their containers' names.
106 pub(crate) services: Vec<(String, String)>,
107 /// `container:`, once started.
108 pub(crate) container: Option<containers::JobContainer>,
109 /// The `job` context's `container` and `services`.
110 pub(crate) job_context: Map<String, Value>,
111 /// Docker actions' images built in this job.
112 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.113 /// `RUNNER_TRACKING_ID`, which every process a step starts inherits.
114 tracking_id: String,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent115}
116
117fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
118 value
119 .and_then(Value::as_object)
120 .map(|map| map.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect())
121 .unwrap_or_default()
122}
123
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9124/// Where `name_steps` puts a step's `GITHUB_ACTION`.
125const ACTION_NAME: &str = "__g1t_action";
126
127/// Gives each step its `GITHUB_ACTION`, as GitHub's runner names them: its
128/// `id`, else `__run` for a script and `__owner_repo` for an action
129/// (`__self` for one in the repository), the second of a name `_2`, the
130/// third `_3`.
131pub(crate) fn name_steps(steps: &mut [Map<String, Value>]) {
132 let mut taken: BTreeSet<String> = steps.iter().filter_map(|step| step.get("id").map(expr::to_text)).collect();
133 for step in steps.iter_mut() {
134 let name = match step.get("id").map(expr::to_text) {
135 Some(id) => id,
136 None => {
137 let base = match step.get("uses").map(expr::to_text) {
138 None => "run".to_owned(),
139 Some(uses) if uses.starts_with("./") => "self".to_owned(),
140 Some(uses) => {
141 let name = uses.split('@').next().unwrap_or_default();
142 let name = name.strip_prefix("docker://").unwrap_or(name);
143 let repository: Vec<&str> = name.splitn(3, '/').take(2).collect();
144 repository.join("_").chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect()
145 }
146 };
147 let base = format!("__{base}");
148 let mut name = base.clone();
149 let mut n = 2;
150 while taken.contains(&name) {
151 name = format!("{base}_{n}");
152 n += 1;
153 }
154 taken.insert(name.clone());
155 name
156 }
157 };
158 step.insert(ACTION_NAME.into(), Value::String(name));
159 }
160}
161
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent162/// A step's title when it has no name, as GitHub shows it.
163fn default_title(step: &Map<String, Value>) -> String {
164 if let Some(uses) = step.get("uses").and_then(Value::as_str) {
165 return format!("Run {uses}");
166 }
167 let run = step.get("run").map(expr::to_text).unwrap_or_default();
168 let first = run.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
169 format!("Run {first}")
170}
171
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.172/// What GitHub's runner sets on a step itself, which its `env:` list leaves out.
173const RUNNER_SET: [&str; 2] = ["GITHUB_ACTION", "GITHUB_ACTION_PATH"];
174
175/// The step's variables as GitHub lists them under `env:`: each name and
176/// its value (masked later, as every line is), a value of several lines
177/// with the rest indented beneath it.
178fn env_lines(env: &BTreeMap<String, String>) -> Vec<String> {
179 let listed: Vec<(&String, &String)> = env.iter().filter(|(name, _)| !RUNNER_SET.contains(&name.as_str())).collect();
180 if listed.is_empty() {
181 return Vec::new();
182 }
183 let mut lines = vec!["env:".to_owned()];
184 for (name, value) in listed {
185 let mut parts = value.split('\n');
186 lines.push(format!(" {name}: {}", parts.next().unwrap_or_default()));
187 lines.extend(parts.map(|part| format!(" {part}")));
188 }
189 lines
190}
191
192/// How GitHub's runner opens a `run` step, in a group that starts folded:
193/// `Run` and the script's first line, the whole script (in cyan, as
194/// GitHub draws it), the shell it runs in and the step's variables.
195pub(crate) fn script_header(script: &str, shell: &str, env: &BTreeMap<String, String>) -> Vec<String> {
196 let first = script.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
197 let mut lines = vec![format!("##[group]Run {first}")];
198 lines.extend(script.trim_matches(['\n', '\r']).lines().map(|line| format!("\u{1b}[36;1m{line}\u{1b}[0m")));
199 lines.push(format!("shell: {shell}"));
200 lines.extend(env_lines(env));
201 lines.push("##[endgroup]".into());
202 lines
203}
204
205/// How GitHub's runner opens a `uses:` step: `Run` and the action, its
206/// inputs under `with:` and the step's variables under `env:`.
207pub(crate) fn uses_header(uses: &str, with: &BTreeMap<String, String>, env: &BTreeMap<String, String>) -> Vec<String> {
208 let mut lines = vec![format!("##[group]Run {uses}")];
209 if !with.is_empty() {
210 lines.push("with:".into());
211 for (name, value) in with {
212 let mut parts = value.trim_end_matches('\n').split('\n');
213 lines.push(format!(" {name}: {}", parts.next().unwrap_or_default()));
214 lines.extend(parts.map(|part| format!(" {part}")));
215 }
216 }
217 lines.extend(env_lines(env));
218 lines.push("##[endgroup]".into());
219 lines
220}
221
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent222impl 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.223 /// A shell's whole path, found on the step's `PATH` as GitHub's runner
224 /// shows it (`/usr/bin/bash`), or its name when it is not there.
225 fn program_path(&self, program: &str) -> String {
226 if Path::new(program).is_absolute() {
227 return program.to_owned();
228 }
229 let path = self.base_env.get("PATH").cloned().unwrap_or_default();
230 let dirs = self.path_prepend.iter().map(String::as_str).chain(path.split(paths::PATH_SEPARATOR));
231 let names: Vec<String> = if cfg!(windows) { vec![format!("{program}.exe"), program.to_owned()] } else { vec![program.to_owned()] };
232 dirs.filter(|dir| !dir.is_empty())
233 .flat_map(|dir| names.iter().map(move |name| Path::new(dir).join(name)))
234 .find(|candidate| candidate.is_file())
235 .map_or_else(|| program.to_owned(), |found| found.display().to_string())
236 }
237
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent238 pub(crate) fn base_env_value(&self, name: &str) -> Option<String> {
239 self.base_env.get(name).cloned()
240 }
241
Merge branch 'main' into actions-toolkit-oidc-artifacts242 /// What earlier steps added to `PATH`, newest first.
243 pub(crate) fn path_prepend_entries(&self) -> &[String] {
244 &self.path_prepend
245 }
246
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)247 /// How the job is going, for `success()`, `failure()`, `cancelled()`
248 /// and `always()`: once the run is cancelled, only steps that ask for
249 /// `always()` or `cancelled()` run.
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent250 fn status(&self) -> Status {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)251 if report::cancelled() {
252 Status::Cancelled
253 } else if self.failed {
254 Status::Failure
255 } else {
256 Status::Success
257 }
258 }
259
260 /// `job.status`.
261 fn status_word(&self) -> &'static str {
262 if report::cancelled() {
263 "cancelled"
264 } else if self.failed {
265 "failure"
266 } else {
267 "success"
268 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent269 }
270
271 /// The contexts an expression in a step can use.
272 pub(crate) fn contexts_for(&self, frame: &Frame, env: &BTreeMap<String, String>) -> Map<String, Value> {
273 let mut contexts = self.contexts.clone();
274 contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
275 contexts.insert("steps".into(), Value::Object(frame.steps.clone()));
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)276 contexts.insert("job".into(), containers::job_context(self.status_word(), &self.job_context));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent277 if let Some(inputs) = &frame.inputs {
278 contexts.insert("inputs".into(), inputs.clone());
279 }
280 if let Some(path) = &frame.action_path
281 && let Some(github) = contexts.get_mut("github")
282 {
283 github["action_path"] = Value::String(path.clone());
284 }
285 contexts
286 }
287
288 /// Runs `f` with a scope over these contexts.
289 pub(crate) fn with_scope<T>(&self, contexts: &Map<String, Value>, f: impl FnOnce(&Scope) -> T) -> T {
290 let workspace = self.workspace.clone();
291 let hash = move |patterns: &[String]| files::hash_files(&workspace, patterns);
292 let scope = Scope {
293 contexts,
294 status: self.status(),
295 hash_files: Some(&hash),
296 };
297 f(&scope)
298 }
299
300 /// The `env` context for a step: the workflow's, the job's, what earlier
301 /// steps wrote to `GITHUB_ENV`, and the frame's.
Merge branch 'main' into actions-toolkit-oidc-artifacts302 pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent303 let mut env = self.added_env.clone();
304 env.extend(self.workflow_env.clone());
305 env.extend(self.job_env.clone());
306 env.extend(frame.env.clone());
307 env
308 }
309
310 /// What a process for a step is given.
311 pub(crate) fn process_env(&self, env: &BTreeMap<String, String>, files: &StepFiles) -> BTreeMap<String, String> {
312 let mut out = self.base_env.clone();
313 out.extend(env.clone());
314 for (name, value) in files.variables() {
315 out.insert(name.to_owned(), value);
316 }
317 if !self.path_prepend.is_empty() {
318 let current = out.get("PATH").cloned().unwrap_or_default();
319 let separator = paths::PATH_SEPARATOR;
320 out.insert("PATH".into(), format!("{}{separator}{current}", self.path_prepend.join(separator)));
321 }
322 out
323 }
324
325 /// Takes in what a step wrote to its files. Returns its outputs.
326 pub(crate) fn absorb(&mut self, files: &StepFiles, commands: &Commands) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
327 let mut outputs: BTreeMap<String, String> = commands.outputs.clone();
328 match files::key_values(&StepFiles::read(&files.output)) {
329 Ok(values) => outputs.extend(values),
330 Err(problem) => self.log.line(&format!("##[error]$GITHUB_OUTPUT: {problem}")),
331 }
332 match files::key_values(&StepFiles::read(&files.env)) {
333 Ok(values) => {
334 for (name, value) in values {
335 if name.starts_with("GITHUB_") || name == "NODE_OPTIONS" {
336 self.log.line(&format!("##[warning]{name} cannot be set through $GITHUB_ENV."));
337 continue;
338 }
339 self.added_env.insert(name, value);
340 }
341 }
342 Err(problem) => self.log.line(&format!("##[error]$GITHUB_ENV: {problem}")),
343 }
344 for line in StepFiles::read(&files.path).lines().map(str::trim).filter(|l| !l.is_empty()) {
345 self.path_prepend.insert(0, line.to_owned());
346 }
347 let mut state = commands.state.clone();
348 if let Ok(values) = files::key_values(&StepFiles::read(&files.state)) {
349 state.extend(values);
350 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)351 // The step's job summary, for the run's page (report.rs).
352 self.log.summary(&StepFiles::read(&files.summary));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent353 (outputs, state)
354 }
355
356 pub(crate) fn remaining_time(&self) -> Duration {
357 self.remaining()
358 }
359
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts360 /// What is left for the running step: its `timeout-minutes`, within
361 /// the job's.
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent362 fn remaining(&self) -> Duration {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts363 let until = self.step_deadline.map_or(self.deadline, |step| step.min(self.deadline));
364 until.saturating_duration_since(Instant::now())
365 }
366
367 /// What is left of the job's own time, whatever the step's.
368 fn job_remaining(&self) -> Duration {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent369 self.deadline.saturating_duration_since(Instant::now())
370 }
371
372 /// Runs a shell script for a `run` step.
373 pub(crate) fn run_script(
374 &mut self,
375 script: &str,
376 shell: Option<&str>,
377 working_directory: Option<&str>,
378 env: &BTreeMap<String, String>,
379 timeout: Duration,
380 ) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) {
381 crate::abuse::touch();
382 // Mining is never a workflow's job (abuse.rs).
383 if let Some(miner) = crate::abuse::miner_in(script) {
384 self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run."));
385 return (false, BTreeMap::new(), BTreeMap::new());
386 }
387 let id = format!("{:x}", rand_id());
388 let shell = shell.map(str::trim).filter(|s| !s.is_empty());
389 let (program, args, extension): (String, Vec<String>, &str) = match shell {
390 // A self-hosted Windows runner, as GitHub's: PowerShell.
391 None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"),
Merge branch 'main' into actions-toolkit-oidc-artifacts392 // A job container without bash, as GitHub's runner does.
393 None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent394 None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
395 Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
396 Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9397 Some("python") => (self.python_program(), vec!["{0}".into()], "py"),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent398 Some("pwsh") if cfg!(windows) || program_exists("pwsh") => ("pwsh".into(), powershell_args(), "ps1"),
399 Some("powershell") if cfg!(windows) => ("powershell".into(), powershell_args(), "ps1"),
400 Some("cmd") if cfg!(windows) => (
401 "cmd".into(),
402 vec!["/D".into(), "/E:ON".into(), "/V:OFF".into(), "/S".into(), "/C".into(), "CALL \"{0}\"".into()],
403 "cmd",
404 ),
405 Some(other @ ("pwsh" | "powershell" | "cmd")) => {
406 self.log.line(&format!(
407 "##[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]`."
408 ));
409 return (false, BTreeMap::new(), BTreeMap::new());
410 }
411 Some(custom) => {
412 let mut parts = custom.split_whitespace().map(str::to_owned);
413 let program = parts.next().unwrap_or_default();
414 let mut args: Vec<String> = parts.collect();
415 if !args.iter().any(|a| a.contains("{0}")) {
416 args.push("{0}".into());
417 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9418 let extension = script_extension(&program);
419 (program, args, extension)
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent420 }
421 };
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9422 let script_path = self.temp.join(if extension.is_empty() { id.clone() } else { format!("{id}.{extension}") });
423 if let Err(error) = std::fs::write(&script_path, script_contents(&program, script)) {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent424 self.log.line(&format!("##[error]Could not write the script: {error}"));
425 return (false, BTreeMap::new(), BTreeMap::new());
426 }
427 let files = match StepFiles::new(&self.temp, &id) {
428 Ok(files) => files,
429 Err(error) => {
430 self.log.line(&format!("##[error]Could not make the step's files: {error}"));
431 return (false, BTreeMap::new(), BTreeMap::new());
432 }
433 };
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.434 // GitHub's opening of a `run` step, folded: the script, the shell
435 // it runs in (the program's whole path) and the step's variables.
436 let shown_program = if self.container.is_some() { program.clone() } else { self.program_path(&program) };
437 for line in script_header(script, &format!("{shown_program} {}", args.join(" ")), env) {
438 self.log.line(&line);
439 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent440 let args: Vec<String> = args.iter().map(|a| a.replace("{0}", &paths::shown(&script_path))).collect();
441 let dir = match working_directory {
442 Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
443 Some(dir) => self.workspace.join(dir),
444 None => self.workspace.clone(),
445 };
Merge branch 'main' into actions-toolkit-oidc-artifacts446 let full = self.process_env(env, &files);
447 let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| {
448 let inside = self.container_env(env, full.clone(), &image_path);
449 self.in_container(&program, &args, &dir, inside)
450 });
451 let command = match in_container {
452 Some(command) => command,
453 None => {
454 let mut command = Command::new(&program);
455 command.args(&args).current_dir(&dir).env_clear().envs(full);
456 command
457 }
458 };
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent459 let mut commands = Commands {
460 debug: self.debug,
461 ..Commands::default()
462 };
463 let ended = process::run(command, timeout.min(self.remaining()), &mut self.log, &mut commands);
464 let ok = match ended {
465 Ok(Ended::Exited(0)) => true,
466 Ok(Ended::Exited(code)) => {
467 self.log.line(&format!("##[error]Process completed with exit code {code}."));
468 false
469 }
470 Ok(Ended::TimedOut) => {
471 self.log.line("##[error]The step ran past its time limit and was stopped.");
472 false
473 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)474 Ok(Ended::Cancelled) => false,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent475 Err(error) => {
476 self.log.line(&format!("##[error]{program} could not be started: {error}"));
477 false
478 }
479 };
480 let (outputs, state) = self.absorb(&files, &commands);
481 (ok, outputs, state)
482 }
483
484 /// Runs one step of a frame. Returns whether it succeeded (its
485 /// conclusion). `number` is the step the log belongs to.
486 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)487 // A cancellation heard during an earlier step (which it stopped) or
488 // since: from here on, only cleanup steps run, and none is stopped
489 // for it again.
490 if report::cancelled() {
491 report::take_cancel();
492 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent493 let env_before = self.env_context(frame);
494 let contexts = self.contexts_for(frame, &env_before);
495 let title = match step.get("name").map(expr::to_text) {
496 Some(name) => self.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
497 None => default_title(step),
498 };
499 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)500 let read = self.with_scope(&contexts, |scope| expr::condition(&condition, scope));
501 // Debug logging: how the step's `if` read, as GitHub's runner says.
502 if self.debug {
503 let shown = if condition.trim().is_empty() { "success()" } else { condition.trim() };
504 self.log.line(&format!("##[debug]Evaluating condition for step: '{title}'"));
505 self.log.line(&format!("##[debug]Evaluating: {shown}"));
506 match &read {
507 Ok(result) => self.log.line(&format!("##[debug]Result: {result}")),
508 Err(problem) => self.log.line(&format!("##[debug]Failed: {problem}")),
509 }
510 }
511 let run_it = match read {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent512 Ok(run_it) => run_it,
513 Err(problem) => {
514 self.log.line(&format!("##[error]The step's `if` does not read: {problem}"));
515 self.failed = true;
516 if report {
517 self.log.step_state(number, &title, "completed", Some("failure"));
518 }
519 return false;
520 }
521 };
522 let id = step.get("id").map(expr::to_text);
523 if !run_it {
524 if let Some(id) = &id {
525 frame.steps.insert(id.clone(), json!({ "outputs": {}, "outcome": "skipped", "conclusion": "skipped" }));
526 }
527 if report {
528 self.log.step_state(number, &title, "completed", Some("skipped"));
529 }
530 return true;
531 }
532 if report {
533 self.log.step(number);
534 self.log.step_state(number, &title, "in_progress", None);
535 }
536
537 // The step's own env, read with the contexts before it.
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9538 let env = self.step_env(step, &contexts, env_before.clone());
539 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 timeouts540 let minutes = step
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent541 .get("timeout-minutes")
542 .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 timeouts543 .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok()));
544 let timeout = step_timeout(minutes);
545 // Every step stops at its own `timeout-minutes`, `run` or `uses`: a
546 // `uses:` step's action, and every process it starts, included.
547 let outer_deadline = self.step_deadline;
548 self.step_deadline = step_deadline(outer_deadline, Instant::now(), minutes);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent549 let continue_on_error = step
550 .get("continue-on-error")
551 .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
552 .is_some_and(|v| expr::truthy(&v));
553
554 let (ok, outputs) = if let Some(run) = step.get("run").map(expr::to_text) {
555 let script = match self.with_scope(&contexts, |scope| expr::interpolate(&run, scope)) {
556 Ok(script) => script,
557 Err(problem) => {
558 self.log.line(&format!("##[error]The script does not read: {problem}"));
559 String::new()
560 }
561 };
562 let shell = step
563 .get("shell")
564 .map(expr::to_text)
565 .or_else(|| defaults.get("shell").map(expr::to_text));
566 if frame.action_path.is_some() && shell.is_none() {
567 self.log.line("##[error]A composite action's `run` steps need a `shell`.");
568 (false, BTreeMap::new())
569 } else {
570 let working_directory = step
571 .get("working-directory")
572 .or_else(|| defaults.get("working-directory"))
573 .map(|v| self.with_scope(&contexts, |scope| expr::interpolate(&expr::to_text(v), scope)).unwrap_or_else(|_| expr::to_text(v)));
574 let mut env = env;
575 if let Some(path) = &frame.action_path {
576 env.insert("GITHUB_ACTION_PATH".into(), path.clone());
577 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9578 env.insert("GITHUB_ACTION".into(), action_name.clone());
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent579 let (ok, outputs, _) = self.run_script(&script, shell.as_deref(), working_directory.as_deref(), &env, timeout);
580 (ok, outputs)
581 }
582 } else if let Some(uses) = step.get("uses").map(expr::to_text) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9583 let with = self.step_with(step, &contexts);
584 self.uses(&uses, &with, &env, frame, &title, &action_name, timeout)
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent585 } else {
586 self.log.line("##[error]A step needs `run` or `uses`.");
587 (false, BTreeMap::new())
588 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts589 let own_deadline = self.step_deadline.filter(|_| self.step_deadline != outer_deadline);
590 self.step_deadline = outer_deadline;
591 let ok = if own_deadline.is_some_and(|until| Instant::now() >= until) {
592 self.log.line(&format!(
593 "##[error]The step ran past its timeout-minutes ({}) and was stopped.",
594 minutes.unwrap_or_default()
595 ));
596 false
597 } else {
598 ok
599 };
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent600
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)601 // A step the cancellation stopped ends cancelled, not failed.
602 let stopped = !ok && report::cancelled();
603 let outcome = if ok {
604 "success"
605 } else if stopped {
606 "cancelled"
607 } else {
608 "failure"
609 };
610 let conclusion = if ok || continue_on_error {
611 "success"
612 } else {
613 outcome
614 };
615 if !ok && continue_on_error && !stopped {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent616 self.log.line("##[warning]The step failed, and `continue-on-error` lets the job go on.");
617 }
618 if let Some(id) = &id {
619 let outputs: Map<String, Value> = outputs.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
620 frame.steps.insert(id.clone(), json!({ "outputs": outputs, "outcome": outcome, "conclusion": conclusion }));
621 }
622 if conclusion == "failure" {
623 self.failed = true;
624 }
625 if report {
626 self.log.step_state(number, &title, "completed", Some(conclusion));
627 }
628 conclusion == "success"
629 }
630
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9631 /// A step's own `env` over `base`, read with `contexts`.
632 fn step_env(&self, step: &Map<String, Value>, contexts: &Map<String, Value>, mut base: BTreeMap<String, String>) -> BTreeMap<String, String> {
633 if let Some(Value::Object(step_env)) = step.get("env") {
634 for (name, value) in step_env {
635 let value = self.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
636 base.insert(name.clone(), expr::to_text(&value));
637 }
638 }
639 base
640 }
641
642 /// A `uses:` step's `with`, read with `contexts`.
643 fn step_with(&self, step: &Map<String, Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
644 match step.get("with") {
645 Some(Value::Object(with)) => with
646 .iter()
647 .map(|(k, v)| {
648 let value = self.with_scope(contexts, |scope| expr::interpolate_value(v, scope)).unwrap_or(Value::Null);
649 (k.clone(), expr::to_text(&value))
650 })
651 .collect(),
652 _ => BTreeMap::new(),
653 }
654 }
655
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent656 fn report_steps(&self) {
657 self.log.steps(&self.step_names);
658 }
659}
660
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts661/// How long a step's processes may run: its `timeout-minutes`, or six
662/// hours (the job's own limit still applies).
663fn step_timeout(minutes: Option<f64>) -> Duration {
664 match minutes {
665 Some(minutes) if minutes.is_finite() && minutes > 0.0 => Duration::from_secs_f64(minutes * 60.0),
666 Some(_) => Duration::ZERO,
667 None => Duration::from_secs(6 * 3600),
668 }
669}
670
671/// When a step must end by: its `timeout-minutes` from `now`, or `outer`
672/// (the deadline of the `uses:` step it runs inside, for a composite
673/// action's step), whichever is nearer. None when neither sets one.
674fn step_deadline(outer: Option<Instant>, now: Instant, minutes: Option<f64>) -> Option<Instant> {
675 let own = minutes.map(|minutes| now + step_timeout(Some(minutes)));
676 match (outer, own) {
677 (Some(outer), Some(own)) => Some(outer.min(own)),
678 (outer, own) => outer.or(own),
679 }
680}
681
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent682/// PowerShell on Windows: `pwsh` (PowerShell 7) if it is installed, as on
683/// GitHub's Windows runners, else Windows PowerShell.
684fn windows_powershell() -> String {
685 if program_exists("pwsh") { "pwsh".into() } else { "powershell".into() }
686}
687
688/// How GitHub runs a PowerShell step: the script, stopping at the first error.
689fn powershell_args() -> Vec<String> {
690 vec!["-NoLogo".into(), "-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), ". '{0}'".into()]
691}
692
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9693/// The name of a shell's program, without its folder or `.exe`.
694fn program_name(program: &str) -> &str {
695 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
696 name.strip_suffix(".exe").or_else(|| name.strip_suffix(".EXE")).unwrap_or(name)
697}
698
699/// The extension GitHub's runner gives a script, by its shell's program:
700/// none for a program it does not know.
701fn script_extension(program: &str) -> &'static str {
702 match program_name(program) {
703 "bash" | "sh" => "sh",
704 "pwsh" | "powershell" => "ps1",
705 "cmd" => "cmd",
706 "python" => "py",
707 _ => "",
708 }
709}
710
711/// A script as GitHub's runner writes it: for PowerShell, stopping at the
712/// first error and exiting with the last program's exit code.
713fn script_contents(program: &str, script: &str) -> String {
714 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
715 match program_name(program) {
716 "pwsh" | "powershell" => format!(
717 "$ErrorActionPreference = 'stop'{newline}{script}{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}"
718 ),
719 _ => script.to_owned(),
720 }
721}
722
723/// Whether `program` is a file in one of `path`'s folders (or, on
724/// Windows, `program.exe`), without running it.
725fn on_path(program: &str, path: &str) -> bool {
726 let names = if cfg!(windows) { vec![program.to_owned(), format!("{program}.exe")] } else { vec![program.to_owned()] };
727 path.split(paths::PATH_SEPARATOR).filter(|dir| !dir.is_empty()).any(|dir| names.iter().any(|name| Path::new(dir).join(name).is_file()))
728}
729
730impl Job {
731 /// `shell: python`'s program: `python`, as on GitHub, where there is
732 /// one, else `python3` (Debian has no `python`).
733 fn python_program(&self) -> String {
734 if let Some(container) = &self.container {
735 return container.python.into();
736 }
737 let path = std::iter::once(self.base_env.get("PATH").cloned().unwrap_or_default());
738 let path: Vec<String> = self.path_prepend.iter().cloned().chain(path).collect();
739 if on_path("python", &path.join(paths::PATH_SEPARATOR)) { "python".into() } else { "python3".into() }
740 }
741}
742
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent743/// Whether `program` is on `PATH`.
744fn program_exists(program: &str) -> bool {
745 Command::new(program)
746 .arg(if program == "cmd" { "/C" } else { "-Version" })
747 .stdout(std::process::Stdio::null())
748 .stderr(std::process::Stdio::null())
749 .status()
750 .is_ok()
751}
752
753/// An id for files, unique enough within one job.
754fn rand_id() -> u64 {
755 use std::sync::atomic::{AtomicU64, Ordering};
756 static NEXT: AtomicU64 = AtomicU64::new(1);
757 let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
758 nanos ^ (NEXT.fetch_add(1, Ordering::Relaxed) << 48)
759}
760
761fn interpolated_map(job: &Job, value: Option<&Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
762 let mut out = BTreeMap::new();
763 if let Some(Value::Object(map)) = value {
764 for (name, value) in map {
765 let value = job.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
766 out.insert(name.clone(), expr::to_text(&value));
767 }
768 }
769 out
770}
771
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)772/// Whether `::debug::` lines are shown and steps' conditions explained:
773/// `ACTIONS_STEP_DEBUG` as a secret or a variable set to `true`, or a
774/// re-run with debug logging, which sets it and `RUNNER_DEBUG=1` among the
775/// job's variables.
776fn step_debug(contexts: &Map<String, Value>, variables: &Value) -> bool {
777 let named = |name: &str| {
778 contexts
779 .get("secrets")
780 .and_then(|s| s.get(name))
781 .or_else(|| contexts.get("vars").and_then(|v| v.get(name)))
782 .or_else(|| variables.get(name))
783 .is_some_and(|v| expr::to_text(v).eq_ignore_ascii_case("true"))
784 };
785 named("ACTIONS_STEP_DEBUG") || variables.get("RUNNER_DEBUG").is_some_and(|v| expr::to_text(v) == "1")
786}
787
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'788fn setup(spec: Value, api: Api) -> Result<Job> {
789 setup_in(spec, api, paths::under_home(WORKSPACE), paths::under_home(TEMP))
790}
791
792/// [`setup`] with the workspace and temporary folder given: a test's own.
793fn setup_in(mut spec: Value, api: Api, workspace: PathBuf, temp: PathBuf) -> Result<Job> {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent794 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 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9795 let mut log = Log::new(api, masks);
796 // Variables GitHub sets that a g1t from before them does not send.
797 default_variables(&mut spec);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent798 // On a self-hosted runner's own machine, GitHub's layout lives in a
799 // folder of the runner's (paths.rs).
800 for part in ["variables", "github"] {
801 paths::relocate(&mut spec[part]);
802 }
803 paths::relocate(&mut spec["contexts"]["runner"]);
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9804 log.matchers.workspace = workspace.clone();
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent805 std::fs::create_dir_all(&workspace).context("could not make the workspace")?;
806 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
807 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
808
Merge branch 'main' into actions-toolkit-oidc-artifacts809 let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
810 // Docker of the job's own, on g1t's machines (crate::docker).
811 let docker_hosted = cfg!(target_os = "linux")
812 && std::env::var("G1T_DOCKER").as_deref() == Ok("on")
813 && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted");
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9814 // This process's environment, less what only it should see. `ImageOS`
815 // and `ImageVersion` come with it when the machine's image sets them,
816 // as GitHub's images do; nothing makes them up otherwise.
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent817 let mut base_env: BTreeMap<String, String> =
818 std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
819 base_env.insert("HOME".into(), std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()));
820 base_env.extend(text_map(spec.get("variables")));
821 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.822 // As GitHub's runner marks what a job starts, so "Complete job" finds
823 // what its steps left running (process::orphans).
824 let tracking_id = format!("github_{:016x}{:016x}", rand_id(), rand_id());
825 base_env.insert("RUNNER_TRACKING_ID".into(), tracking_id.clone());
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent826
827 let mut contexts: Map<String, Value> = spec["contexts"].as_object().cloned().unwrap_or_default();
828 contexts.insert("github".into(), spec["github"].clone());
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)829 let debug = step_debug(&contexts, &spec["variables"]);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent830
831 // `timeoutMinutes` is how the API spelled it before its bodies were
832 // `snake_case`.
833 let timeout = spec["timeout_minutes"]
834 .as_u64()
835 .or_else(|| spec["timeoutMinutes"].as_u64())
836 .unwrap_or(60);
837 let mut job = Job {
838 log,
839 spec,
840 workspace,
841 temp,
842 base_env,
843 added_env: BTreeMap::new(),
844 path_prepend: Vec::new(),
845 workflow_env: BTreeMap::new(),
846 job_env: BTreeMap::new(),
847 contexts,
848 failed: false,
849 posts: Vec::new(),
850 step_names: Vec::new(),
851 deadline: Instant::now() + Duration::from_secs(timeout * 60),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts852 step_deadline: None,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent853 debug,
854 last_node_outputs: BTreeMap::new(),
855 last_node_state: BTreeMap::new(),
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9856 pre_state: BTreeMap::new(),
Merge branch 'main' into actions-toolkit-oidc-artifacts857 host_env,
858 docker_hosted,
859 network: None,
860 services: Vec::new(),
861 container: None,
862 job_context: Map::new(),
863 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.864 tracking_id,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent865 };
866
867 // The workflow's env reads github, secrets, inputs and vars; the job's
868 // also its matrix, needs and strategy.
869 let mut contexts = job.contexts.clone();
870 contexts.insert("env".into(), json!({}));
871 job.workflow_env = interpolated_map(&job, job.spec["workflow"].get("env"), &contexts);
872 contexts.insert("env".into(), Value::Object(job.workflow_env.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
873 job.job_env = interpolated_map(&job, job.spec["spec"].get("env"), &contexts);
874 Ok(job)
875}
876
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r9877/// `RUNNER_WORKSPACE` and `GITHUB_REPOSITORY_OWNER_ID` (with
878/// `github.repository_owner_id`), when the spec does not have them: the
879/// folder the workspace is in, and the owner's id from the event.
880fn default_variables(spec: &mut Value) {
881 if spec["variables"].is_object() && spec["variables"].get("RUNNER_WORKSPACE").is_none() {
882 spec["variables"]["RUNNER_WORKSPACE"] = json!(g1t_actions::events::RUNNER_WORKSPACE);
883 }
884 let owner_id = g1t_actions::events::owner_id(&spec["event"]);
885 if let Some(owner_id) = owner_id.filter(|id| !id.is_empty()) {
886 if spec["variables"].is_object() && spec["variables"].get("GITHUB_REPOSITORY_OWNER_ID").and_then(Value::as_str).is_none_or(str::is_empty) {
887 spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"] = json!(owner_id);
888 }
889 if spec["github"].is_object() && spec["github"].get("repository_owner_id").and_then(Value::as_str).is_none_or(str::is_empty) {
890 spec["github"]["repository_owner_id"] = json!(owner_id);
891 }
892 }
893}
894
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.895/// What "Set up job" says of the runner and the machine it runs on.
896#[derive(Default)]
897pub(crate) struct Machine {
898 pub(crate) version: String,
899 pub(crate) self_hosted: bool,
900 pub(crate) runner_name: String,
901 /// `RUNNER_OS` and `RUNNER_ARCH`.
902 pub(crate) os: String,
903 pub(crate) arch: String,
904 /// `/etc/os-release`, where there is one.
905 pub(crate) os_release: Option<String>,
906 /// `ImageOS` and `ImageVersion`, when the machine's image sets them.
907 pub(crate) image: Option<(String, String)>,
908 pub(crate) cpus: usize,
909 pub(crate) memory_bytes: Option<u64>,
910 /// The job token's permissions, by GitHub's names.
911 pub(crate) permissions: Vec<(String, String)>,
912}
913
914impl Machine {
915 fn here(spec: &Value, base_env: &BTreeMap<String, String>) -> Machine {
916 let variables = &spec["variables"];
917 let text = |name: &str| variables[name].as_str().unwrap_or_default().to_owned();
918 let self_hosted = variables["RUNNER_ENVIRONMENT"] == "self-hosted";
919 let image = match (base_env.get("ImageOS"), base_env.get("ImageVersion")) {
920 (None, None) => None,
921 (os, version) => Some((os.cloned().unwrap_or_default(), version.cloned().unwrap_or_default())),
922 };
923 let permissions = spec
924 .get("permissions")
925 .and_then(Value::as_object)
926 .map(|map| map.iter().map(|(name, access)| (name.clone(), expr::to_text(access))).collect())
927 .unwrap_or_default();
928 Machine {
929 version: env!("CARGO_PKG_VERSION").to_owned(),
930 self_hosted,
931 runner_name: if self_hosted { text("RUNNER_NAME") } else { "g1t".to_owned() },
932 os: text("RUNNER_OS"),
933 arch: text("RUNNER_ARCH"),
934 os_release: std::fs::read_to_string("/etc/os-release").ok(),
935 image,
936 cpus: std::thread::available_parallelism().map_or(1, usize::from),
937 memory_bytes: memory_limit(
938 std::fs::read_to_string("/sys/fs/cgroup/memory.max").ok().as_deref(),
939 std::fs::read_to_string("/proc/meminfo").ok().as_deref(),
940 ),
941 permissions,
942 }
943 }
944}
945
946/// The memory a job has: its cgroup's limit, else the machine's.
947fn memory_limit(cgroup_max: Option<&str>, meminfo: Option<&str>) -> Option<u64> {
948 let total = meminfo.and_then(|info| {
949 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)
950 });
951 let limit = cgroup_max.and_then(|max| max.trim().parse::<u64>().ok());
952 match (limit, total) {
953 (Some(limit), Some(total)) => Some(limit.min(total)),
954 (limit, total) => limit.or(total),
955 }
956}
957
958/// `pull-requests` as GitHub's log names it: `PullRequests`.
959fn permission_title(name: &str) -> String {
960 name.split(['-', '_'])
961 .map(|word| {
962 let mut chars = word.chars();
963 chars.next().map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default()
964 })
965 .collect()
966}
967
968/// GitHub's "Set up job", before the actions are downloaded: the runner's
969/// version and name, the operating system, the image, the machine, what
970/// the job's token may do and where its secrets come from.
971pub(crate) fn setup_header(machine: &Machine) -> Vec<String> {
972 let mut lines = vec![format!("Current runner version: '{}'", machine.version), format!("Runner name: '{}'", machine.runner_name)];
973 lines.push("##[group]Operating System".into());
974 let release = machine.os_release.as_deref().unwrap_or_default();
975 let field = |key: &str| {
976 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())
977 };
978 match field("NAME") {
979 Some(name) => {
980 lines.push(name);
981 lines.extend(field("VERSION").or_else(|| field("VERSION_ID")));
982 }
983 None => lines.push(format!("{} {}", machine.os, machine.arch).trim().to_owned()),
984 }
985 lines.push("##[endgroup]".into());
986 if machine.image.is_some() || !machine.self_hosted {
987 lines.push("##[group]Runner Image".into());
988 if let Some((os, version)) = &machine.image {
989 if !os.is_empty() {
990 lines.push(format!("Image: {os}"));
991 }
992 if !version.is_empty() {
993 lines.push(format!("Version: {version}"));
994 }
995 }
996 if !machine.self_hosted {
997 lines.push("Included Software: Node 24, Python 3, Go, Rust".into());
998 }
999 lines.push("##[endgroup]".into());
1000 }
1001 lines.push("##[group]Machine".into());
1002 lines.push(if machine.self_hosted { "Self-hosted".into() } else { "Hosted by g1t".to_owned() });
1003 lines.push(format!("Architecture: {}", machine.arch));
1004 lines.push(format!("CPUs: {}", machine.cpus));
1005 if let Some(bytes) = machine.memory_bytes {
1006 let gib = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
1007 let shown = if (gib - gib.round()).abs() < 0.05 { format!("{}", gib.round()) } else { format!("{gib:.1}") };
1008 lines.push(format!("Memory: {shown} GB"));
1009 }
1010 lines.push("##[endgroup]".into());
1011 let granted: Vec<&(String, String)> = machine.permissions.iter().filter(|(_, access)| access != "none" && !access.is_empty()).collect();
1012 if !granted.is_empty() {
1013 lines.push("##[group]G1T_TOKEN Permissions".into());
1014 lines.extend(granted.iter().map(|(name, access)| format!("{}: {access}", permission_title(name))));
1015 lines.push("##[endgroup]".into());
1016 }
1017 lines.push("Secret source: Actions".into());
1018 lines.push("Prepare workflow directory".into());
1019 lines
1020}
1021
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1022/// The job's `defaults.run`, its own over the workflow's.
1023fn run_defaults(spec: &Value) -> Map<String, Value> {
1024 let mut defaults = spec["workflow"]["defaults"]["run"].as_object().cloned().unwrap_or_default();
1025 if let Some(own) = spec["spec"]["defaults"]["run"].as_object() {
1026 defaults.extend(own.clone());
1027 }
1028 defaults
1029}
1030
1031fn run_job(job: &mut Job) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91032 let mut steps: Vec<Map<String, Value>> = job.spec["spec"]["steps"]
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1033 .as_array()
1034 .map(|steps| steps.iter().filter_map(|s| s.as_object().cloned()).collect())
1035 .unwrap_or_default();
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91036 name_steps(&mut steps);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1037 let defaults = run_defaults(&job.spec);
1038
1039 // Step names as they read before anything has run.
1040 let frame = Frame::default();
1041 let env = job.env_context(&frame);
1042 let contexts = job.contexts_for(&frame, &env);
1043 job.step_names = steps
1044 .iter()
1045 .map(|step| match step.get("name").map(expr::to_text) {
1046 Some(name) => job.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
1047 None => default_title(step),
1048 })
1049 .collect();
1050 job.report_steps();
1051
1052 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.1053 // GitHub's "Set up job": the runner, the machine, the token, then the
1054 // actions the job uses.
1055 for line in setup_header(&Machine::here(&job.spec, &job.base_env)) {
1056 job.log.line(&line);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1057 }
1058 if let Some(Value::Object(matrix)) = job.contexts.get("matrix")
1059 && !matrix.is_empty()
1060 {
1061 job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
1062 }
Merge branch 'main' into actions-toolkit-oidc-artifacts1063 if job.docker_hosted {
1064 let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host);
1065 let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty());
1066 let options = crate::docker::engine::Options { registry: registry.zip(token) };
1067 if let Err(problem) = crate::docker::engine::enable(options) {
1068 job.log.line(&format!("##[warning]Docker is not available in this job: {problem}"));
1069 job.docker_hosted = false;
1070 }
1071 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r91072 // A self-hosted runner's word on how this job was run (selfhosted/exec.rs).
1073 if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") {
1074 job.log.line(&format!("##[warning]{notice}"));
1075 }
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'1076 // Actions first, then containers, as GitHub's "Set up job" and
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91077 // "Initialize containers". Each action's `pre` (read from the actions
1078 // just downloaded) is a step of its own, "Pre <step>", before the
1079 // job's first step.
1080 let prepared = job.prepare_actions(&steps);
1081 let pres = if prepared { job.pre_steps(&steps) } else { Vec::new() };
1082 if !pres.is_empty() {
1083 let mut names: Vec<String> = pres.iter().map(|(index, _)| format!("Pre {}", job.step_names[*index])).collect();
1084 names.extend(job.step_names.iter().cloned());
1085 job.step_names = names;
1086 job.report_steps();
1087 }
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.1088 if prepared {
1089 job.log.line(&format!("Complete job name: {}", job.spec["name"].as_str().unwrap_or_default()));
1090 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91091 let containers_started = prepared && job.start_containers();
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1092 job.log.flush();
1093
1094 let mut frame = Frame::default();
Merge branch 'main' into actions-toolkit-oidc-artifacts1095 if !containers_started {
1096 job.failed = true;
1097 for (index, name) in job.step_names.clone().iter().enumerate() {
1098 job.log.step_state(index as u32 + 1, name, "completed", Some("skipped"));
1099 }
1100 }
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91101 for (index, (_, pre)) in pres.iter().enumerate().filter(|_| containers_started) {
1102 let number = index as u32 + 1;
1103 let name = job.step_names[index].clone();
1104 if report::cancelled() {
1105 report::take_cancel();
1106 }
1107 if !job.pre_runs(pre) {
1108 job.log.step_state(number, &name, "completed", Some("skipped"));
1109 continue;
1110 }
1111 job.log.step(number);
1112 job.log.step_state(number, &name, "in_progress", None);
1113 let ok = job.run_pre(pre);
1114 if !ok {
1115 job.failed = true;
1116 }
1117 let conclusion = if ok {
1118 "success"
1119 } else if report::cancelled() {
1120 "cancelled"
1121 } else {
1122 "failure"
1123 };
1124 job.log.step_state(number, &name, "completed", Some(conclusion));
1125 }
1126 let offset = pres.len() as u32;
Merge branch 'main' into actions-toolkit-oidc-artifacts1127 for (index, step) in steps.iter().enumerate().filter(|_| containers_started) {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91128 job.step(&mut frame, step, offset + index as u32 + 1, true, &defaults);
Merge branch 'main' into actions-toolkit-oidc-artifacts1129 job.log_docker_notes();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1130 if job.job_remaining().is_zero() {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1131 job.log.line("##[error]The job ran past its time limit.");
1132 job.failed = true;
1133 break;
1134 }
1135 }
1136
1137 // Post steps, last registered first.
1138 let posts: Vec<Post> = std::mem::take(&mut job.posts);
1139 for post in posts.into_iter().rev() {
1140 let number = job.step_names.len() as u32 + 1;
1141 job.step_names.push(post.name.clone());
1142 job.report_steps();
1143 let contexts = job.contexts_for(&frame, &job.env_context(&frame));
1144 let run_it = job.with_scope(&contexts, |scope| expr::condition(&post.condition, scope)).unwrap_or(true);
1145 if !run_it {
1146 job.log.step_state(number, &post.name, "completed", Some("skipped"));
1147 continue;
1148 }
1149 job.log.step(number);
1150 job.log.step_state(number, &post.name, "in_progress", None);
1151 let ok = match &post.run {
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91152 PostRun::Node { action_dir, script, using } => job.run_node(action_dir, script, &post.env, using),
Merge branch 'main' into actions-toolkit-oidc-artifacts1153 PostRun::Docker(run) => job.run_docker(run).0,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1154 };
1155 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
1156 if !ok {
1157 job.failed = true;
1158 }
1159 }
1160
Merge branch 'main' into actions-toolkit-oidc-artifacts1161 // GitHub's "Stop containers": services' logs, and everything removed.
1162 if job.has_containers() {
1163 let number = job.step_names.len() as u32 + 1;
1164 job.step_names.push("Stop containers".into());
1165 job.report_steps();
1166 job.log.step(number);
1167 job.log.step_state(number, "Stop containers", "in_progress", None);
1168 job.stop_containers();
1169 job.log.step_state(number, "Stop containers", "completed", Some("success"));
1170 }
1171
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1172 // The job's outputs, read now that every step has run.
1173 let env = job.env_context(&frame);
1174 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.1175 // GitHub's "Complete job": the job's outputs set, and whatever its
1176 // steps left running stopped.
1177 let number = job.step_names.len() as u32 + 1;
1178 job.step_names.push("Complete job".into());
1179 job.report_steps();
1180 job.log.step(number);
1181 job.log.step_state(number, "Complete job", "in_progress", None);
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1182 let mut outputs = Map::new();
1183 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.1184 job.log.line("Evaluate and set job outputs");
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1185 for (name, value) in declared {
1186 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.1187 job.log.line(&format!("Set output '{name}'"));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1188 outputs.insert(name.clone(), Value::String(expr::to_text(&value)));
1189 }
1190 }
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.1191 let outputs = job.log.withhold_secrets(&outputs);
1192 job.log.line("Cleaning up orphan processes");
1193 let orphans = process::orphans(&job.tracking_id);
1194 for (pid, name) in &orphans {
1195 job.log.line(&format!("Terminate orphan process: pid ({pid}) ({name})"));
1196 }
1197 process::kill_orphans(&orphans);
1198 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)1199 // Cancelled, the job ends so however its cleanup went (and g1t holds
1200 // to that whatever it is told).
1201 let conclusion = if report::cancelled() {
1202 "cancelled"
1203 } else if job.failed {
1204 "failure"
1205 } else {
1206 "success"
1207 };
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1208 job.log.done(conclusion, &outputs, None);
1209}
1210
1211pub(crate) fn main() -> i32 {
1212 let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
1213 (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
1214 _ => {
1215 eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
1216 return 2;
1217 }
1218 };
1219 let spec = match api.spec() {
1220 Ok(spec) => spec,
1221 Err(error) => {
1222 eprintln!("g1t-runner: could not fetch the job: {error:#}");
1223 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not fetch the job: {error}") }));
1224 return 1;
1225 }
1226 };
1227 let reporter = Api {
1228 base: api.base.clone(),
1229 job: api.job.clone(),
1230 token: api.token.clone(),
1231 };
1232 let mut job = match setup(spec, reporter) {
1233 Ok(job) => job,
1234 Err(error) => {
1235 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not set up: {error:#}") }));
1236 return 1;
1237 }
1238 };
1239 run_job(&mut job);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1240 if job.failed || report::cancelled() { 1 } else { 0 }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use serde_json::{Map, Value, json};
1246
Runner: one tests module after merging step debug and step timeouts1247 use super::*;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1248
1249 #[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.1250 fn set_up_job_says_what_runs_the_job_as_github_does() {
1251 let machine = Machine {
1252 version: "0.2.0".into(),
1253 runner_name: "g1t".into(),
1254 os: "Linux".into(),
1255 arch: "X64".into(),
1256 os_release: Some("PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"12\"\nVERSION=\"12 (bookworm)\"\n".into()),
1257 image: Some(("ubuntu24".into(), "20261005.1".into())),
1258 cpus: 4,
1259 memory_bytes: Some(12 * 1024 * 1024 * 1024),
1260 permissions: vec![("contents".into(), "read".into()), ("id-token".into(), "none".into()), ("metadata".into(), "read".into()), ("pull-requests".into(), "write".into())],
1261 ..Machine::default()
1262 };
1263 let text = setup_header(&machine).join("\n");
1264 assert_eq!(
1265 text,
1266 "Current runner version: '0.2.0'\nRunner name: 'g1t'\n\
1267 ##[group]Operating System\nDebian GNU/Linux\n12 (bookworm)\n##[endgroup]\n\
1268 ##[group]Runner Image\nImage: ubuntu24\nVersion: 20261005.1\nIncluded Software: Node 24, Python 3, Go, Rust\n##[endgroup]\n\
1269 ##[group]Machine\nHosted by g1t\nArchitecture: X64\nCPUs: 4\nMemory: 12 GB\n##[endgroup]\n\
1270 ##[group]G1T_TOKEN Permissions\nContents: read\nMetadata: read\nPullRequests: write\n##[endgroup]\n\
1271 Secret source: Actions\nPrepare workflow directory"
1272 );
1273 // A self-hosted machine without an image of GitHub's, or an os-release.
1274 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() };
1275 let text = setup_header(&own).join("\n");
1276 assert!(text.contains("Runner name: 'build-box'\n##[group]Operating System\nmacOS ARM64\n##[endgroup]\n##[group]Machine\nSelf-hosted"), "{text}");
1277 assert!(!text.contains("Runner Image") && !text.contains("Memory") && !text.contains("Permissions"), "{text}");
1278 assert_eq!(memory_limit(Some("max\n"), Some("MemTotal: 16384000 kB\n")), Some(16_384_000 * 1024));
1279 assert_eq!(memory_limit(Some("4294967296\n"), Some("MemTotal: 16384000 kB\n")), Some(4_294_967_296));
1280 assert_eq!(memory_limit(None, None), None);
1281 assert_eq!(permission_title("security-events"), "SecurityEvents");
1282 }
1283
1284 #[test]
1285 fn a_run_step_opens_with_its_script_shell_and_variables_folded() {
1286 let env: BTreeMap<String, String> = [("CARGO_TERM_COLOR", "always"), ("GITHUB_ACTION", "__run"), ("KEY", "line one\nline two")]
1287 .into_iter()
1288 .map(|(k, v)| (k.to_owned(), v.to_owned()))
1289 .collect();
1290 let lines = script_header("\n node build.mjs --all\necho done\n", "/usr/bin/bash -e {0}", &env);
1291 assert_eq!(
1292 lines,
1293 [
1294 "##[group]Run node build.mjs --all",
1295 "\u{1b}[36;1m node build.mjs --all\u{1b}[0m",
1296 "\u{1b}[36;1mecho done\u{1b}[0m",
1297 "shell: /usr/bin/bash -e {0}",
1298 "env:",
1299 " CARGO_TERM_COLOR: always",
1300 " KEY: line one",
1301 " line two",
1302 "##[endgroup]",
1303 ]
1304 );
1305 // No variables: no `env:`.
1306 assert!(!script_header("make", "sh -e {0}", &BTreeMap::new()).contains(&"env:".to_owned()));
1307 let with: BTreeMap<String, String> = [("path", "~/.cargo\ntarget\n"), ("key", "k")].into_iter().map(|(k, v)| (k.to_owned(), v.to_owned())).collect();
1308 assert_eq!(
1309 uses_header("actions/cache@v4", &with, &BTreeMap::new()),
1310 ["##[group]Run actions/cache@v4", "with:", " key: k", " path: ~/.cargo", " target", "##[endgroup]"]
1311 );
1312 }
1313
1314 #[test]
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1315 fn debug_logging_comes_from_a_secret_a_variable_or_a_debug_rerun() {
1316 let contexts = |value: Value| -> Map<String, Value> { serde_json::from_value(value).unwrap() };
1317 let none = json!({});
1318 assert!(!step_debug(&contexts(json!({ "secrets": {}, "vars": {} })), &none));
1319 assert!(step_debug(&contexts(json!({ "secrets": { "ACTIONS_STEP_DEBUG": "true" } })), &none));
1320 assert!(step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "TRUE" } })), &none));
1321 assert!(!step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "false" } })), &none));
1322 // A re-run with debug logging sets these among the job's variables.
1323 assert!(step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "1" })));
1324 assert!(step_debug(&contexts(json!({})), &json!({ "ACTIONS_STEP_DEBUG": "true" })));
1325 assert!(!step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "0" })));
1326 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1327
1328 #[test]
Merge branch 'worktree-agent-a41ebf64a05c894fe' into claude/cool-newton-7nm8r91329 fn scripts_get_githubs_extension_for_their_shell() {
1330 assert_eq!(script_extension("bash"), "sh");
1331 assert_eq!(script_extension("/usr/bin/sh"), "sh");
1332 assert_eq!(script_extension("pwsh"), "ps1");
1333 assert_eq!(script_extension("C:\\Windows\\powershell.exe"), "ps1");
1334 assert_eq!(script_extension("cmd"), "cmd");
1335 assert_eq!(script_extension("python"), "py");
1336 // A program GitHub does not know: no extension.
1337 assert_eq!(script_extension("perl"), "");
1338 assert_eq!(script_extension("python3"), "");
1339 }
1340
1341 #[test]
1342 fn powershell_scripts_stop_at_errors_and_keep_the_exit_code() {
1343 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
1344 assert_eq!(
1345 script_contents("pwsh", "Write-Output hi"),
1346 format!("$ErrorActionPreference = 'stop'{newline}Write-Output hi{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}")
1347 );
1348 assert!(script_contents("powershell.exe", "x").starts_with("$ErrorActionPreference = 'stop'"));
1349 assert_eq!(script_contents("bash", "echo hi"), "echo hi");
1350 }
1351
1352 #[test]
1353 fn programs_are_found_on_path_without_running_them() {
1354 let dir = std::env::temp_dir().join(format!("g1t-on-path-{}", std::process::id()));
1355 std::fs::create_dir_all(&dir).unwrap();
1356 std::fs::write(dir.join("python"), "").unwrap();
1357 let path = format!("/nowhere{}{}", paths::PATH_SEPARATOR, dir.display());
1358 assert!(on_path("python", &path));
1359 assert!(!on_path("python9", &path));
1360 assert!(!on_path("python", ""));
1361 let _ = std::fs::remove_dir_all(&dir);
1362 }
1363
1364 #[test]
1365 fn steps_are_named_for_github_action_as_github_names_them() {
1366 let mut steps: Vec<Map<String, Value>> = serde_json::from_value(json!([
1367 { "run": "a" },
1368 { "run": "b" },
1369 { "id": "build", "run": "c" },
1370 { "uses": "actions/setup-node@v4" },
1371 { "uses": "actions/cache/restore@v4" },
1372 { "uses": "actions/setup-node@v4" },
1373 { "uses": "./.github/actions/x" },
1374 { "run": "d" },
1375 { "id": "__run_4", "run": "e" },
1376 { "run": "f" },
1377 ]))
1378 .unwrap();
1379 name_steps(&mut steps);
1380 let names: Vec<String> = steps.iter().map(|s| expr::to_text(&s[ACTION_NAME])).collect();
1381 assert_eq!(
1382 names,
1383 ["__run", "__run_2", "build", "__actions_setup-node", "__actions_cache", "__actions_setup-node_2", "__self", "__run_3", "__run_4", "__run_5"]
1384 );
1385 }
1386
1387 #[test]
1388 fn variables_a_spec_lacks_are_filled_in() {
1389 let mut spec = json!({
1390 "variables": { "CI": "true" },
1391 "github": { "repository_owner_id": "" },
1392 "event": { "repository": { "owner": { "id": 7 } } },
1393 });
1394 default_variables(&mut spec);
1395 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/home/runner/work");
1396 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "7");
1397 assert_eq!(spec["github"]["repository_owner_id"], "7");
1398 // What g1t sent stands.
1399 let mut spec = json!({ "variables": { "RUNNER_WORKSPACE": "/w", "GITHUB_REPOSITORY_OWNER_ID": "9" }, "github": {}, "event": {} });
1400 default_variables(&mut spec);
1401 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/w");
1402 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "9");
1403 }
1404
1405 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1406 fn a_steps_timeout_is_its_own_or_the_nearer_one_around_it() {
1407 let now = Instant::now();
1408 // No timeout-minutes anywhere: only the job's deadline applies.
1409 assert_eq!(step_deadline(None, now, None), None);
1410 // A step's own, in minutes, fractions included.
1411 assert_eq!(step_deadline(None, now, Some(1.5)), Some(now + Duration::from_secs(90)));
1412 // A composite action's step inside a `uses:` step with its own:
1413 // the nearer of the two.
1414 let outer = now + Duration::from_secs(60);
1415 assert_eq!(step_deadline(Some(outer), now, Some(10.0)), Some(outer));
1416 assert_eq!(step_deadline(Some(outer), now, Some(0.5)), Some(now + Duration::from_secs(30)));
1417 assert_eq!(step_deadline(Some(outer), now, None), Some(outer));
1418 // Zero or less is a time already up.
1419 assert_eq!(step_deadline(None, now, Some(0.0)), Some(now));
1420 assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600));
1421 assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO);
1422 }
1423}

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