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