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 statement is a month at a time, a line per kind of charge | 1 | //! 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 | ||
| 11 | mod blobs; | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 12 | mod containers; |
| The statement is a month at a time, a line per kind of charge | 13 | mod files; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 14 | mod 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. | 15 | mod matchers; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 16 | mod paths; |
| The statement is a month at a time, a line per kind of charge | 17 | mod process; |
| 18 | mod report; | |
| 19 | mod 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). |
| 21 | pub(crate) mod zip; | |
| The statement is a month at a time, a line per kind of charge | 22 | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 23 | use std::collections::{BTreeMap, BTreeSet}; |
| The statement is a month at a time, a line per kind of charge | 24 | use std::path::{Path, PathBuf}; |
| 25 | use std::process::Command; | |
| 26 | use std::time::{Duration, Instant}; | |
| 27 | ||
| 28 | use anyhow::{Context, Result}; | |
| 29 | use g1t_actions::events::WORKSPACE; | |
| 30 | use g1t_actions::expr::{self, Scope, Status}; | |
| 31 | use serde_json::{Map, Value, json}; | |
| 32 | ||
| 33 | use files::StepFiles; | |
| 34 | use process::{Commands, Ended}; | |
| 35 | use report::{Api, Log}; | |
| 36 | ||
| 37 | const TEMP: &str = "/home/runner/_temp"; | |
| 38 | ||
| 39 | /// Who is running steps: the job itself, or a composite action inside it. | |
| 40 | #[derive(Clone, Default)] | |
| 41 | pub(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. | |
| 54 | pub(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 | ||
| 61 | pub(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-artifacts | 64 | /// A Docker action's `post-entrypoint`. |
| 65 | Docker(containers::DockerRun), | |
| The statement is a month at a time, a line per kind of charge | 66 | } |
| 67 | ||
| 68 | pub(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 timeouts | 87 | /// 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 statement is a month at a time, a line per kind of charge | 92 | 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-artifacts | 99 | /// 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 statement is a month at a time, a line per kind of charge | 114 | } |
| 115 | ||
| 116 | fn 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`. |
| 124 | const 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`. | |
| 130 | pub(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 statement is a month at a time, a line per kind of charge | 161 | /// A step's title when it has no name, as GitHub shows it. |
| 162 | fn 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 | ||
| 171 | impl 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-artifacts | 176 | /// 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 statement is a month at a time, a line per kind of charge | 184 | 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 statement is a month at a time, a line per kind of charge | 203 | } |
| 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 statement is a month at a time, a line per kind of charge | 211 | 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-artifacts | 236 | pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> { |
| The statement is a month at a time, a line per kind of charge | 237 | 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 incidents | 253 | let separator = paths::PATH_SEPARATOR; |
| 254 | out.insert("PATH".into(), format!("{}{separator}{current}", self.path_prepend.join(separator))); | |
| The statement is a month at a time, a line per kind of charge | 255 | } |
| 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 statement is a month at a time, a line per kind of charge | 287 | (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 timeouts | 294 | /// What is left for the running step: its `timeout-minutes`, within |
| 295 | /// the job's. | |
| The statement is a month at a time, a line per kind of charge | 296 | fn remaining(&self) -> Duration { |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 297 | 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 statement is a month at a time, a line per kind of charge | 303 | 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 look | 315 | 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 statement is a month at a time, a line per kind of charge | 321 | 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 incidents | 324 | // 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-artifacts | 326 | // 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 statement is a month at a time, a line per kind of charge | 328 | 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 incidents | 332 | 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 statement is a month at a time, a line per kind of charge | 339 | Some(other @ ("pwsh" | "powershell" | "cmd")) => { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 340 | 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 statement is a month at a time, a line per kind of charge | 343 | 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 statement is a month at a time, a line per kind of charge | 354 | } |
| 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 statement is a month at a time, a line per kind of charge | 358 | 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 incidents | 368 | let args: Vec<String> = args.iter().map(|a| a.replace("{0}", &paths::shown(&script_path))).collect(); |
| The statement is a month at a time, a line per kind of charge | 369 | 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-artifacts | 375 | 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 statement is a month at a time, a line per kind of charge | 388 | 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 statement is a month at a time, a line per kind of charge | 404 | 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 statement is a month at a time, a line per kind of charge | 422 | 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 statement is a month at a time, a line per kind of charge | 441 | 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 timeouts | 469 | let minutes = step |
| The statement is a month at a time, a line per kind of charge | 470 | .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 timeouts | 472 | .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 statement is a month at a time, a line per kind of charge | 478 | 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 statement is a month at a time, a line per kind of charge | 513 | 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 statement is a month at a time, a line per kind of charge | 519 | } 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 timeouts | 523 | 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 statement is a month at a time, a line per kind of charge | 534 | |
| 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 statement is a month at a time, a line per kind of charge | 550 | 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 statement is a month at a time, a line per kind of charge | 590 | 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 timeouts | 595 | /// How long a step's processes may run: its `timeout-minutes`, or six |
| 596 | /// hours (the job's own limit still applies). | |
| 597 | fn 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. | |
| 608 | fn 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 incidents | 616 | /// PowerShell on Windows: `pwsh` (PowerShell 7) if it is installed, as on |
| 617 | /// GitHub's Windows runners, else Windows PowerShell. | |
| 618 | fn 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. | |
| 623 | fn 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`. |
| 628 | fn 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. | |
| 635 | fn 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. | |
| 647 | fn 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. | |
| 659 | fn 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 | ||
| 664 | impl 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 incidents | 677 | /// Whether `program` is on `PATH`. |
| 678 | fn 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 statement is a month at a time, a line per kind of charge | 687 | /// An id for files, unique enough within one job. |
| 688 | fn 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 | ||
| 695 | fn 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. | |
| 710 | fn 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 incidents | 722 | fn setup(mut spec: Value, api: Api) -> Result<Job> { |
| The statement is a month at a time, a line per kind of charge | 723 | 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 incidents | 727 | // 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 statement is a month at a time, a line per kind of charge | 736 | 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-artifacts | 740 | 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 statement is a month at a time, a line per kind of charge | 748 | 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 incidents | 752 | base_env.insert("GITHUB_EVENT_PATH".into(), paths::shown(&temp.join("event.json"))); |
| The statement is a month at a time, a line per kind of charge | 753 | |
| 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 statement is a month at a time, a line per kind of charge | 757 | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 758 | // `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 statement is a month at a time, a line per kind of charge | 764 | 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 timeouts | 779 | step_deadline: None, |
| The statement is a month at a time, a line per kind of charge | 780 | 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-artifacts | 784 | 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 statement is a month at a time, a line per kind of charge | 791 | }; |
| 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. | |
| 806 | fn 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 statement is a month at a time, a line per kind of charge | 821 | /// The job's `defaults.run`, its own over the workflow's. |
| 822 | fn 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 | ||
| 830 | fn 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 statement is a month at a time, a line per kind of charge | 832 | .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 statement is a month at a time, a line per kind of charge | 836 | 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 incidents | 853 | 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 statement is a month at a time, a line per kind of charge | 860 | 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-artifacts | 875 | 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 statement is a month at a time, a line per kind of charge | 901 | job.log.flush(); |
| 902 | ||
| 903 | let mut frame = Frame::default(); | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 904 | 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-artifacts | 936 | 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-artifacts | 938 | job.log_docker_notes(); |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 939 | if job.job_remaining().is_zero() { |
| The statement is a month at a time, a line per kind of charge | 940 | 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-artifacts | 963 | PostRun::Docker(run) => job.run_docker(run).0, |
| The statement is a month at a time, a line per kind of charge | 964 | }; |
| 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-artifacts | 971 | // 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 statement is a month at a time, a line per kind of charge | 982 | // 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 statement is a month at a time, a line per kind of charge | 1001 | job.log.done(conclusion, &outputs, None); |
| 1002 | } | |
| 1003 | ||
| 1004 | pub(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)] | |
| 1037 | mod tests { | |
| 1038 | use serde_json::{Map, Value, json}; | |
| 1039 | ||
| Runner: one tests module after merging step debug and step timeouts | 1040 | 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 timeouts | 1055 | |
| 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 timeouts | 1134 | 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.