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 total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 22 | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 23 | use std::collections::{BTreeMap, BTreeSet}; |
| The balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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 balance shows what was spent this month and in total | 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.