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