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

The balance shows what was spent this month and in total1//! 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;
The balance shows what was spent this month and in total13mod files;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R214mod glob;
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.15mod matchers;
Fast pages, required checks on the branch, self-hosted runners, honest incidents16mod paths;
The balance shows what was spent this month and in total17mod process;
18mod report;
19mod uses;
Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell.20/// Also unpacks the plugins a run gets from Customize (customize.rs).
21pub(crate) mod zip;
The balance shows what was spent this month and in total22
Merge branch 'main' into actions-toolkit-oidc-artifacts23use std::collections::{BTreeMap, BTreeSet};
The balance shows what was spent this month and in total24use 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.62 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),
The balance shows what was spent this month and in total66}
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>,
The balance shows what was spent this month and in total92 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.96 /// 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,
The balance shows what was spent this month and in total114}
115
116fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
117 value
118 .and_then(Value::as_object)
119 .map(|map| map.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect())
120 .unwrap_or_default()
121}
122
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.123/// Where `name_steps` puts a step's `GITHUB_ACTION`.
124const ACTION_NAME: &str = "__g1t_action";
125
126/// Gives each step its `GITHUB_ACTION`, as GitHub's runner names them: its
127/// `id`, else `__run` for a script and `__owner_repo` for an action
128/// (`__self` for one in the repository), the second of a name `_2`, the
129/// third `_3`.
130pub(crate) fn name_steps(steps: &mut [Map<String, Value>]) {
131 let mut taken: BTreeSet<String> = steps.iter().filter_map(|step| step.get("id").map(expr::to_text)).collect();
132 for step in steps.iter_mut() {
133 let name = match step.get("id").map(expr::to_text) {
134 Some(id) => id,
135 None => {
136 let base = match step.get("uses").map(expr::to_text) {
137 None => "run".to_owned(),
138 Some(uses) if uses.starts_with("./") => "self".to_owned(),
139 Some(uses) => {
140 let name = uses.split('@').next().unwrap_or_default();
141 let name = name.strip_prefix("docker://").unwrap_or(name);
142 let repository: Vec<&str> = name.splitn(3, '/').take(2).collect();
143 repository.join("_").chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect()
144 }
145 };
146 let base = format!("__{base}");
147 let mut name = base.clone();
148 let mut n = 2;
149 while taken.contains(&name) {
150 name = format!("{base}_{n}");
151 n += 1;
152 }
153 taken.insert(name.clone());
154 name
155 }
156 };
157 step.insert(ACTION_NAME.into(), Value::String(name));
158 }
159}
160
The balance shows what was spent this month and in total161/// A step's title when it has no name, as GitHub shows it.
162fn default_title(step: &Map<String, Value>) -> String {
163 if let Some(uses) = step.get("uses").and_then(Value::as_str) {
164 return format!("Run {uses}");
165 }
166 let run = step.get("run").map(expr::to_text).unwrap_or_default();
167 let first = run.lines().find(|line| !line.trim().is_empty()).unwrap_or_default().trim();
168 format!("Run {first}")
169}
170
171impl Job {
172 pub(crate) fn base_env_value(&self, name: &str) -> Option<String> {
173 self.base_env.get(name).cloned()
174 }
175
Merge branch 'main' into actions-toolkit-oidc-artifacts176 /// What earlier steps added to `PATH`, newest first.
177 pub(crate) fn path_prepend_entries(&self) -> &[String] {
178 &self.path_prepend
179 }
180
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)181 /// How the job is going, for `success()`, `failure()`, `cancelled()`
182 /// and `always()`: once the run is cancelled, only steps that ask for
183 /// `always()` or `cancelled()` run.
The balance shows what was spent this month and in total184 fn status(&self) -> Status {
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)185 if report::cancelled() {
186 Status::Cancelled
187 } else if self.failed {
188 Status::Failure
189 } else {
190 Status::Success
191 }
192 }
193
194 /// `job.status`.
195 fn status_word(&self) -> &'static str {
196 if report::cancelled() {
197 "cancelled"
198 } else if self.failed {
199 "failure"
200 } else {
201 "success"
202 }
The balance shows what was spent this month and in total203 }
204
205 /// The contexts an expression in a step can use.
206 pub(crate) fn contexts_for(&self, frame: &Frame, env: &BTreeMap<String, String>) -> Map<String, Value> {
207 let mut contexts = self.contexts.clone();
208 contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
209 contexts.insert("steps".into(), Value::Object(frame.steps.clone()));
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)210 contexts.insert("job".into(), containers::job_context(self.status_word(), &self.job_context));
The balance shows what was spent this month and in total211 if let Some(inputs) = &frame.inputs {
212 contexts.insert("inputs".into(), inputs.clone());
213 }
214 if let Some(path) = &frame.action_path
215 && let Some(github) = contexts.get_mut("github")
216 {
217 github["action_path"] = Value::String(path.clone());
218 }
219 contexts
220 }
221
222 /// Runs `f` with a scope over these contexts.
223 pub(crate) fn with_scope<T>(&self, contexts: &Map<String, Value>, f: impl FnOnce(&Scope) -> T) -> T {
224 let workspace = self.workspace.clone();
225 let hash = move |patterns: &[String]| files::hash_files(&workspace, patterns);
226 let scope = Scope {
227 contexts,
228 status: self.status(),
229 hash_files: Some(&hash),
230 };
231 f(&scope)
232 }
233
234 /// The `env` context for a step: the workflow's, the job's, what earlier
235 /// steps wrote to `GITHUB_ENV`, and the frame's.
Merge branch 'main' into actions-toolkit-oidc-artifacts236 pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
The balance shows what was spent this month and in total237 let mut env = self.added_env.clone();
238 env.extend(self.workflow_env.clone());
239 env.extend(self.job_env.clone());
240 env.extend(frame.env.clone());
241 env
242 }
243
244 /// What a process for a step is given.
245 pub(crate) fn process_env(&self, env: &BTreeMap<String, String>, files: &StepFiles) -> BTreeMap<String, String> {
246 let mut out = self.base_env.clone();
247 out.extend(env.clone());
248 for (name, value) in files.variables() {
249 out.insert(name.to_owned(), value);
250 }
251 if !self.path_prepend.is_empty() {
252 let current = out.get("PATH").cloned().unwrap_or_default();
Fast pages, required checks on the branch, self-hosted runners, honest incidents253 let separator = paths::PATH_SEPARATOR;
254 out.insert("PATH".into(), format!("{}{separator}{current}", self.path_prepend.join(separator)));
The balance shows what was spent this month and in total255 }
256 out
257 }
258
259 /// Takes in what a step wrote to its files. Returns its outputs.
260 pub(crate) fn absorb(&mut self, files: &StepFiles, commands: &Commands) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
261 let mut outputs: BTreeMap<String, String> = commands.outputs.clone();
262 match files::key_values(&StepFiles::read(&files.output)) {
263 Ok(values) => outputs.extend(values),
264 Err(problem) => self.log.line(&format!("##[error]$GITHUB_OUTPUT: {problem}")),
265 }
266 match files::key_values(&StepFiles::read(&files.env)) {
267 Ok(values) => {
268 for (name, value) in values {
269 if name.starts_with("GITHUB_") || name == "NODE_OPTIONS" {
270 self.log.line(&format!("##[warning]{name} cannot be set through $GITHUB_ENV."));
271 continue;
272 }
273 self.added_env.insert(name, value);
274 }
275 }
276 Err(problem) => self.log.line(&format!("##[error]$GITHUB_ENV: {problem}")),
277 }
278 for line in StepFiles::read(&files.path).lines().map(str::trim).filter(|l| !l.is_empty()) {
279 self.path_prepend.insert(0, line.to_owned());
280 }
281 let mut state = commands.state.clone();
282 if let Ok(values) = files::key_values(&StepFiles::read(&files.state)) {
283 state.extend(values);
284 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)285 // The step's job summary, for the run's page (report.rs).
286 self.log.summary(&StepFiles::read(&files.summary));
The balance shows what was spent this month and in total287 (outputs, state)
288 }
289
290 pub(crate) fn remaining_time(&self) -> Duration {
291 self.remaining()
292 }
293
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts294 /// What is left for the running step: its `timeout-minutes`, within
295 /// the job's.
The balance shows what was spent this month and in total296 fn remaining(&self) -> Duration {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts297 let until = self.step_deadline.map_or(self.deadline, |step| step.min(self.deadline));
298 until.saturating_duration_since(Instant::now())
299 }
300
301 /// What is left of the job's own time, whatever the step's.
302 fn job_remaining(&self) -> Duration {
The balance shows what was spent this month and in total303 self.deadline.saturating_duration_since(Instant::now())
304 }
305
306 /// Runs a shell script for a `run` step.
307 pub(crate) fn run_script(
308 &mut self,
309 script: &str,
310 shell: Option<&str>,
311 working_directory: Option<&str>,
312 env: &BTreeMap<String, String>,
313 timeout: Duration,
314 ) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 crate::abuse::touch();
316 // Mining is never a workflow's job (abuse.rs).
317 if let Some(miner) = crate::abuse::miner_in(script) {
318 self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run."));
319 return (false, BTreeMap::new(), BTreeMap::new());
320 }
The balance shows what was spent this month and in total321 let id = format!("{:x}", rand_id());
322 let shell = shell.map(str::trim).filter(|s| !s.is_empty());
323 let (program, args, extension): (String, Vec<String>, &str) = match shell {
Fast pages, required checks on the branch, self-hosted runners, honest incidents324 // A self-hosted Windows runner, as GitHub's: PowerShell.
325 None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"),
Merge branch 'main' into actions-toolkit-oidc-artifacts326 // A job container without bash, as GitHub's runner does.
327 None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
The balance shows what was spent this month and in total328 None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
329 Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
330 Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.331 Some("python") => (self.python_program(), vec!["{0}".into()], "py"),
Fast pages, required checks on the branch, self-hosted runners, honest incidents332 Some("pwsh") if cfg!(windows) || program_exists("pwsh") => ("pwsh".into(), powershell_args(), "ps1"),
333 Some("powershell") if cfg!(windows) => ("powershell".into(), powershell_args(), "ps1"),
334 Some("cmd") if cfg!(windows) => (
335 "cmd".into(),
336 vec!["/D".into(), "/E:ON".into(), "/V:OFF".into(), "/S".into(), "/C".into(), "CALL \"{0}\"".into()],
337 "cmd",
338 ),
The balance shows what was spent this month and in total339 Some(other @ ("pwsh" | "powershell" | "cmd")) => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents340 self.log.line(&format!(
341 "##[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]`."
342 ));
The balance shows what was spent this month and in total343 return (false, BTreeMap::new(), BTreeMap::new());
344 }
345 Some(custom) => {
346 let mut parts = custom.split_whitespace().map(str::to_owned);
347 let program = parts.next().unwrap_or_default();
348 let mut args: Vec<String> = parts.collect();
349 if !args.iter().any(|a| a.contains("{0}")) {
350 args.push("{0}".into());
351 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.352 let extension = script_extension(&program);
353 (program, args, extension)
The balance shows what was spent this month and in total354 }
355 };
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.356 let script_path = self.temp.join(if extension.is_empty() { id.clone() } else { format!("{id}.{extension}") });
357 if let Err(error) = std::fs::write(&script_path, script_contents(&program, script)) {
The balance shows what was spent this month and in total358 self.log.line(&format!("##[error]Could not write the script: {error}"));
359 return (false, BTreeMap::new(), BTreeMap::new());
360 }
361 let files = match StepFiles::new(&self.temp, &id) {
362 Ok(files) => files,
363 Err(error) => {
364 self.log.line(&format!("##[error]Could not make the step's files: {error}"));
365 return (false, BTreeMap::new(), BTreeMap::new());
366 }
367 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents368 let args: Vec<String> = args.iter().map(|a| a.replace("{0}", &paths::shown(&script_path))).collect();
The balance shows what was spent this month and in total369 self.log.line(&format!("shell: {program} {}", args.join(" ")));
370 let dir = match working_directory {
371 Some(dir) if Path::new(dir).is_absolute() => PathBuf::from(dir),
372 Some(dir) => self.workspace.join(dir),
373 None => self.workspace.clone(),
374 };
Merge branch 'main' into actions-toolkit-oidc-artifacts375 let full = self.process_env(env, &files);
376 let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| {
377 let inside = self.container_env(env, full.clone(), &image_path);
378 self.in_container(&program, &args, &dir, inside)
379 });
380 let command = match in_container {
381 Some(command) => command,
382 None => {
383 let mut command = Command::new(&program);
384 command.args(&args).current_dir(&dir).env_clear().envs(full);
385 command
386 }
387 };
The balance shows what was spent this month and in total388 let mut commands = Commands {
389 debug: self.debug,
390 ..Commands::default()
391 };
392 let ended = process::run(command, timeout.min(self.remaining()), &mut self.log, &mut commands);
393 let ok = match ended {
394 Ok(Ended::Exited(0)) => true,
395 Ok(Ended::Exited(code)) => {
396 self.log.line(&format!("##[error]Process completed with exit code {code}."));
397 false
398 }
399 Ok(Ended::TimedOut) => {
400 self.log.line("##[error]The step ran past its time limit and was stopped.");
401 false
402 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)403 Ok(Ended::Cancelled) => false,
The balance shows what was spent this month and in total404 Err(error) => {
405 self.log.line(&format!("##[error]{program} could not be started: {error}"));
406 false
407 }
408 };
409 let (outputs, state) = self.absorb(&files, &commands);
410 (ok, outputs, state)
411 }
412
413 /// Runs one step of a frame. Returns whether it succeeded (its
414 /// conclusion). `number` is the step the log belongs to.
415 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)416 // A cancellation heard during an earlier step (which it stopped) or
417 // since: from here on, only cleanup steps run, and none is stopped
418 // for it again.
419 if report::cancelled() {
420 report::take_cancel();
421 }
The balance shows what was spent this month and in total422 let env_before = self.env_context(frame);
423 let contexts = self.contexts_for(frame, &env_before);
424 let title = match step.get("name").map(expr::to_text) {
425 Some(name) => self.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
426 None => default_title(step),
427 };
428 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)429 let read = self.with_scope(&contexts, |scope| expr::condition(&condition, scope));
430 // Debug logging: how the step's `if` read, as GitHub's runner says.
431 if self.debug {
432 let shown = if condition.trim().is_empty() { "success()" } else { condition.trim() };
433 self.log.line(&format!("##[debug]Evaluating condition for step: '{title}'"));
434 self.log.line(&format!("##[debug]Evaluating: {shown}"));
435 match &read {
436 Ok(result) => self.log.line(&format!("##[debug]Result: {result}")),
437 Err(problem) => self.log.line(&format!("##[debug]Failed: {problem}")),
438 }
439 }
440 let run_it = match read {
The balance shows what was spent this month and in total441 Ok(run_it) => run_it,
442 Err(problem) => {
443 self.log.line(&format!("##[error]The step's `if` does not read: {problem}"));
444 self.failed = true;
445 if report {
446 self.log.step_state(number, &title, "completed", Some("failure"));
447 }
448 return false;
449 }
450 };
451 let id = step.get("id").map(expr::to_text);
452 if !run_it {
453 if let Some(id) = &id {
454 frame.steps.insert(id.clone(), json!({ "outputs": {}, "outcome": "skipped", "conclusion": "skipped" }));
455 }
456 if report {
457 self.log.step_state(number, &title, "completed", Some("skipped"));
458 }
459 return true;
460 }
461 if report {
462 self.log.step(number);
463 self.log.step_state(number, &title, "in_progress", None);
464 }
465
466 // The step's own env, read with the contexts before it.
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.467 let env = self.step_env(step, &contexts, env_before.clone());
468 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 timeouts469 let minutes = step
The balance shows what was spent this month and in total470 .get("timeout-minutes")
471 .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 timeouts472 .and_then(|v| v.as_f64().or_else(|| expr::to_text(&v).parse().ok()));
473 let timeout = step_timeout(minutes);
474 // Every step stops at its own `timeout-minutes`, `run` or `uses`: a
475 // `uses:` step's action, and every process it starts, included.
476 let outer_deadline = self.step_deadline;
477 self.step_deadline = step_deadline(outer_deadline, Instant::now(), minutes);
The balance shows what was spent this month and in total478 let continue_on_error = step
479 .get("continue-on-error")
480 .and_then(|v| self.with_scope(&contexts, |scope| expr::interpolate_value(v, scope)).ok())
481 .is_some_and(|v| expr::truthy(&v));
482
483 let (ok, outputs) = if let Some(run) = step.get("run").map(expr::to_text) {
484 let script = match self.with_scope(&contexts, |scope| expr::interpolate(&run, scope)) {
485 Ok(script) => script,
486 Err(problem) => {
487 self.log.line(&format!("##[error]The script does not read: {problem}"));
488 String::new()
489 }
490 };
491 self.log.line(&format!("##[group]{title}"));
492 for line in script.lines() {
493 self.log.line(line);
494 }
495 self.log.line("##[endgroup]");
496 let shell = step
497 .get("shell")
498 .map(expr::to_text)
499 .or_else(|| defaults.get("shell").map(expr::to_text));
500 if frame.action_path.is_some() && shell.is_none() {
501 self.log.line("##[error]A composite action's `run` steps need a `shell`.");
502 (false, BTreeMap::new())
503 } else {
504 let working_directory = step
505 .get("working-directory")
506 .or_else(|| defaults.get("working-directory"))
507 .map(|v| self.with_scope(&contexts, |scope| expr::interpolate(&expr::to_text(v), scope)).unwrap_or_else(|_| expr::to_text(v)));
508 let mut env = env;
509 if let Some(path) = &frame.action_path {
510 env.insert("GITHUB_ACTION_PATH".into(), path.clone());
511 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.512 env.insert("GITHUB_ACTION".into(), action_name.clone());
The balance shows what was spent this month and in total513 let (ok, outputs, _) = self.run_script(&script, shell.as_deref(), working_directory.as_deref(), &env, timeout);
514 (ok, outputs)
515 }
516 } else if let Some(uses) = step.get("uses").map(expr::to_text) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.517 let with = self.step_with(step, &contexts);
518 self.uses(&uses, &with, &env, frame, &title, &action_name, timeout)
The balance shows what was spent this month and in total519 } else {
520 self.log.line("##[error]A step needs `run` or `uses`.");
521 (false, BTreeMap::new())
522 };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts523 let own_deadline = self.step_deadline.filter(|_| self.step_deadline != outer_deadline);
524 self.step_deadline = outer_deadline;
525 let ok = if own_deadline.is_some_and(|until| Instant::now() >= until) {
526 self.log.line(&format!(
527 "##[error]The step ran past its timeout-minutes ({}) and was stopped.",
528 minutes.unwrap_or_default()
529 ));
530 false
531 } else {
532 ok
533 };
The balance shows what was spent this month and in total534
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)535 // A step the cancellation stopped ends cancelled, not failed.
536 let stopped = !ok && report::cancelled();
537 let outcome = if ok {
538 "success"
539 } else if stopped {
540 "cancelled"
541 } else {
542 "failure"
543 };
544 let conclusion = if ok || continue_on_error {
545 "success"
546 } else {
547 outcome
548 };
549 if !ok && continue_on_error && !stopped {
The balance shows what was spent this month and in total550 self.log.line("##[warning]The step failed, and `continue-on-error` lets the job go on.");
551 }
552 if let Some(id) = &id {
553 let outputs: Map<String, Value> = outputs.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
554 frame.steps.insert(id.clone(), json!({ "outputs": outputs, "outcome": outcome, "conclusion": conclusion }));
555 }
556 if conclusion == "failure" {
557 self.failed = true;
558 }
559 if report {
560 self.log.step_state(number, &title, "completed", Some(conclusion));
561 }
562 conclusion == "success"
563 }
564
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.565 /// A step's own `env` over `base`, read with `contexts`.
566 fn step_env(&self, step: &Map<String, Value>, contexts: &Map<String, Value>, mut base: BTreeMap<String, String>) -> BTreeMap<String, String> {
567 if let Some(Value::Object(step_env)) = step.get("env") {
568 for (name, value) in step_env {
569 let value = self.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
570 base.insert(name.clone(), expr::to_text(&value));
571 }
572 }
573 base
574 }
575
576 /// A `uses:` step's `with`, read with `contexts`.
577 fn step_with(&self, step: &Map<String, Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
578 match step.get("with") {
579 Some(Value::Object(with)) => with
580 .iter()
581 .map(|(k, v)| {
582 let value = self.with_scope(contexts, |scope| expr::interpolate_value(v, scope)).unwrap_or(Value::Null);
583 (k.clone(), expr::to_text(&value))
584 })
585 .collect(),
586 _ => BTreeMap::new(),
587 }
588 }
589
The balance shows what was spent this month and in total590 fn report_steps(&self) {
591 self.log.steps(&self.step_names);
592 }
593}
594
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts595/// How long a step's processes may run: its `timeout-minutes`, or six
596/// hours (the job's own limit still applies).
597fn step_timeout(minutes: Option<f64>) -> Duration {
598 match minutes {
599 Some(minutes) if minutes.is_finite() && minutes > 0.0 => Duration::from_secs_f64(minutes * 60.0),
600 Some(_) => Duration::ZERO,
601 None => Duration::from_secs(6 * 3600),
602 }
603}
604
605/// When a step must end by: its `timeout-minutes` from `now`, or `outer`
606/// (the deadline of the `uses:` step it runs inside, for a composite
607/// action's step), whichever is nearer. None when neither sets one.
608fn step_deadline(outer: Option<Instant>, now: Instant, minutes: Option<f64>) -> Option<Instant> {
609 let own = minutes.map(|minutes| now + step_timeout(Some(minutes)));
610 match (outer, own) {
611 (Some(outer), Some(own)) => Some(outer.min(own)),
612 (outer, own) => outer.or(own),
613 }
614}
615
Fast pages, required checks on the branch, self-hosted runners, honest incidents616/// PowerShell on Windows: `pwsh` (PowerShell 7) if it is installed, as on
617/// GitHub's Windows runners, else Windows PowerShell.
618fn windows_powershell() -> String {
619 if program_exists("pwsh") { "pwsh".into() } else { "powershell".into() }
620}
621
622/// How GitHub runs a PowerShell step: the script, stopping at the first error.
623fn powershell_args() -> Vec<String> {
624 vec!["-NoLogo".into(), "-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), ". '{0}'".into()]
625}
626
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.627/// The name of a shell's program, without its folder or `.exe`.
628fn program_name(program: &str) -> &str {
629 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
630 name.strip_suffix(".exe").or_else(|| name.strip_suffix(".EXE")).unwrap_or(name)
631}
632
633/// The extension GitHub's runner gives a script, by its shell's program:
634/// none for a program it does not know.
635fn script_extension(program: &str) -> &'static str {
636 match program_name(program) {
637 "bash" | "sh" => "sh",
638 "pwsh" | "powershell" => "ps1",
639 "cmd" => "cmd",
640 "python" => "py",
641 _ => "",
642 }
643}
644
645/// A script as GitHub's runner writes it: for PowerShell, stopping at the
646/// first error and exiting with the last program's exit code.
647fn script_contents(program: &str, script: &str) -> String {
648 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
649 match program_name(program) {
650 "pwsh" | "powershell" => format!(
651 "$ErrorActionPreference = 'stop'{newline}{script}{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}"
652 ),
653 _ => script.to_owned(),
654 }
655}
656
657/// Whether `program` is a file in one of `path`'s folders (or, on
658/// Windows, `program.exe`), without running it.
659fn on_path(program: &str, path: &str) -> bool {
660 let names = if cfg!(windows) { vec![program.to_owned(), format!("{program}.exe")] } else { vec![program.to_owned()] };
661 path.split(paths::PATH_SEPARATOR).filter(|dir| !dir.is_empty()).any(|dir| names.iter().any(|name| Path::new(dir).join(name).is_file()))
662}
663
664impl Job {
665 /// `shell: python`'s program: `python`, as on GitHub, where there is
666 /// one, else `python3` (Debian has no `python`).
667 fn python_program(&self) -> String {
668 if let Some(container) = &self.container {
669 return container.python.into();
670 }
671 let path = std::iter::once(self.base_env.get("PATH").cloned().unwrap_or_default());
672 let path: Vec<String> = self.path_prepend.iter().cloned().chain(path).collect();
673 if on_path("python", &path.join(paths::PATH_SEPARATOR)) { "python".into() } else { "python3".into() }
674 }
675}
676
Fast pages, required checks on the branch, self-hosted runners, honest incidents677/// Whether `program` is on `PATH`.
678fn program_exists(program: &str) -> bool {
679 Command::new(program)
680 .arg(if program == "cmd" { "/C" } else { "-Version" })
681 .stdout(std::process::Stdio::null())
682 .stderr(std::process::Stdio::null())
683 .status()
684 .is_ok()
685}
686
The balance shows what was spent this month and in total687/// An id for files, unique enough within one job.
688fn rand_id() -> u64 {
689 use std::sync::atomic::{AtomicU64, Ordering};
690 static NEXT: AtomicU64 = AtomicU64::new(1);
691 let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
692 nanos ^ (NEXT.fetch_add(1, Ordering::Relaxed) << 48)
693}
694
695fn interpolated_map(job: &Job, value: Option<&Value>, contexts: &Map<String, Value>) -> BTreeMap<String, String> {
696 let mut out = BTreeMap::new();
697 if let Some(Value::Object(map)) = value {
698 for (name, value) in map {
699 let value = job.with_scope(contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
700 out.insert(name.clone(), expr::to_text(&value));
701 }
702 }
703 out
704}
705
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)706/// Whether `::debug::` lines are shown and steps' conditions explained:
707/// `ACTIONS_STEP_DEBUG` as a secret or a variable set to `true`, or a
708/// re-run with debug logging, which sets it and `RUNNER_DEBUG=1` among the
709/// job's variables.
710fn step_debug(contexts: &Map<String, Value>, variables: &Value) -> bool {
711 let named = |name: &str| {
712 contexts
713 .get("secrets")
714 .and_then(|s| s.get(name))
715 .or_else(|| contexts.get("vars").and_then(|v| v.get(name)))
716 .or_else(|| variables.get(name))
717 .is_some_and(|v| expr::to_text(v).eq_ignore_ascii_case("true"))
718 };
719 named("ACTIONS_STEP_DEBUG") || variables.get("RUNNER_DEBUG").is_some_and(|v| expr::to_text(v) == "1")
720}
721
Fast pages, required checks on the branch, self-hosted runners, honest incidents722fn setup(mut spec: Value, api: Api) -> Result<Job> {
The balance shows what was spent this month and in total723 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.724 let mut log = Log::new(api, masks);
725 // Variables GitHub sets that a g1t from before them does not send.
726 default_variables(&mut spec);
Fast pages, required checks on the branch, self-hosted runners, honest incidents727 // On a self-hosted runner's own machine, GitHub's layout lives in a
728 // folder of the runner's (paths.rs).
729 for part in ["variables", "github"] {
730 paths::relocate(&mut spec[part]);
731 }
732 paths::relocate(&mut spec["contexts"]["runner"]);
733 let workspace = paths::under_home(WORKSPACE);
734 let temp = paths::under_home(TEMP);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.735 log.matchers.workspace = workspace.clone();
The balance shows what was spent this month and in total736 std::fs::create_dir_all(&workspace).context("could not make the workspace")?;
737 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
738 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
739
Merge branch 'main' into actions-toolkit-oidc-artifacts740 let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
741 // Docker of the job's own, on g1t's machines (crate::docker).
742 let docker_hosted = cfg!(target_os = "linux")
743 && std::env::var("G1T_DOCKER").as_deref() == Ok("on")
744 && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted");
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.745 // This process's environment, less what only it should see. `ImageOS`
746 // and `ImageVersion` come with it when the machine's image sets them,
747 // as GitHub's images do; nothing makes them up otherwise.
The balance shows what was spent this month and in total748 let mut base_env: BTreeMap<String, String> =
749 std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
750 base_env.insert("HOME".into(), std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()));
751 base_env.extend(text_map(spec.get("variables")));
Fast pages, required checks on the branch, self-hosted runners, honest incidents752 base_env.insert("GITHUB_EVENT_PATH".into(), paths::shown(&temp.join("event.json")));
The balance shows what was spent this month and in total753
754 let mut contexts: Map<String, Value> = spec["contexts"].as_object().cloned().unwrap_or_default();
755 contexts.insert("github".into(), spec["github"].clone());
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)756 let debug = step_debug(&contexts, &spec["variables"]);
The balance shows what was spent this month and in total757
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API758 // `timeoutMinutes` is how the API spelled it before its bodies were
759 // `snake_case`.
760 let timeout = spec["timeout_minutes"]
761 .as_u64()
762 .or_else(|| spec["timeoutMinutes"].as_u64())
763 .unwrap_or(60);
The balance shows what was spent this month and in total764 let mut job = Job {
765 log,
766 spec,
767 workspace,
768 temp,
769 base_env,
770 added_env: BTreeMap::new(),
771 path_prepend: Vec::new(),
772 workflow_env: BTreeMap::new(),
773 job_env: BTreeMap::new(),
774 contexts,
775 failed: false,
776 posts: Vec::new(),
777 step_names: Vec::new(),
778 deadline: Instant::now() + Duration::from_secs(timeout * 60),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts779 step_deadline: None,
The balance shows what was spent this month and in total780 debug,
781 last_node_outputs: BTreeMap::new(),
782 last_node_state: BTreeMap::new(),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.783 pre_state: BTreeMap::new(),
Merge branch 'main' into actions-toolkit-oidc-artifacts784 host_env,
785 docker_hosted,
786 network: None,
787 services: Vec::new(),
788 container: None,
789 job_context: Map::new(),
790 built_actions: containers::Built::new(),
The balance shows what was spent this month and in total791 };
792
793 // The workflow's env reads github, secrets, inputs and vars; the job's
794 // also its matrix, needs and strategy.
795 let mut contexts = job.contexts.clone();
796 contexts.insert("env".into(), json!({}));
797 job.workflow_env = interpolated_map(&job, job.spec["workflow"].get("env"), &contexts);
798 contexts.insert("env".into(), Value::Object(job.workflow_env.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
799 job.job_env = interpolated_map(&job, job.spec["spec"].get("env"), &contexts);
800 Ok(job)
801}
802
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.803/// `RUNNER_WORKSPACE` and `GITHUB_REPOSITORY_OWNER_ID` (with
804/// `github.repository_owner_id`), when the spec does not have them: the
805/// folder the workspace is in, and the owner's id from the event.
806fn default_variables(spec: &mut Value) {
807 if spec["variables"].is_object() && spec["variables"].get("RUNNER_WORKSPACE").is_none() {
808 spec["variables"]["RUNNER_WORKSPACE"] = json!(g1t_actions::events::RUNNER_WORKSPACE);
809 }
810 let owner_id = g1t_actions::events::owner_id(&spec["event"]);
811 if let Some(owner_id) = owner_id.filter(|id| !id.is_empty()) {
812 if spec["variables"].is_object() && spec["variables"].get("GITHUB_REPOSITORY_OWNER_ID").and_then(Value::as_str).is_none_or(str::is_empty) {
813 spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"] = json!(owner_id);
814 }
815 if spec["github"].is_object() && spec["github"].get("repository_owner_id").and_then(Value::as_str).is_none_or(str::is_empty) {
816 spec["github"]["repository_owner_id"] = json!(owner_id);
817 }
818 }
819}
820
The balance shows what was spent this month and in total821/// The job's `defaults.run`, its own over the workflow's.
822fn run_defaults(spec: &Value) -> Map<String, Value> {
823 let mut defaults = spec["workflow"]["defaults"]["run"].as_object().cloned().unwrap_or_default();
824 if let Some(own) = spec["spec"]["defaults"]["run"].as_object() {
825 defaults.extend(own.clone());
826 }
827 defaults
828}
829
830fn run_job(job: &mut Job) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.831 let mut steps: Vec<Map<String, Value>> = job.spec["spec"]["steps"]
The balance shows what was spent this month and in total832 .as_array()
833 .map(|steps| steps.iter().filter_map(|s| s.as_object().cloned()).collect())
834 .unwrap_or_default();
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.835 name_steps(&mut steps);
The balance shows what was spent this month and in total836 let defaults = run_defaults(&job.spec);
837
838 // Step names as they read before anything has run.
839 let frame = Frame::default();
840 let env = job.env_context(&frame);
841 let contexts = job.contexts_for(&frame, &env);
842 job.step_names = steps
843 .iter()
844 .map(|step| match step.get("name").map(expr::to_text) {
845 Some(name) => job.with_scope(&contexts, |scope| expr::interpolate(&name, scope)).unwrap_or(name),
846 None => default_title(step),
847 })
848 .collect();
849 job.report_steps();
850
851 job.log.step(0);
852 job.log.line(&format!("Job: {}", job.spec["name"].as_str().unwrap_or_default()));
Fast pages, required checks on the branch, self-hosted runners, honest incidents853 let variables = &job.spec["variables"];
854 if variables["RUNNER_ENVIRONMENT"] == "self-hosted" {
855 let text = |name: &str| variables[name].as_str().unwrap_or_default().to_owned();
856 job.log.line(&format!("Runner: {}, self-hosted, {} {}", text("RUNNER_NAME"), text("RUNNER_OS"), text("RUNNER_ARCH")));
857 } else {
858 job.log.line("Runner: g1t, Linux X64 (Debian bookworm, Node 24, Python 3, Go, Rust)");
859 }
The balance shows what was spent this month and in total860 if let Some(Value::Object(matrix)) = job.contexts.get("matrix")
861 && !matrix.is_empty()
862 {
863 job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
864 }
Merge branch 'worktree-agent-a3abfcce648e87dca'865 // What its G1T_TOKEN may do, as its `permissions:` gave it.
866 if let Some(Value::Object(permissions)) = job.spec.get("permissions").cloned() {
867 job.log.line("##[group]G1T_TOKEN permissions");
868 for (name, access) in &permissions {
869 if access.as_str() != Some("none") {
870 job.log.line(&format!("{name}: {}", access.as_str().unwrap_or_default()));
871 }
872 }
873 job.log.line("##[endgroup]");
874 }
Merge branch 'main' into actions-toolkit-oidc-artifacts875 if job.docker_hosted {
876 let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host);
877 let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty());
878 let options = crate::docker::engine::Options { registry: registry.zip(token) };
879 if let Err(problem) = crate::docker::engine::enable(options) {
880 job.log.line(&format!("##[warning]Docker is not available in this job: {problem}"));
881 job.docker_hosted = false;
882 }
883 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.884 // A self-hosted runner's word on how this job was run (selfhosted/exec.rs).
885 if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") {
886 job.log.line(&format!("##[warning]{notice}"));
887 }
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'888 // Actions first, then containers, as GitHub's "Set up job" and
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.889 // "Initialize containers". Each action's `pre` (read from the actions
890 // just downloaded) is a step of its own, "Pre <step>", before the
891 // job's first step.
892 let prepared = job.prepare_actions(&steps);
893 let pres = if prepared { job.pre_steps(&steps) } else { Vec::new() };
894 if !pres.is_empty() {
895 let mut names: Vec<String> = pres.iter().map(|(index, _)| format!("Pre {}", job.step_names[*index])).collect();
896 names.extend(job.step_names.iter().cloned());
897 job.step_names = names;
898 job.report_steps();
899 }
900 let containers_started = prepared && job.start_containers();
The balance shows what was spent this month and in total901 job.log.flush();
902
903 let mut frame = Frame::default();
Merge branch 'main' into actions-toolkit-oidc-artifacts904 if !containers_started {
905 job.failed = true;
906 for (index, name) in job.step_names.clone().iter().enumerate() {
907 job.log.step_state(index as u32 + 1, name, "completed", Some("skipped"));
908 }
909 }
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.910 for (index, (_, pre)) in pres.iter().enumerate().filter(|_| containers_started) {
911 let number = index as u32 + 1;
912 let name = job.step_names[index].clone();
913 if report::cancelled() {
914 report::take_cancel();
915 }
916 if !job.pre_runs(pre) {
917 job.log.step_state(number, &name, "completed", Some("skipped"));
918 continue;
919 }
920 job.log.step(number);
921 job.log.step_state(number, &name, "in_progress", None);
922 let ok = job.run_pre(pre);
923 if !ok {
924 job.failed = true;
925 }
926 let conclusion = if ok {
927 "success"
928 } else if report::cancelled() {
929 "cancelled"
930 } else {
931 "failure"
932 };
933 job.log.step_state(number, &name, "completed", Some(conclusion));
934 }
935 let offset = pres.len() as u32;
Merge branch 'main' into actions-toolkit-oidc-artifacts936 for (index, step) in steps.iter().enumerate().filter(|_| containers_started) {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.937 job.step(&mut frame, step, offset + index as u32 + 1, true, &defaults);
Merge branch 'main' into actions-toolkit-oidc-artifacts938 job.log_docker_notes();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts939 if job.job_remaining().is_zero() {
The balance shows what was spent this month and in total940 job.log.line("##[error]The job ran past its time limit.");
941 job.failed = true;
942 break;
943 }
944 }
945
946 // Post steps, last registered first.
947 let posts: Vec<Post> = std::mem::take(&mut job.posts);
948 for post in posts.into_iter().rev() {
949 let number = job.step_names.len() as u32 + 1;
950 job.step_names.push(post.name.clone());
951 job.report_steps();
952 let contexts = job.contexts_for(&frame, &job.env_context(&frame));
953 let run_it = job.with_scope(&contexts, |scope| expr::condition(&post.condition, scope)).unwrap_or(true);
954 if !run_it {
955 job.log.step_state(number, &post.name, "completed", Some("skipped"));
956 continue;
957 }
958 job.log.step(number);
959 job.log.step_state(number, &post.name, "in_progress", None);
960 let ok = match &post.run {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.961 PostRun::Node { action_dir, script, using } => job.run_node(action_dir, script, &post.env, using),
Merge branch 'worktree-agent-a3abfcce648e87dca'962 PostRun::CacheSave { key, paths, version } => job.cache_save(key, paths, version),
Merge branch 'main' into actions-toolkit-oidc-artifacts963 PostRun::Docker(run) => job.run_docker(run).0,
The balance shows what was spent this month and in total964 };
965 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
966 if !ok {
967 job.failed = true;
968 }
969 }
970
Merge branch 'main' into actions-toolkit-oidc-artifacts971 // GitHub's "Stop containers": services' logs, and everything removed.
972 if job.has_containers() {
973 let number = job.step_names.len() as u32 + 1;
974 job.step_names.push("Stop containers".into());
975 job.report_steps();
976 job.log.step(number);
977 job.log.step_state(number, "Stop containers", "in_progress", None);
978 job.stop_containers();
979 job.log.step_state(number, "Stop containers", "completed", Some("success"));
980 }
981
The balance shows what was spent this month and in total982 // The job's outputs, read now that every step has run.
983 let env = job.env_context(&frame);
984 let contexts = job.contexts_for(&frame, &env);
985 let mut outputs = Map::new();
986 if let Some(Value::Object(declared)) = job.spec["spec"].get("outputs") {
987 for (name, value) in declared {
988 let value = job.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
989 outputs.insert(name.clone(), Value::String(expr::to_text(&value)));
990 }
991 }
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)992 // Cancelled, the job ends so however its cleanup went (and g1t holds
993 // to that whatever it is told).
994 let conclusion = if report::cancelled() {
995 "cancelled"
996 } else if job.failed {
997 "failure"
998 } else {
999 "success"
1000 };
The balance shows what was spent this month and in total1001 job.log.done(conclusion, &outputs, None);
1002}
1003
1004pub(crate) fn main() -> i32 {
1005 let api = match (crate::env("G1T_API"), crate::env("ACTIONS_JOB"), crate::env("ACTIONS_TOKEN")) {
1006 (Ok(base), Ok(job), Ok(token)) => Api { base, job, token },
1007 _ => {
1008 eprintln!("g1t-runner: G1T_API, ACTIONS_JOB and ACTIONS_TOKEN are needed");
1009 return 2;
1010 }
1011 };
1012 let spec = match api.spec() {
1013 Ok(spec) => spec,
1014 Err(error) => {
1015 eprintln!("g1t-runner: could not fetch the job: {error:#}");
1016 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not fetch the job: {error}") }));
1017 return 1;
1018 }
1019 };
1020 let reporter = Api {
1021 base: api.base.clone(),
1022 job: api.job.clone(),
1023 token: api.token.clone(),
1024 };
1025 let mut job = match setup(spec, reporter) {
1026 Ok(job) => job,
1027 Err(error) => {
1028 api.report(json!({ "kind": "done", "conclusion": "failure", "reason": format!("The runner could not set up: {error:#}") }));
1029 return 1;
1030 }
1031 };
1032 run_job(&mut job);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1033 if job.failed || report::cancelled() { 1 } else { 0 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use serde_json::{Map, Value, json};
1039
Runner: one tests module after merging step debug and step timeouts1040 use super::*;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)1041
1042 #[test]
1043 fn debug_logging_comes_from_a_secret_a_variable_or_a_debug_rerun() {
1044 let contexts = |value: Value| -> Map<String, Value> { serde_json::from_value(value).unwrap() };
1045 let none = json!({});
1046 assert!(!step_debug(&contexts(json!({ "secrets": {}, "vars": {} })), &none));
1047 assert!(step_debug(&contexts(json!({ "secrets": { "ACTIONS_STEP_DEBUG": "true" } })), &none));
1048 assert!(step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "TRUE" } })), &none));
1049 assert!(!step_debug(&contexts(json!({ "vars": { "ACTIONS_STEP_DEBUG": "false" } })), &none));
1050 // A re-run with debug logging sets these among the job's variables.
1051 assert!(step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "1" })));
1052 assert!(step_debug(&contexts(json!({})), &json!({ "ACTIONS_STEP_DEBUG": "true" })));
1053 assert!(!step_debug(&contexts(json!({})), &json!({ "RUNNER_DEBUG": "0" })));
1054 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1055
1056 #[test]
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1057 fn scripts_get_githubs_extension_for_their_shell() {
1058 assert_eq!(script_extension("bash"), "sh");
1059 assert_eq!(script_extension("/usr/bin/sh"), "sh");
1060 assert_eq!(script_extension("pwsh"), "ps1");
1061 assert_eq!(script_extension("C:\\Windows\\powershell.exe"), "ps1");
1062 assert_eq!(script_extension("cmd"), "cmd");
1063 assert_eq!(script_extension("python"), "py");
1064 // A program GitHub does not know: no extension.
1065 assert_eq!(script_extension("perl"), "");
1066 assert_eq!(script_extension("python3"), "");
1067 }
1068
1069 #[test]
1070 fn powershell_scripts_stop_at_errors_and_keep_the_exit_code() {
1071 let newline = if cfg!(windows) { "\r\n" } else { "\n" };
1072 assert_eq!(
1073 script_contents("pwsh", "Write-Output hi"),
1074 format!("$ErrorActionPreference = 'stop'{newline}Write-Output hi{newline}if ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) {{ exit $LASTEXITCODE }}")
1075 );
1076 assert!(script_contents("powershell.exe", "x").starts_with("$ErrorActionPreference = 'stop'"));
1077 assert_eq!(script_contents("bash", "echo hi"), "echo hi");
1078 }
1079
1080 #[test]
1081 fn programs_are_found_on_path_without_running_them() {
1082 let dir = std::env::temp_dir().join(format!("g1t-on-path-{}", std::process::id()));
1083 std::fs::create_dir_all(&dir).unwrap();
1084 std::fs::write(dir.join("python"), "").unwrap();
1085 let path = format!("/nowhere{}{}", paths::PATH_SEPARATOR, dir.display());
1086 assert!(on_path("python", &path));
1087 assert!(!on_path("python9", &path));
1088 assert!(!on_path("python", ""));
1089 let _ = std::fs::remove_dir_all(&dir);
1090 }
1091
1092 #[test]
1093 fn steps_are_named_for_github_action_as_github_names_them() {
1094 let mut steps: Vec<Map<String, Value>> = serde_json::from_value(json!([
1095 { "run": "a" },
1096 { "run": "b" },
1097 { "id": "build", "run": "c" },
1098 { "uses": "actions/setup-node@v4" },
1099 { "uses": "actions/cache/restore@v4" },
1100 { "uses": "actions/setup-node@v4" },
1101 { "uses": "./.github/actions/x" },
1102 { "run": "d" },
1103 { "id": "__run_4", "run": "e" },
1104 { "run": "f" },
1105 ]))
1106 .unwrap();
1107 name_steps(&mut steps);
1108 let names: Vec<String> = steps.iter().map(|s| expr::to_text(&s[ACTION_NAME])).collect();
1109 assert_eq!(
1110 names,
1111 ["__run", "__run_2", "build", "__actions_setup-node", "__actions_cache", "__actions_setup-node_2", "__self", "__run_3", "__run_4", "__run_5"]
1112 );
1113 }
1114
1115 #[test]
1116 fn variables_a_spec_lacks_are_filled_in() {
1117 let mut spec = json!({
1118 "variables": { "CI": "true" },
1119 "github": { "repository_owner_id": "" },
1120 "event": { "repository": { "owner": { "id": 7 } } },
1121 });
1122 default_variables(&mut spec);
1123 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/home/runner/work");
1124 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "7");
1125 assert_eq!(spec["github"]["repository_owner_id"], "7");
1126 // What g1t sent stands.
1127 let mut spec = json!({ "variables": { "RUNNER_WORKSPACE": "/w", "GITHUB_REPOSITORY_OWNER_ID": "9" }, "github": {}, "event": {} });
1128 default_variables(&mut spec);
1129 assert_eq!(spec["variables"]["RUNNER_WORKSPACE"], "/w");
1130 assert_eq!(spec["variables"]["GITHUB_REPOSITORY_OWNER_ID"], "9");
1131 }
1132
1133 #[test]
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1134 fn a_steps_timeout_is_its_own_or_the_nearer_one_around_it() {
1135 let now = Instant::now();
1136 // No timeout-minutes anywhere: only the job's deadline applies.
1137 assert_eq!(step_deadline(None, now, None), None);
1138 // A step's own, in minutes, fractions included.
1139 assert_eq!(step_deadline(None, now, Some(1.5)), Some(now + Duration::from_secs(90)));
1140 // A composite action's step inside a `uses:` step with its own:
1141 // the nearer of the two.
1142 let outer = now + Duration::from_secs(60);
1143 assert_eq!(step_deadline(Some(outer), now, Some(10.0)), Some(outer));
1144 assert_eq!(step_deadline(Some(outer), now, Some(0.5)), Some(now + Duration::from_secs(30)));
1145 assert_eq!(step_deadline(Some(outer), now, None), Some(outer));
1146 // Zero or less is a time already up.
1147 assert_eq!(step_deadline(None, now, Some(0.0)), Some(now));
1148 assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600));
1149 assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO);
1150 }
1151}

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