Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,215 linesCodeBlameRaw
1//! `uses:` steps: `actions/checkout` done natively against g1t, actions
2//! fetched from another repository on g1t (or else GitHub, at the commit
3//! g1t resolved its ref to, from the archive g1t keeps of it when it has
4//! one) and run as they are (JavaScript, composite and Docker),
5//! `docker://` images, and a few of GitHub's own whose services g1t does
6//! not have yet. Every action a job's steps name is downloaded before the
7//! first step runs, as GitHub's runner does.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::Duration;
13
14use base64::Engine;
15use base64::engine::general_purpose::STANDARD;
16use g1t_actions::expr;
17use g1t_actions::workflow::yaml_to_json;
18use serde_json::{Map, Value, json};
19
20use super::containers::{self, DockerRun};
21use super::files::StepFiles;
22use super::process::{self, Commands, Ended};
23use super::{Frame, Job, Post, PostRun};
24
25const ACTIONS_DIR: &str = "/home/runner/_actions";
26/// How often a wait on g1t for an action says it is still waiting, as
27/// transfers say how far they have got.
28const WAITING_EVERY: Duration = Duration::from_secs(5);
29
30/// Where an action comes from.
31enum Source {
32 Local(PathBuf),
33 /// Another repository: on g1t when g1t has it and this one may use
34 /// it, otherwise on GitHub.
35 GitHub { owner: String, repo: String, path: String, git_ref: String },
36}
37
38fn safe(part: &str) -> bool {
39 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) && !part.contains("..")
40}
41
42fn is_sha(text: &str) -> bool {
43 text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit())
44}
45
46/// Whether the runner does `name` (lowercased, without its ref) itself
47/// rather than fetching it.
48fn native(name: &str, docker_hosted: bool) -> bool {
49 matches!(
50 name,
51 "actions/checkout"
52 | "actions/upload-artifact"
53 | "actions/upload-artifact/merge"
54 | "actions/download-artifact"
55 ) || (docker_hosted && name == "docker/setup-buildx-action")
56}
57
58/// The repository and ref of an action `uses` fetches from another
59/// repository: (owner, repo, ref). None for one that is local, a Docker
60/// image, done natively, or not a name at all.
61fn remote_action(uses: &str, docker_hosted: bool) -> Option<(String, String, String)> {
62 let uses = uses.trim();
63 if uses.starts_with("./") || uses.starts_with("docker://") {
64 return None;
65 }
66 let (name, git_ref) = uses.split_once('@')?;
67 if git_ref.is_empty() || native(&name.to_ascii_lowercase(), docker_hosted) {
68 return None;
69 }
70 let mut parts = name.splitn(3, '/');
71 let (owner, repo) = (parts.next()?, parts.next()?);
72 (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned()))
73}
74
75/// Every branch, as a checkout with all history fetches them.
76const HEADS_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
77/// Every tag, likewise.
78const TAGS_REFSPEC: &str = "+refs/tags/*:refs/tags/*";
79
80/// What a checkout fetches and where it ends, as `actions/checkout`
81/// decides it (its `getRefSpec`, `getRefSpecForAllHistory` and
82/// `getCheckoutInfo`).
83#[derive(Debug, PartialEq, Eq)]
84struct CheckoutPlan {
85 refspecs: Vec<String>,
86 /// The commit to end on, when it is known before the fetch.
87 sha: Option<String>,
88 /// The branch made there (or at its remote-tracking branch).
89 branch: Option<String>,
90 /// Where to end with neither: a tag, or another ref.
91 start: Option<String>,
92 /// A name that may be a branch or a tag; the fetch tells which.
93 unqualified: Option<String>,
94 /// The step's `ref` output.
95 output_ref: String,
96}
97
98/// `wanted` is the step's `ref` (or another repository's default branch);
99/// without one, the run's own ref and commit for its own repository.
100fn checkout_plan(wanted: Option<&str>, same: bool, run_ref: &str, run_sha: &str) -> CheckoutPlan {
101 let (git_ref, commit) = match wanted {
102 Some(r) if is_sha(r) => (String::new(), r.to_owned()),
103 Some(r) => (r.to_owned(), String::new()),
104 // Some events name a branch without `refs/heads/`.
105 None if same && !run_sha.is_empty() && !run_ref.is_empty() && !run_ref.starts_with("refs/") => (format!("refs/heads/{run_ref}"), run_sha.to_owned()),
106 None if same => (run_ref.to_owned(), run_sha.to_owned()),
107 None => (String::new(), String::new()),
108 };
109 let upper = git_ref.to_ascii_uppercase();
110 let rest = |prefix: &str| git_ref[prefix.len()..].to_owned();
111 let mut plan = CheckoutPlan { refspecs: Vec::new(), sha: None, branch: None, start: None, unqualified: None, output_ref: git_ref.clone() };
112 if !commit.is_empty() {
113 plan.refspecs.push(if upper.starts_with("REFS/HEADS/") {
114 plan.branch = Some(rest("refs/heads/"));
115 format!("+{commit}:refs/remotes/origin/{}", rest("refs/heads/"))
116 } else if upper.starts_with("REFS/PULL/") {
117 format!("+{commit}:refs/remotes/pull/{}", rest("refs/pull/"))
118 } else if upper.starts_with("REFS/TAGS/") {
119 format!("+{commit}:{git_ref}")
120 } else {
121 commit.clone()
122 });
123 plan.sha = Some(commit);
124 } else if git_ref.is_empty() {
125 plan.refspecs.push("HEAD".into());
126 } else if !upper.starts_with("REFS/") {
127 plan.refspecs.push(format!("+refs/heads/{git_ref}*:refs/remotes/origin/{git_ref}*"));
128 plan.refspecs.push(format!("+refs/tags/{git_ref}*:refs/tags/{git_ref}*"));
129 plan.unqualified = Some(git_ref.clone());
130 } else if upper.starts_with("REFS/HEADS/") {
131 plan.branch = Some(rest("refs/heads/"));
132 plan.refspecs.push(format!("+{git_ref}:refs/remotes/origin/{}", rest("refs/heads/")));
133 } else if upper.starts_with("REFS/PULL/") {
134 let local = format!("refs/remotes/pull/{}", rest("refs/pull/"));
135 plan.refspecs.push(format!("+{git_ref}:{local}"));
136 plan.start = Some(local);
137 } else {
138 plan.refspecs.push(format!("+{git_ref}:{git_ref}"));
139 plan.start = Some(git_ref.clone());
140 }
141 plan
142}
143
144impl Job {
145 /// Runs a git command, logging it; the credential header is never logged.
146 fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool {
147 let shown: Vec<&str> = args.to_vec();
148 self.log.line(&format!("[command]git {}", shown.join(" ")));
149 let mut command = Command::new("git");
150 command.current_dir(dir);
151 if let Some(header) = auth {
152 command.args(["-c", &format!("http.extraheader={header}")]);
153 }
154 command.args(args).env("GIT_TERMINAL_PROMPT", "0");
155 let mut commands = Commands::default();
156 matches!(process::run(command, Duration::from_secs(600).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
157 }
158
159 /// A fetch, tried again after a short wait when it fails: a transfer
160 /// cut short on the way ("transfer closed with N bytes remaining") is
161 /// over by the next try. Three tries in all, as actions/checkout does.
162 fn fetch_retrying(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool {
163 for (attempt, wait) in [0u64, 2, 5].into_iter().enumerate() {
164 if attempt > 0 {
165 self.log.line(&format!("The fetch failed; trying again in {wait} s ({} of 3).", attempt + 1));
166 std::thread::sleep(Duration::from_secs(wait));
167 }
168 if self.git(dir, args, auth) {
169 return true;
170 }
171 }
172 false
173 }
174
175 /// A git command with `header` sent only to `server` (`http.<server>/.extraheader`),
176 /// so a submodule on another host never sees the job's token. Logged
177 /// without the header.
178 fn git_scoped(&mut self, dir: &Path, args: &[&str], server: &str, header: Option<&str>) -> bool {
179 self.log.line(&format!("[command]git {}", args.join(" ")));
180 let mut command = Command::new("git");
181 command.current_dir(dir);
182 if let Some(header) = header {
183 command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
184 }
185 command.args(args).env("GIT_TERMINAL_PROMPT", "0");
186 let mut commands = Commands::default();
187 matches!(process::run(command, Duration::from_secs(600).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
188 }
189
190 /// `actions/checkout`, against g1t: the inputs of its version 4 and 5
191 /// but `ssh-key`, `lfs` and `clean: false`, fetching what it fetches
192 /// and ending on the same branch or commit.
193 fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
194 let checkout = self.spec["checkout"].clone();
195 let own = checkout["repository"].as_str().unwrap_or_default().to_owned();
196 let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").trim_end_matches('/').to_owned();
197 let input = |name: &str| with.get(name).map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
198 let yes = |name: &str, default: bool| input(name).map_or(default, |v| v.eq_ignore_ascii_case("true"));
199 let repository = input("repository").unwrap_or(own.clone());
200 let same = repository.eq_ignore_ascii_case(&own);
201 let token = input("token").or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
202 let url = if same { checkout["url"].as_str().unwrap_or_default().to_owned() } else { format!("{server}/{repository}.git") };
203 let path = input("path").map_or(self.workspace.clone(), |p| self.workspace.join(p));
204 // As the action reads it: not a number, or below zero, is all history.
205 let depth: u32 = input("fetch-depth").map_or(1, |d| d.parse::<f64>().ok().filter(|d| *d >= 0.0).map_or(0, |d| d as u32));
206 let wanted_ref = input("ref");
207 let filter = input("filter");
208 let sparse: Vec<String> = with.get("sparse-checkout").map(|s| s.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_owned).collect()).unwrap_or_default();
209 let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
210 let header = auth.as_deref();
211
212 self.log.line(&format!("Syncing repository: {repository}"));
213 if path.exists() {
214 let _ = std::fs::remove_dir_all(&path);
215 }
216 if let Err(error) = std::fs::create_dir_all(&path) {
217 self.log.line(&format!("##[error]Could not make {}: {error}", path.display()));
218 return (false, BTreeMap::new());
219 }
220 // So git works on it in a container run as another user.
221 if yes("set-safe-directory", true) {
222 let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
223 }
224 if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) {
225 return (false, BTreeMap::new());
226 }
227 let _ = Command::new("git").current_dir(&path).args(["config", "--local", "gc.auto", "0"]).status();
228
229 // Another repository with no ref: its default branch, as the
230 // action asks the API for it.
231 let default_branch = match (&wanted_ref, same) {
232 (None, false) => self.default_branch(&path, &server, header),
233 _ => None,
234 };
235 let plan = checkout_plan(
236 wanted_ref.as_deref().or(default_branch.as_deref()),
237 same,
238 checkout["ref"].as_str().unwrap_or_default(),
239 checkout["sha"].as_str().unwrap_or_default(),
240 );
241 let depth_arg = format!("--depth={depth}");
242 let filter_arg = filter.clone().or_else(|| (!sparse.is_empty()).then(|| "blob:none".to_owned())).map(|f| format!("--filter={f}"));
243 let fetch_args = |refspecs: &[String], tags: bool| {
244 let mut args: Vec<String> = ["-c", "protocol.version=2", "fetch", "--prune", "--no-recurse-submodules", "--quiet"].map(String::from).into();
245 if !tags {
246 args.push("--no-tags".into());
247 }
248 args.extend(filter_arg.clone());
249 if depth > 0 {
250 args.push(depth_arg.clone());
251 }
252 args.push("origin".into());
253 args.extend(refspecs.iter().cloned());
254 args
255 };
256 // All history: every branch and tag, then what the run names, which
257 // may be a commit its branch has since moved on from.
258 let everything = fetch_args(&[HEADS_REFSPEC.into(), TAGS_REFSPEC.into()], true);
259 let everything: Vec<&str> = everything.iter().map(String::as_str).collect();
260 let named = fetch_args(&plan.refspecs, yes("fetch-tags", false));
261 let named: Vec<&str> = named.iter().map(String::as_str).collect();
262 let fetched = (depth > 0 || self.fetch_retrying(&path, &everything, header)) && self.fetch_retrying(&path, &named, header);
263 let has = |target: &str| Command::new("git").current_dir(&path).args(["cat-file", "-e", &format!("{target}^{{commit}}")]).status().is_ok_and(|s| s.success());
264 // The commit may be further back than a shallow fetch reaches (the
265 // branch moved on after the run began), or not one the server
266 // gives by itself: then the whole history.
267 if let Some(sha) = plan.sha.as_deref().filter(|sha| !has(sha)) {
268 let shallow = path.join(".git").join("shallow").exists();
269 let mut deepen = vec!["-c", "protocol.version=2", "fetch", "--no-tags", "--quiet"];
270 if shallow {
271 deepen.push("--unshallow");
272 }
273 deepen.extend(["origin", HEADS_REFSPEC]);
274 if !self.fetch_retrying(&path, &deepen, header) || !has(sha) {
275 self.log.line(&format!("##[error]Could not fetch {sha} from {repository}."));
276 return (false, BTreeMap::new());
277 }
278 } else if !fetched {
279 self.log.line(&format!("##[error]Could not fetch {} from {repository}.", plan.refspecs.join(" ")));
280 return (false, BTreeMap::new());
281 }
282
283 // Where to end up: a branch made at its start point, or detached.
284 let ref_exists = |name: &str| Command::new("git").current_dir(&path).args(["rev-parse", "--verify", "--quiet", name]).output().is_ok_and(|o| o.status.success());
285 let (branch, target) = match (&plan.sha, &plan.branch, &plan.unqualified) {
286 (Some(sha), branch, _) => (branch.clone(), sha.clone()),
287 (None, Some(branch), _) => (Some(branch.clone()), format!("refs/remotes/origin/{branch}")),
288 (None, None, Some(name)) if ref_exists(&format!("refs/remotes/origin/{name}")) => (Some(name.clone()), format!("refs/remotes/origin/{name}")),
289 (None, None, Some(name)) if ref_exists(&format!("refs/tags/{name}")) => (None, format!("refs/tags/{name}")),
290 (None, None, Some(name)) => {
291 self.log.line(&format!("##[error]A branch or tag with the name '{name}' could not be found"));
292 return (false, BTreeMap::new());
293 }
294 (None, None, None) => (None, plan.start.clone().unwrap_or_else(|| "FETCH_HEAD".into())),
295 };
296 if !sparse.is_empty() {
297 let mut args = vec!["sparse-checkout", "set"];
298 args.push(if yes("sparse-checkout-cone-mode", true) { "--cone" } else { "--no-cone" });
299 args.extend(sparse.iter().map(String::as_str));
300 if !self.git(&path, &args, None) {
301 return (false, BTreeMap::new());
302 }
303 }
304 let checked_out = match &branch {
305 Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None),
306 None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None),
307 };
308 if !checked_out {
309 return (false, BTreeMap::new());
310 }
311 let persist = yes("persist-credentials", true);
312 let key = format!("http.{server}/.extraheader");
313 if persist && let Some(header) = header {
314 let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status();
315 }
316 if let Some(submodules) = input("submodules").map(|s| s.to_ascii_lowercase()).filter(|s| s == "true" || s == "recursive") {
317 let recursive = submodules == "recursive";
318 // A submodule on g1t named by its SSH address is fetched over
319 // HTTPS with the token, as the action does.
320 let host = server.split_once("://").map_or(server.as_str(), |(_, host)| host).to_owned();
321 let instead = format!("url.{server}/.insteadOf=git@{host}:");
322 let mut sync = vec!["submodule", "sync", "--quiet"];
323 let mut update = vec!["-c", &instead, "-c", "protocol.version=2", "submodule", "update", "--init", "--force", "--quiet"];
324 if depth > 0 {
325 update.push(&depth_arg);
326 }
327 if recursive {
328 sync.push("--recursive");
329 update.push("--recursive");
330 }
331 if !self.git(&path, &sync, None) || !self.git_scoped(&path, &update, &server, header) {
332 self.log.line("##[error]The submodules could not all be checked out.");
333 return (false, BTreeMap::new());
334 }
335 if persist && let Some(header) = header {
336 let mut foreach = vec!["submodule", "foreach", "--quiet"];
337 if recursive {
338 foreach.push("--recursive");
339 }
340 foreach.push(r#"git config --local "$G1T_KEY" "$G1T_HEADER" && git config --local gc.auto 0"#);
341 let _ = Command::new("git").current_dir(&path).args(&foreach).env("G1T_KEY", &key).env("G1T_HEADER", header).status();
342 }
343 }
344 if yes("lfs", false) {
345 self.log.line("##[warning]Git LFS files are not fetched on g1t: g1t does not keep LFS objects yet.");
346 }
347 let commit = Command::new("git").current_dir(&path).args(["rev-parse", "HEAD"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default();
348 self.log.line(&format!("Checked out {commit}"));
349 let mut outputs = BTreeMap::new();
350 outputs.insert("ref".into(), plan.output_ref);
351 outputs.insert("commit".into(), commit);
352 (true, outputs)
353 }
354
355 /// Another repository's default branch, from what its remote says
356 /// `HEAD` is. None when it says nothing: the fetch then takes `HEAD`.
357 fn default_branch(&mut self, dir: &Path, server: &str, header: Option<&str>) -> Option<String> {
358 let mut command = Command::new("git");
359 command.current_dir(dir);
360 if let Some(header) = header {
361 command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
362 }
363 let output = command.args(["ls-remote", "--quiet", "--symref", "origin", "HEAD"]).env("GIT_TERMINAL_PROMPT", "0").output().ok()?;
364 let text = String::from_utf8_lossy(&output.stdout);
365 let branch = text.lines().find_map(|line| line.strip_prefix("ref: ")?.split_once('\t')?.0.strip_prefix("refs/heads/").map(str::to_owned))?;
366 self.log.line(&format!("Default branch '{branch}'"));
367 Some(format!("refs/heads/{branch}"))
368 }
369
370 /// Fetches another repository's action, once per job: from g1t when
371 /// g1t has the repository and this one may use it, otherwise from
372 /// GitHub. A private repository on g1t that may not be used here fails
373 /// the step, saying why, rather than fetching something else by its
374 /// name.
375 fn fetch_remote_action(&mut self, owner: &str, repo: &str, git_ref: &str) -> Option<PathBuf> {
376 let on_g1t = super::paths::under_home(ACTIONS_DIR).join("_g1t").join(owner).join(repo).join(git_ref);
377 let on_github = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
378 for dir in [&on_g1t, &on_github] {
379 if dir.join(".g1t-fetched").exists() {
380 return Some(dir.clone());
381 }
382 }
383 if !(safe(owner) && safe(repo) && safe(git_ref)) {
384 self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch."));
385 return None;
386 }
387 // The first job to use an action at a commit waits while g1t
388 // fetches its archive from GitHub, up to two minutes: the log says
389 // what it is waiting for, and that it still is, as GitHub's does.
390 self.log.line(&format!("Getting action download info for '{owner}/{repo}@{git_ref}'"));
391 let repository = format!("{owner}/{repo}");
392 let asked = (repository.clone(), git_ref.to_owned());
393 let found = self.log.waiting(
394 WAITING_EVERY,
395 move |api| api.action(&asked.0, &asked.1),
396 |seconds| format!("Waiting for g1t to fetch '{repository}@{git_ref}' ({seconds}s)"),
397 );
398 match found {
399 Ok(found) if found["source"] == "g1t" => self.fetch_g1t_action(&on_g1t, owner, repo, git_ref, &found),
400 Ok(found) => {
401 if let Some(note) = found["note"].as_str() {
402 self.log.line(&format!("##[warning]{note}"));
403 }
404 let sha = found["sha"].as_str().filter(|sha| is_sha(sha)).map(str::to_owned);
405 if let Some(sha) = &sha
406 && found["archive"] == true
407 && let Some(dir) = self.fetch_archived_action(owner, repo, git_ref, sha)
408 {
409 return Some(dir);
410 }
411 self.fetch_action(owner, repo, git_ref, sha.as_deref())
412 }
413 Err((true, why)) => {
414 self.log.line(&format!("##[error]{why}"));
415 None
416 }
417 Err((false, why)) => {
418 self.log.line(&format!("##[warning]g1t could not say where {owner}/{repo} is ({why}); fetching it from GitHub."));
419 self.fetch_action(owner, repo, git_ref, None)
420 }
421 }
422 }
423
424 /// Fetches an action from a repository on g1t, at its ref, with the
425 /// read-only token g1t gave for it when it is private.
426 fn fetch_g1t_action(&mut self, dir: &Path, owner: &str, repo: &str, git_ref: &str, found: &Value) -> Option<PathBuf> {
427 let url = found["url"].as_str().unwrap_or_default().to_owned();
428 let token = found["token"].as_str().filter(|token| !token.is_empty()).map(str::to_owned);
429 if let Some(token) = &token {
430 self.log.add_mask(token);
431 }
432 let auth = token.map(|token| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
433 self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' from g1t"));
434 let _ = std::fs::remove_dir_all(dir);
435 if std::fs::create_dir_all(dir).is_err() || !self.git(dir, &["init", "--quiet"], None) || !self.git(dir, &["remote", "add", "origin", &url], None) {
436 return None;
437 }
438 // A branch or tag at its tip; a commit may need the history.
439 let shallow = self.fetch_retrying(dir, &["fetch", "--depth=1", "--no-tags", "--quiet", "origin", git_ref], auth.as_deref());
440 let checked_out = if shallow {
441 self.git(dir, &["checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"], None)
442 } else {
443 self.fetch_retrying(dir, &["fetch", "--quiet", "--tags", "origin", "+refs/heads/*:refs/remotes/origin/*"], auth.as_deref())
444 && self.git(dir, &["checkout", "--quiet", "--force", "--detach", git_ref], None)
445 };
446 if !checked_out {
447 self.log.line(&format!("##[error]Could not fetch {owner}/{repo}@{git_ref} from g1t: is {git_ref} a branch, tag or commit there?"));
448 let _ = std::fs::remove_dir_all(dir);
449 return None;
450 }
451 let _ = std::fs::write(dir.join(".g1t-fetched"), "");
452 Some(dir.to_path_buf())
453 }
454
455 /// Downloads a GitHub action's archive that g1t keeps, at the commit
456 /// g1t resolved its ref to. None when it could not, and the job fetches
457 /// it from GitHub instead.
458 fn fetch_archived_action(&mut self, owner: &str, repo: &str, git_ref: &str, sha: &str) -> Option<PathBuf> {
459 let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
460 self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha}) from g1t"));
461 let archive = self.temp.join(format!("action{}.tar.gz", super::rand_id()));
462 let asked = (format!("{owner}/{repo}"), sha.to_owned(), archive.clone());
463 let downloaded = self.log.waiting(
464 WAITING_EVERY,
465 move |api| api.action_archive(&asked.0, &asked.1, &asked.2),
466 |seconds| format!("Downloading '{owner}/{repo}@{git_ref}' from g1t ({seconds}s)"),
467 );
468 let unpacked = match downloaded {
469 Ok(_) => {
470 let _ = std::fs::remove_dir_all(&dir);
471 let _ = std::fs::create_dir_all(&dir);
472 let mut command = Command::new("tar");
473 command.arg("-xzf").arg(&archive).arg("-C").arg(&dir).arg("--strip-components=1");
474 if cfg!(windows) {
475 // GNU tar on Windows reads `C:...` as a host and a file.
476 command.arg("--force-local");
477 }
478 let mut commands = Commands::default();
479 matches!(process::run(command, Duration::from_secs(300).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
480 }
481 Err(why) => {
482 self.log.line(&format!("g1t could not send it ({why}); fetching it from GitHub."));
483 false
484 }
485 };
486 let _ = std::fs::remove_file(&archive);
487 if !unpacked {
488 let _ = std::fs::remove_dir_all(&dir);
489 return None;
490 }
491 let _ = std::fs::write(dir.join(".g1t-fetched"), "");
492 Some(dir)
493 }
494
495 /// Fetches an action from GitHub, once per job: at `sha` when g1t
496 /// resolved its ref to one, so it is the commit g1t named.
497 fn fetch_action(&mut self, owner: &str, repo: &str, git_ref: &str, sha: Option<&str>) -> Option<PathBuf> {
498 let dir = super::paths::under_home(ACTIONS_DIR).join(owner).join(repo).join(git_ref);
499 if dir.join(".g1t-fetched").exists() {
500 return Some(dir);
501 }
502 if !(safe(owner) && safe(repo) && safe(git_ref)) {
503 self.log.line(&format!("##[error]`{owner}/{repo}@{git_ref}` is not a name g1t can fetch."));
504 return None;
505 }
506 match sha {
507 Some(sha) => self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}' (SHA:{sha})")),
508 None => self.log.line(&format!("Download action repository '{owner}/{repo}@{git_ref}'")),
509 }
510 let _ = std::fs::create_dir_all(&dir);
511 let url = format!("https://codeload.github.com/{owner}/{repo}/tar.gz/{}", sha.unwrap_or(git_ref));
512 let script = format!("set -o pipefail; curl -fsSL --retry 3 '{url}' | tar{} -xz -C '{}' --strip-components=1", super::blobs::tar_local(), dir.display());
513 let mut command = Command::new("bash");
514 command.args(["-c", &script]);
515 let mut commands = Commands::default();
516 match process::run(command, Duration::from_secs(300).min(self.remaining_time()), &mut self.log, &mut commands) {
517 Ok(Ended::Exited(0)) => {
518 let _ = std::fs::write(dir.join(".g1t-fetched"), "");
519 Some(dir)
520 }
521 _ => {
522 self.log.line(&format!("##[error]Could not download {owner}/{repo}@{git_ref} from GitHub."));
523 let _ = std::fs::remove_dir_all(&dir);
524 None
525 }
526 }
527 }
528
529 /// Downloads every action the job's steps fetch from other
530 /// repositories, before the first step runs, as GitHub's runner does in
531 /// "Set up job". False when one could not be: the job fails before
532 /// anything runs. Actions a composite action uses are fetched as it
533 /// runs.
534 pub(crate) fn prepare_actions(&mut self, steps: &[Map<String, Value>]) -> bool {
535 let mut wanted: Vec<(String, String, String)> = Vec::new();
536 for step in steps {
537 if let Some(found) = step.get("uses").and_then(Value::as_str).and_then(|uses| remote_action(uses, self.docker_hosted))
538 && !wanted.contains(&found)
539 {
540 wanted.push(found);
541 }
542 }
543 if wanted.is_empty() {
544 return true;
545 }
546 self.log.line("Prepare all required actions");
547 self.log.line("Getting action download info");
548 let mut ready = true;
549 for (owner, repo, git_ref) in &wanted {
550 if self.fetch_remote_action(owner, repo, git_ref).is_none() {
551 ready = false;
552 }
553 }
554 if !ready {
555 self.log.line("##[error]Not every action this job uses could be downloaded, so no step was run.");
556 }
557 ready
558 }
559
560 /// Runs a JavaScript file of an action with Node: the one its
561 /// `runs.using` names where the machine has it (`node_program`).
562 pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>, using: &str) -> bool {
563 let id = format!("node{}", super::rand_id());
564 let Ok(files) = StepFiles::new(&self.temp, &id) else { return false };
565 let full = self.process_env(env, &files);
566 let script_path = action_dir.join(script);
567 // In a job container whose image runs the runner's Node, the action
568 // runs there, as on GitHub.
569 let in_container = self.container.as_ref().filter(|c| c.node).map(|c| c.path.clone()).and_then(|image_path| {
570 let inside = self.container_env(env, full.clone(), &image_path);
571 self.in_container(containers::CONTAINER_NODE, &[script_path.display().to_string()], &self.workspace, inside)
572 });
573 let command = match in_container {
574 Some(command) => command,
575 None => {
576 let node = node_program(using, |name| std::env::var(name).ok(), |path| path.is_file());
577 let mut command = Command::new(node);
578 command.arg(&script_path).current_dir(&self.workspace).env_clear().envs(full);
579 command
580 }
581 };
582 let mut commands = Commands {
583 debug: self.debug,
584 ..Commands::default()
585 };
586 let ended = process::run(command, Duration::from_secs(6 * 3600).min(self.deadline_left()), &mut self.log, &mut commands);
587 let ok = matches!(ended, Ok(Ended::Exited(0)));
588 match ended {
589 Ok(Ended::Exited(code)) if code != 0 => self.log.line(&format!("##[error]The action exited with code {code}.")),
590 Ok(Ended::TimedOut) => self.log.line("##[error]The action ran past its time limit and was stopped."),
591 _ => {}
592 }
593 let (outputs, state) = self.absorb(&files, &commands);
594 self.last_node_outputs = outputs;
595 self.last_node_state = state;
596 ok
597 }
598
599 fn log_header(&mut self, uses: &str, with: &BTreeMap<String, String>, env: &BTreeMap<String, String>) {
600 for line in super::uses_header(uses, with, env) {
601 self.log.line(&line);
602 }
603 }
604
605 fn deadline_left(&self) -> Duration {
606 self.remaining_time()
607 }
608
609 /// Runs a `uses:` step. Returns whether it succeeded, and its outputs.
610 #[allow(clippy::too_many_arguments)]
611 pub(crate) fn uses(
612 &mut self,
613 uses: &str,
614 with: &BTreeMap<String, String>,
615 env: &BTreeMap<String, String>,
616 frame: &Frame,
617 title: &str,
618 action_name: &str,
619 _timeout: Duration,
620 ) -> (bool, BTreeMap<String, String>) {
621 let uses = uses.trim();
622 let (name, git_ref) = uses.split_once('@').unwrap_or((uses, ""));
623 let lower = name.to_ascii_lowercase();
624 // The step opens as GitHub's do: `Run` and the action, its inputs
625 // and variables, folded. An action fetched from a repository lists
626 // its inputs as they are read, its defaults among them.
627 let fetched = !uses.starts_with("docker://") && !native(&lower, self.docker_hosted);
628 if !fetched {
629 self.log_header(uses, with, env);
630 }
631 if let Some(image) = uses.strip_prefix("docker://") {
632 // `with.args` and `with.entrypoint` are the container's; every
633 // input is also an `INPUT_` variable, as on GitHub.
634 let mut step_env = env.clone();
635 step_env.insert("GITHUB_ACTION".into(), action_name.to_owned());
636 for (input, value) in with {
637 step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
638 }
639 let run = DockerRun {
640 image: image.to_owned(),
641 entrypoint: with.get("entrypoint").filter(|e| !e.is_empty()).cloned(),
642 args: with.get("args").map(|a| containers::split_words(a)).unwrap_or_default(),
643 env: step_env,
644 };
645 let (ok, outputs, _) = self.run_docker(&run);
646 return (ok, outputs);
647 }
648 if lower == "docker/setup-buildx-action" && native(&lower, self.docker_hosted) {
649 return self.setup_buildx(with);
650 }
651 match lower.as_str() {
652 "actions/checkout" => return self.checkout(with),
653 "actions/upload-artifact" => return self.upload_artifact(with),
654 "actions/upload-artifact/merge" => return self.merge_artifacts(with),
655 "actions/download-artifact" => return self.download_artifact(with),
656 _ => {}
657 }
658 let Some(found) = self.resolve_action(uses) else {
659 self.log_header(uses, with, env);
660 return (false, BTreeMap::new());
661 };
662 let Action { dir, local, manifest: action, .. } = &found;
663
664 // Inputs: what the step gives, else the action's defaults.
665 let env_context = env.clone();
666 let contexts = self.contexts_for(frame, &env_context);
667 let inputs = self.action_inputs(action, with, &contexts, true);
668 self.log_header(uses, &inputs, env);
669
670 let runs = action.get("runs").cloned().unwrap_or(Value::Null);
671 let using = runs.get("using").map(expr::to_text).unwrap_or_default().to_ascii_lowercase();
672 let mut step_env = action_env(env, action_name, &found, git_ref);
673
674 if using.starts_with("node") {
675 for (input, value) in &inputs {
676 step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
677 }
678 let condition_of = |key: &str| runs.get(key).map(expr::to_text).unwrap_or_else(|| "always()".into());
679 // State an action saves, `STATE_<name>` for its later stages.
680 let mut state = BTreeMap::new();
681 if let Some(pre) = runs.get("pre").map(expr::to_text) {
682 if frame.action_path.is_some() {
683 // An action inside a composite action: its `pre` right
684 // before its `main`.
685 let run_pre = self.with_scope(&contexts, |scope| expr::condition(&condition_of("pre-if"), scope)).unwrap_or(true);
686 if run_pre {
687 if !self.run_node(dir, &pre, &step_env, &using) {
688 return (false, BTreeMap::new());
689 }
690 state = std::mem::take(&mut self.last_node_state);
691 }
692 } else if *local {
693 self.log.line(&format!("##[warning]`pre` execution is not supported for local action from '{uses}'"));
694 } else {
695 // Run before the job's first step (`pre_steps`).
696 state = self.pre_state.remove(action_name).unwrap_or_default();
697 }
698 }
699 let Some(main) = runs.get("main").map(expr::to_text) else {
700 self.log.line(&format!("##[error]`{uses}` has no `runs.main`."));
701 return (false, BTreeMap::new());
702 };
703 let mut main_env = step_env.clone();
704 for (name, value) in &state {
705 main_env.insert(format!("STATE_{name}"), value.clone());
706 }
707 let ok = self.run_node(dir, &main, &main_env, &using);
708 let outputs = std::mem::take(&mut self.last_node_outputs);
709 state.extend(std::mem::take(&mut self.last_node_state));
710 if let Some(post) = runs.get("post").map(expr::to_text) {
711 let mut post_env = step_env.clone();
712 for (name, value) in state {
713 post_env.insert(format!("STATE_{name}"), value);
714 }
715 self.posts.push(Post {
716 name: format!("Post {title}"),
717 condition: condition_of("post-if"),
718 env: post_env,
719 run: PostRun::Node { action_dir: dir.clone(), script: post, using: using.clone() },
720 });
721 }
722 return (ok, outputs);
723 }
724 if using == "composite" {
725 let mut inner = Frame {
726 steps: Map::new(),
727 inputs: Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect())),
728 action_path: Some(dir.display().to_string()),
729 env: env.clone(),
730 };
731 let mut steps: Vec<Map<String, Value>> = runs.get("steps").and_then(Value::as_array).map(|s| s.iter().filter_map(|s| s.as_object().cloned()).collect()).unwrap_or_default();
732 super::name_steps(&mut steps);
733 let was_failed = self.failed;
734 // A composite's steps see their own success, not the job's.
735 self.failed = false;
736 let mut ok = true;
737 for step in &steps {
738 if !self.step(&mut inner, step, 0, false, &Map::new()) {
739 ok = false;
740 }
741 }
742 let contexts = self.contexts_for(&inner, &inner.env.clone());
743 let mut outputs = BTreeMap::new();
744 if let Some(Value::Object(declared)) = action.get("outputs") {
745 for (name, spec) in declared {
746 if let Some(value) = spec.get("value") {
747 let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
748 outputs.insert(name.clone(), expr::to_text(&value));
749 }
750 }
751 }
752 self.failed = was_failed;
753 return (ok, outputs);
754 }
755 if using == "docker" {
756 return self.docker_action(uses, dir, &runs, &inputs, &step_env, frame, title);
757 }
758 self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know."));
759 (false, BTreeMap::new())
760 }
761
762 /// A Docker action: its image built from its Dockerfile (or pulled,
763 /// for `docker://`), then run with its `args`, `entrypoint` and `env`,
764 /// its inputs as `INPUT_` variables, and `pre-entrypoint` and
765 /// `post-entrypoint` around it.
766 #[allow(clippy::too_many_arguments)]
767 fn docker_action(
768 &mut self,
769 uses: &str,
770 dir: &Path,
771 runs: &Value,
772 inputs: &BTreeMap<String, String>,
773 step_env: &BTreeMap<String, String>,
774 frame: &Frame,
775 title: &str,
776 ) -> (bool, BTreeMap<String, String>) {
777 let image = runs.get("image").map(expr::to_text).unwrap_or_default();
778 let image = if let Some(pulled) = image.strip_prefix("docker://") {
779 pulled.to_owned()
780 } else if image.is_empty() {
781 self.log.line(&format!("##[error]`{uses}` has no `runs.image`."));
782 return (false, BTreeMap::new());
783 } else {
784 match self.build_action_image(dir, &image, uses) {
785 Some(tag) => tag,
786 None => return (false, BTreeMap::new()),
787 }
788 };
789 // `args` and `env` read with the action's own inputs.
790 let mut env = step_env.clone();
791 for (input, value) in inputs {
792 env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
793 }
794 let mut scope_frame = frame.clone();
795 scope_frame.inputs = Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
796 let contexts = self.contexts_for(&scope_frame, &env);
797 if let Some(Value::Object(own)) = runs.get("env") {
798 for (name, value) in own {
799 let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
800 env.insert(name.clone(), expr::to_text(&value));
801 }
802 }
803 let args: Vec<String> = match runs.get("args") {
804 Some(Value::Array(items)) => items
805 .iter()
806 .map(|item| {
807 let value = self.with_scope(&contexts, |scope| expr::interpolate_value(item, scope)).unwrap_or(Value::Null);
808 expr::to_text(&value)
809 })
810 .collect(),
811 _ => Vec::new(),
812 };
813 let entry = |key: &str| runs.get(key).map(expr::to_text).filter(|e| !e.is_empty());
814 if let Some(pre) = entry("pre-entrypoint") {
815 let run = DockerRun { image: image.clone(), entrypoint: Some(pre), args: Vec::new(), env: env.clone() };
816 if !self.run_docker(&run).0 {
817 return (false, BTreeMap::new());
818 }
819 }
820 let run = DockerRun { image: image.clone(), entrypoint: entry("entrypoint"), args, env: env.clone() };
821 let (ok, outputs, state) = self.run_docker(&run);
822 if let Some(post) = entry("post-entrypoint") {
823 let mut post_env = env;
824 for (name, value) in state {
825 post_env.insert(format!("STATE_{name}"), value);
826 }
827 self.posts.push(Post {
828 name: format!("Post {title}"),
829 condition: runs.get("post-if").map(expr::to_text).unwrap_or_else(|| "always()".into()),
830 env: BTreeMap::new(),
831 run: PostRun::Docker(DockerRun { image, entrypoint: Some(post), args: Vec::new(), env: post_env }),
832 });
833 }
834 (ok, outputs)
835 }
836}
837
838/// An action, read from its action.yml.
839pub(crate) struct Action {
840 pub(crate) dir: PathBuf,
841 /// `owner/repo`, or empty for one in the repository.
842 pub(crate) repository: String,
843 pub(crate) local: bool,
844 pub(crate) manifest: Value,
845}
846
847/// An action's `pre`, run before the job's first step.
848pub(crate) struct PreStep {
849 /// Its step's `GITHUB_ACTION`, by which the step finds the state it saved.
850 pub(crate) key: String,
851 /// `pre-if`, `always()` when it has none.
852 pub(crate) condition: String,
853 pub(crate) action_dir: PathBuf,
854 pub(crate) script: String,
855 pub(crate) using: String,
856 /// The `env` context its condition reads.
857 pub(crate) context_env: BTreeMap<String, String>,
858 /// What its process is given: the step's variables and inputs.
859 pub(crate) env: BTreeMap<String, String>,
860}
861
862impl Job {
863 /// Finds `uses` (fetching it when it is another repository's) and reads
864 /// its action.yml. A problem is told in the log.
865 fn resolve_action(&mut self, uses: &str) -> Option<Action> {
866 let (name, git_ref) = uses.split_once('@').unwrap_or((uses, ""));
867 let source = if let Some(local) = name.strip_prefix("./") {
868 // A repository moved from GitHub renamed `.github` to `.g1t`, but
869 // its workflows still say `./.github/actions/…`.
870 let mut dir = self.workspace.join(local);
871 if let Some(rest) = local.strip_prefix(".github/")
872 && !dir.exists()
873 {
874 dir = self.workspace.join(".g1t").join(rest);
875 }
876 Source::Local(dir)
877 } else {
878 let mut parts = name.splitn(3, '/');
879 let (Some(owner), Some(repo)) = (parts.next(), parts.next()) else {
880 self.log.line(&format!("##[error]`{uses}` is not an action: use owner/repo@ref, owner/repo/path@ref, or ./path."));
881 return None;
882 };
883 if git_ref.is_empty() {
884 self.log.line(&format!("##[error]`{uses}` needs a version, such as @v4."));
885 return None;
886 }
887 Source::GitHub {
888 owner: owner.to_owned(),
889 repo: repo.to_owned(),
890 path: parts.next().unwrap_or_default().to_owned(),
891 git_ref: git_ref.to_owned(),
892 }
893 };
894 let (dir, repository) = match &source {
895 Source::Local(dir) => (dir.clone(), String::new()),
896 Source::GitHub { owner, repo, path, git_ref } => {
897 let root = self.fetch_remote_action(owner, repo, git_ref)?;
898 (if path.is_empty() { root } else { root.join(path) }, format!("{owner}/{repo}"))
899 }
900 };
901 let Some(manifest) = ["action.yml", "action.yaml"].iter().map(|f| dir.join(f)).find(|p| p.exists()) else {
902 self.log.line(&format!("##[error]`{uses}` has no action.yml."));
903 return None;
904 };
905 let Some(yaml) = std::fs::read_to_string(&manifest).ok().and_then(|text| serde_yaml::from_str::<serde_yaml::Value>(&text).ok()) else {
906 self.log.line(&format!("##[error]`{uses}`: its action.yml does not read."));
907 return None;
908 };
909 Some(Action { dir, repository, local: matches!(source, Source::Local(_)), manifest: yaml_to_json(&yaml) })
910 }
911
912 /// An action's inputs: what the step gives, else the action's defaults,
913 /// read with `contexts`. With `warn`, inputs the action does not declare
914 /// are warned of, as GitHub's runner does.
915 fn action_inputs(&mut self, action: &Value, with: &BTreeMap<String, String>, contexts: &Map<String, Value>, warn: bool) -> BTreeMap<String, String> {
916 let mut inputs: BTreeMap<String, String> = BTreeMap::new();
917 let mut declared_names = Vec::new();
918 if let Some(Value::Object(declared)) = action.get("inputs") {
919 for (input, spec) in declared {
920 declared_names.push(input.clone());
921 let given = with.iter().find(|(k, _)| k.eq_ignore_ascii_case(input)).map(|(_, v)| v.clone());
922 let value = match given {
923 Some(value) => value,
924 None => match spec.get("default") {
925 Some(default) => {
926 let default = self.with_scope(contexts, |scope| expr::interpolate_value(default, scope)).unwrap_or(Value::Null);
927 expr::to_text(&default)
928 }
929 None => String::new(),
930 },
931 };
932 inputs.insert(input.clone(), value);
933 }
934 }
935 for (key, value) in with {
936 if !inputs.keys().any(|k| k.eq_ignore_ascii_case(key)) {
937 inputs.insert(key.clone(), value.clone());
938 }
939 }
940 let docker = action["runs"]["using"].as_str().is_some_and(|u| u.eq_ignore_ascii_case("docker"));
941 if warn && let Some(warning) = unexpected_inputs(&declared_names, &with.keys().cloned().collect::<Vec<_>>(), docker) {
942 self.log.line(&format!("##[warning]{warning}"));
943 }
944 inputs
945 }
946
947 /// The `pre`s of the job's steps, in step order, each with its step's
948 /// index: those of JavaScript actions from other repositories, fetched
949 /// now, as GitHub's runner fetches every action while it sets up the
950 /// job. An action in the repository is not there yet, so its `pre` is
951 /// not run, as on GitHub.
952 pub(crate) fn pre_steps(&mut self, steps: &[Map<String, Value>]) -> Vec<(usize, PreStep)> {
953 let frame = Frame::default();
954 let mut pres = Vec::new();
955 for (index, step) in steps.iter().enumerate() {
956 let Some(uses) = step.get("uses").map(|u| expr::to_text(u).trim().to_owned()) else { continue };
957 // Only another repository's action, which `prepare_actions` has
958 // already downloaded.
959 if remote_action(&uses, self.docker_hosted).is_none() {
960 continue;
961 }
962 let Some(found) = self.resolve_action(&uses) else { continue };
963 let runs = found.manifest.get("runs").cloned().unwrap_or(Value::Null);
964 let using = runs.get("using").map(expr::to_text).unwrap_or_default().to_ascii_lowercase();
965 let Some(script) = runs.get("pre").map(expr::to_text).filter(|_| using.starts_with("node")) else { continue };
966 let env_before = self.env_context(&frame);
967 let contexts = self.contexts_for(&frame, &env_before);
968 let with = self.step_with(step, &contexts);
969 let context_env = self.step_env(step, &contexts, env_before);
970 let contexts = self.contexts_for(&frame, &context_env);
971 let inputs = self.action_inputs(&found.manifest, &with, &contexts, false);
972 let key = step.get(super::ACTION_NAME).map(expr::to_text).unwrap_or_default();
973 let git_ref = uses.split_once('@').map_or("", |(_, r)| r);
974 let mut env = action_env(&context_env, &key, &found, git_ref);
975 for (input, value) in &inputs {
976 env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
977 }
978 let condition = runs.get("pre-if").map(expr::to_text).unwrap_or_else(|| "always()".into());
979 pres.push((index, PreStep { key, condition, action_dir: found.dir, script, using, context_env, env }));
980 }
981 pres
982 }
983
984 /// Whether a `pre`'s `pre-if` holds, read against the job's status.
985 pub(crate) fn pre_runs(&self, pre: &PreStep) -> bool {
986 let contexts = self.contexts_for(&Frame::default(), &pre.context_env);
987 self.with_scope(&contexts, |scope| expr::condition(&pre.condition, scope)).unwrap_or(true)
988 }
989
990 /// Runs a `pre`, keeping the state it saves for its step's main and post.
991 pub(crate) fn run_pre(&mut self, pre: &PreStep) -> bool {
992 let ok = self.run_node(&pre.action_dir, &pre.script, &pre.env, &pre.using);
993 let state = std::mem::take(&mut self.last_node_state);
994 self.last_node_outputs.clear();
995 self.pre_state.insert(pre.key.clone(), state);
996 ok
997 }
998}
999
1000/// The variables a step running an action gets over its own: which
1001/// action it is and where.
1002fn action_env(env: &BTreeMap<String, String>, action_name: &str, action: &Action, git_ref: &str) -> BTreeMap<String, String> {
1003 let mut step_env = env.clone();
1004 step_env.insert("GITHUB_ACTION".into(), action_name.to_owned());
1005 step_env.insert("GITHUB_ACTION_REPOSITORY".into(), action.repository.clone());
1006 step_env.insert("GITHUB_ACTION_REF".into(), git_ref.to_owned());
1007 step_env.insert("GITHUB_ACTION_PATH".into(), action.dir.display().to_string());
1008 step_env
1009}
1010
1011/// GitHub's warning for inputs a step gives that its action does not
1012/// declare (a Docker action also takes `entryPoint` and `args`), if any.
1013fn unexpected_inputs(declared: &[String], given: &[String], docker: bool) -> Option<String> {
1014 let mut valid: Vec<String> = declared.to_vec();
1015 if docker {
1016 valid.extend(["entryPoint".to_owned(), "args".to_owned()]);
1017 }
1018 let unexpected: Vec<&str> = given.iter().filter(|g| !valid.iter().any(|v| v.eq_ignore_ascii_case(g))).map(String::as_str).collect();
1019 if unexpected.is_empty() {
1020 return None;
1021 }
1022 Some(format!("Unexpected input(s) '{}', valid inputs are ['{}']", unexpected.join("', '"), valid.join("', '")))
1023}
1024
1025/// The Node an action's `runs.using` asks for: for `node20` (or any
1026/// `nodeNN`), the binary `G1T_NODE20` names or `/opt/node20/bin/node`,
1027/// when there is one; otherwise, and for anything else, the `node` on
1028/// `PATH`, the runner's own (Node 24). Nothing is logged either way.
1029pub(crate) fn node_program(using: &str, var: impl Fn(&str) -> Option<String>, exists: impl Fn(&Path) -> bool) -> String {
1030 let using = using.trim().to_ascii_lowercase();
1031 let versioned = using.strip_prefix("node").is_some_and(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()));
1032 if versioned {
1033 let named = var(&format!("G1T_{}", using.to_ascii_uppercase())).filter(|p| !p.is_empty() && exists(Path::new(p)));
1034 if let Some(path) = named {
1035 return path;
1036 }
1037 let standard = format!("/opt/{using}/bin/node");
1038 if exists(Path::new(&standard)) {
1039 return standard;
1040 }
1041 }
1042 "node".into()
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 #[test]
1050 fn the_actions_fetched_before_the_first_step_are_other_repositories() {
1051 let found = |uses: &str| remote_action(uses, true);
1052 assert_eq!(found("actions/setup-node@v4"), Some(("actions".into(), "setup-node".into(), "v4".into())));
1053 assert_eq!(found(" github/codeql-action/init@v3 "), Some(("github".into(), "codeql-action".into(), "v3".into())));
1054 // Done by the runner itself, local, an image, or no version.
1055 assert_eq!(found("actions/checkout@v4"), None);
1056 // actions/cache is the real action, fetched like any other.
1057 assert_eq!(found("Actions/Cache/Restore@v4"), Some(("Actions".into(), "Cache".into(), "v4".into())));
1058 assert_eq!(found("docker/setup-buildx-action@v3"), None);
1059 assert!(remote_action("docker/setup-buildx-action@v3", false).is_some());
1060 assert_eq!(found("./.g1t/actions/setup"), None);
1061 assert_eq!(found("docker://alpine:3"), None);
1062 assert_eq!(found("actions/setup-node"), None);
1063 assert_eq!(found("setup-node@v4"), None);
1064 }
1065
1066 #[test]
1067 fn a_checkout_fetches_what_actions_checkout_fetches() {
1068 let sha = "0123456789abcdef0123456789abcdef01234567";
1069 let specs = |plan: &CheckoutPlan| plan.refspecs.clone();
1070 // A push: the run's commit, as its branch.
1071 let push = checkout_plan(None, true, "refs/heads/main", sha);
1072 assert_eq!(specs(&push), [format!("+{sha}:refs/remotes/origin/main")]);
1073 assert_eq!((push.sha.as_deref(), push.branch.as_deref(), push.output_ref.as_str()), (Some(sha), Some("main"), "refs/heads/main"));
1074 // An unqualified branch from the event is qualified first.
1075 assert_eq!(checkout_plan(None, true, "main", sha).output_ref, "refs/heads/main");
1076 // A pull request: its commit under refs/remotes/pull, detached.
1077 let pull = checkout_plan(None, true, "refs/pull/7/merge", sha);
1078 assert_eq!(specs(&pull), [format!("+{sha}:refs/remotes/pull/7/merge")]);
1079 assert_eq!(pull.branch, None);
1080 // A tag push, and a deployment of a bare commit.
1081 assert_eq!(specs(&checkout_plan(None, true, "refs/tags/v1", sha)), [format!("+{sha}:refs/tags/v1")]);
1082 assert_eq!(specs(&checkout_plan(None, true, "", sha)), [sha]);
1083 // `ref:` a commit, a branch, a tag or a name that may be either.
1084 let by_sha = checkout_plan(Some(sha), true, "refs/heads/main", "ffff");
1085 assert_eq!((specs(&by_sha), by_sha.sha.as_deref(), by_sha.output_ref.as_str()), (vec![sha.to_owned()], Some(sha), ""));
1086 let branch = checkout_plan(Some("refs/heads/dev"), true, "refs/heads/main", sha);
1087 assert_eq!((specs(&branch), branch.sha, branch.branch.as_deref()), (vec!["+refs/heads/dev:refs/remotes/origin/dev".to_owned()], None, Some("dev")));
1088 let tag = checkout_plan(Some("refs/tags/v2"), true, "", "");
1089 assert_eq!((specs(&tag), tag.start.as_deref()), (vec!["+refs/tags/v2:refs/tags/v2".to_owned()], Some("refs/tags/v2")));
1090 let name = checkout_plan(Some("v2"), false, "", "");
1091 assert_eq!(specs(&name), ["+refs/heads/v2*:refs/remotes/origin/v2*", "+refs/tags/v2*:refs/tags/v2*"]);
1092 assert_eq!(name.unqualified.as_deref(), Some("v2"));
1093 // Another repository whose default branch is not known: its HEAD.
1094 assert_eq!(specs(&checkout_plan(None, false, "refs/heads/main", sha)), ["HEAD"]);
1095 }
1096
1097 /// Checks out repositories of its own over `file://`, where git is
1098 /// installed: the run's commit behind its branch's tip, all history,
1099 /// a tag and a branch by name, a sparse checkout, and another
1100 /// repository's default branch.
1101 #[test]
1102 fn a_checkout_ends_where_actions_checkout_ends() {
1103 let git = |dir: &Path, args: &[&str]| {
1104 let out = Command::new("git").current_dir(dir).args(args).output().expect("git");
1105 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1106 String::from_utf8_lossy(&out.stdout).trim().to_owned()
1107 };
1108 if Command::new("git").arg("--version").output().is_err() {
1109 return;
1110 }
1111 let root = std::env::temp_dir().join(format!("g1t-checkout-test-{}", std::process::id()));
1112 let _ = std::fs::remove_dir_all(&root);
1113 let make = |name: &str, branch: &str| {
1114 let dir = root.join("src").join(name);
1115 std::fs::create_dir_all(&dir).unwrap();
1116 git(&dir, &["init", "--quiet", "-b", branch]);
1117 for (key, value) in [("user.name", "t"), ("user.email", "t@example.com"), ("commit.gpgsign", "false"), ("tag.gpgsign", "false")] {
1118 git(&dir, &["config", key, value]);
1119 }
1120 dir
1121 };
1122 let commit = |dir: &Path, file: &str, message: &str| {
1123 std::fs::create_dir_all(dir.join(file).parent().unwrap()).unwrap();
1124 std::fs::write(dir.join(file), message).unwrap();
1125 git(dir, &["add", "."]);
1126 git(dir, &["commit", "--quiet", "-m", message]);
1127 };
1128 let app = make("app", "main");
1129 commit(&app, "docs/a.md", "one");
1130 commit(&app, "src/main.rs", "two");
1131 git(&app, &["tag", "v1"]);
1132 git(&app, &["checkout", "--quiet", "-b", "feature"]);
1133 commit(&app, "feature.txt", "feature");
1134 git(&app, &["checkout", "--quiet", "main"]);
1135 commit(&app, "README.md", "three");
1136 let other = make("other", "trunk");
1137 commit(&other, "o.txt", "o");
1138 let served = root.join("srv").join("acme");
1139 std::fs::create_dir_all(&served).unwrap();
1140 for name in ["app", "other"] {
1141 let src = root.join("src").join(name);
1142 git(&served, &["clone", "--quiet", "--bare", &src.display().to_string(), &format!("{name}.git")]);
1143 git(&served.join(format!("{name}.git")), &["config", "uploadpack.allowFilter", "true"]);
1144 }
1145 let server = format!("file://{}", root.join("srv").display());
1146 let behind = git(&app, &["rev-parse", "main~1"]);
1147 let spec = json!({
1148 "checkout": { "repository": "acme/app", "url": format!("{server}/acme/app.git"), "ref": "refs/heads/main", "sha": behind, "token": "" },
1149 "github": { "server_url": server },
1150 "variables": {},
1151 "contexts": {},
1152 });
1153 let workspace = root.join("work");
1154 let mut job = super::super::setup_in(spec, super::super::report::Api::offline(), workspace.clone(), root.join("temp")).unwrap();
1155 let mut run = |inputs: &[(&str, &str)]| {
1156 let mut with: BTreeMap<String, String> = inputs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1157 with.insert("set-safe-directory".into(), "false".into());
1158 job.checkout(&with)
1159 };
1160
1161 // A push whose branch moved on: the run's commit, on its branch.
1162 let (ok, outputs) = run(&[]);
1163 assert!(ok);
1164 assert_eq!((git(&workspace, &["rev-parse", "HEAD"]), git(&workspace, &["branch", "--show-current"])), (behind.clone(), "main".to_owned()));
1165 assert_eq!((outputs["ref"].as_str(), outputs["commit"].as_str()), ("refs/heads/main", behind.as_str()));
1166 // All history: every branch and tag.
1167 assert!(run(&[("fetch-depth", "0")]).0);
1168 assert!(!workspace.join(".git/shallow").exists());
1169 assert_eq!(git(&workspace, &["branch", "-r"]).split_whitespace().collect::<Vec<_>>(), ["origin/feature", "origin/main"]);
1170 assert_eq!(git(&workspace, &["tag"]), "v1");
1171 // A tag by name, detached; a branch by name, as the branch.
1172 assert!(run(&[("ref", "v1")]).0);
1173 assert_eq!((git(&workspace, &["describe", "--tags"]), git(&workspace, &["branch", "--show-current"])), ("v1".to_owned(), String::new()));
1174 assert!(run(&[("ref", "feature")]).0);
1175 assert_eq!(git(&workspace, &["branch", "--show-current"]), "feature");
1176 // Sparse: only the folders named, from a blobless fetch.
1177 assert!(run(&[("sparse-checkout", "docs\n")]).0);
1178 assert!(workspace.join("docs/a.md").exists() && !workspace.join("src").exists());
1179 assert_eq!(git(&workspace, &["config", "remote.origin.partialclonefilter"]), "blob:none");
1180 // Another repository without a ref: its default branch.
1181 let (ok, outputs) = run(&[("repository", "acme/other"), ("path", "other")]);
1182 assert!(ok);
1183 assert_eq!(git(&workspace.join("other"), &["branch", "--show-current"]), "trunk");
1184 assert_eq!(outputs["ref"], "refs/heads/trunk");
1185 let _ = std::fs::remove_dir_all(&root);
1186 }
1187
1188 #[test]
1189 fn unexpected_inputs_are_named_with_the_valid_ones() {
1190 let declared = vec!["token".to_owned(), "path".to_owned()];
1191 assert_eq!(unexpected_inputs(&declared, &["Token".into()], false), None);
1192 assert_eq!(
1193 unexpected_inputs(&declared, &["token".into(), "foo".into(), "bar".into()], false).as_deref(),
1194 Some("Unexpected input(s) 'foo', 'bar', valid inputs are ['token', 'path']")
1195 );
1196 assert_eq!(unexpected_inputs(&[], &["x".into()], false).as_deref(), Some("Unexpected input(s) 'x', valid inputs are ['']"));
1197 // A Docker action takes `entrypoint` and `args` too.
1198 assert_eq!(unexpected_inputs(&declared, &["entrypoint".into(), "args".into()], true), None);
1199 assert!(unexpected_inputs(&declared, &["args".into()], false).is_some());
1200 }
1201
1202 #[test]
1203 fn node20_runs_with_a_node20_where_there_is_one() {
1204 let none = |_: &str| None;
1205 assert_eq!(node_program("node20", none, |_| false), "node");
1206 assert_eq!(node_program("node20", none, |p| p == Path::new("/opt/node20/bin/node")), "/opt/node20/bin/node");
1207 let named = |name: &str| (name == "G1T_NODE20").then(|| "/usr/local/n20/bin/node".to_owned());
1208 assert_eq!(node_program("Node20", named, |_| true), "/usr/local/n20/bin/node");
1209 // A named Node that is not there is passed over.
1210 assert_eq!(node_program("node20", named, |p| p == Path::new("/opt/node20/bin/node")), "/opt/node20/bin/node");
1211 assert_eq!(node_program("node24", none, |_| false), "node");
1212 assert_eq!(node_program("composite", none, |_| true), "node");
1213 assert_eq!(node_program("node", none, |_| true), "node");
1214 }
1215}