Skip to content
1,422 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.

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

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