g1t/crates/runner/src/actions/files.rs
| 1 | //! The files a step writes to talk back (`GITHUB_OUTPUT`, `GITHUB_ENV`, |
| 2 | //! `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY`), and `hashFiles`. |
| 3 | |
| 4 | use std::collections::BTreeMap; |
| 5 | use std::io::Read; |
| 6 | use std::path::{Path, PathBuf}; |
| 7 | |
| 8 | use g1t_actions::filter::Patterns; |
| 9 | use sha2::{Digest, Sha256}; |
| 10 | |
| 11 | /// `name=value` lines and `name<<DELIMITER` … `DELIMITER` blocks. |
| 12 | pub(crate) fn key_values(text: &str) -> Result<BTreeMap<String, String>, String> { |
| 13 | let mut out = BTreeMap::new(); |
| 14 | let mut lines = text.lines(); |
| 15 | while let Some(line) = lines.next() { |
| 16 | if line.trim().is_empty() { |
| 17 | continue; |
| 18 | } |
| 19 | let heredoc = line.find("<<"); |
| 20 | let equals = line.find('='); |
| 21 | match (heredoc, equals) { |
| 22 | (Some(at), eq) if eq.is_none_or(|eq| at < eq) => { |
| 23 | let name = line[..at].to_owned(); |
| 24 | let delimiter = &line[at + 2..]; |
| 25 | if name.is_empty() || delimiter.is_empty() { |
| 26 | return Err(format!("`{line}` is not a name and a delimiter.")); |
| 27 | } |
| 28 | let mut value = Vec::new(); |
| 29 | let mut closed = false; |
| 30 | for body in lines.by_ref() { |
| 31 | if body == delimiter { |
| 32 | closed = true; |
| 33 | break; |
| 34 | } |
| 35 | value.push(body); |
| 36 | } |
| 37 | if !closed { |
| 38 | return Err(format!("The value of `{name}` never reaches its delimiter `{delimiter}`.")); |
| 39 | } |
| 40 | out.insert(name, value.join("\n")); |
| 41 | } |
| 42 | (_, Some(eq)) => { |
| 43 | out.insert(line[..eq].to_owned(), line[eq + 1..].to_owned()); |
| 44 | } |
| 45 | _ => return Err(format!("`{line}` is not `name=value`.")), |
| 46 | } |
| 47 | } |
| 48 | Ok(out) |
| 49 | } |
| 50 | |
| 51 | /// The files of one step, made empty before it runs. |
| 52 | pub(crate) struct StepFiles { |
| 53 | pub(crate) output: PathBuf, |
| 54 | pub(crate) env: PathBuf, |
| 55 | pub(crate) path: PathBuf, |
| 56 | pub(crate) state: PathBuf, |
| 57 | pub(crate) summary: PathBuf, |
| 58 | } |
| 59 | |
| 60 | impl StepFiles { |
| 61 | pub(crate) fn new(temp: &Path, id: &str) -> std::io::Result<StepFiles> { |
| 62 | let dir = temp.join("_runner_file_commands"); |
| 63 | std::fs::create_dir_all(&dir)?; |
| 64 | let files = StepFiles { |
| 65 | output: dir.join(format!("set_output_{id}")), |
| 66 | env: dir.join(format!("set_env_{id}")), |
| 67 | path: dir.join(format!("add_path_{id}")), |
| 68 | state: dir.join(format!("save_state_{id}")), |
| 69 | summary: dir.join(format!("step_summary_{id}")), |
| 70 | }; |
| 71 | for file in [&files.output, &files.env, &files.path, &files.state, &files.summary] { |
| 72 | std::fs::write(file, "")?; |
| 73 | } |
| 74 | Ok(files) |
| 75 | } |
| 76 | |
| 77 | pub(crate) fn read(path: &Path) -> String { |
| 78 | let mut text = String::new(); |
| 79 | if let Ok(mut file) = std::fs::File::open(path) { |
| 80 | let _ = file.read_to_string(&mut text); |
| 81 | } |
| 82 | text |
| 83 | } |
| 84 | |
| 85 | pub(crate) fn variables(&self) -> [(&'static str, String); 5] { |
| 86 | [ |
| 87 | ("GITHUB_OUTPUT", self.output.display().to_string()), |
| 88 | ("GITHUB_ENV", self.env.display().to_string()), |
| 89 | ("GITHUB_PATH", self.path.display().to_string()), |
| 90 | ("GITHUB_STATE", self.state.display().to_string()), |
| 91 | ("GITHUB_STEP_SUMMARY", self.summary.display().to_string()), |
| 92 | ] |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) { |
| 97 | let Ok(entries) = std::fs::read_dir(dir) else { return }; |
| 98 | for entry in entries.flatten() { |
| 99 | let path = entry.path(); |
| 100 | let Ok(kind) = entry.file_type() else { continue }; |
| 101 | if kind.is_dir() { |
| 102 | if entry.file_name() == ".git" { |
| 103 | continue; |
| 104 | } |
| 105 | walk(root, &path, out); |
| 106 | } else if kind.is_file() |
| 107 | && let Ok(relative) = path.strip_prefix(root) |
| 108 | { |
| 109 | out.push(relative.to_string_lossy().replace('\\', "/")); |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// `hashFiles(patterns)`: the SHA-256 of the SHA-256 of each matching file |
| 115 | /// in the workspace, in path order; empty when nothing matches. |
| 116 | pub(crate) fn hash_files(workspace: &Path, patterns: &[String]) -> String { |
| 117 | // Patterns may be absolute under the workspace, or relative to it. |
| 118 | let prefix = format!("{}/", workspace.display()); |
| 119 | let relative: Vec<String> = patterns.iter().map(|p| p.strip_prefix(&prefix).unwrap_or(p).to_owned()).collect(); |
| 120 | let patterns = Patterns::new(&relative); |
| 121 | let mut files = Vec::new(); |
| 122 | walk(workspace, workspace, &mut files); |
| 123 | files.sort(); |
| 124 | let mut all = Sha256::new(); |
| 125 | let mut any = false; |
| 126 | for file in files.iter().filter(|file| patterns.includes(file)) { |
| 127 | let Ok(bytes) = std::fs::read(workspace.join(file)) else { continue }; |
| 128 | all.update(Sha256::digest(&bytes)); |
| 129 | any = true; |
| 130 | } |
| 131 | if any { hex::encode(all.finalize()) } else { String::new() } |
| 132 | } |
| 133 | |
| 134 | #[cfg(test)] |
| 135 | mod tests { |
| 136 | use super::*; |
| 137 | |
| 138 | #[test] |
| 139 | fn values_and_heredocs() { |
| 140 | let text = "version=1.2.3\nnotes<<EOF\nline one\nline=two\nEOF\nempty=\n"; |
| 141 | let values = key_values(text).unwrap(); |
| 142 | assert_eq!(values["version"], "1.2.3"); |
| 143 | assert_eq!(values["notes"], "line one\nline=two"); |
| 144 | assert_eq!(values["empty"], ""); |
| 145 | assert!(key_values("x<<EOF\nnever closed").is_err()); |
| 146 | assert!(key_values("no equals").is_err()); |
| 147 | // A value holding `<<` after its `=` is a plain value. |
| 148 | assert_eq!(key_values("cmd=a << b").unwrap()["cmd"], "a << b"); |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn hashes_files_by_pattern() { |
| 153 | let dir = std::env::temp_dir().join(format!("g1t-hash-{}", std::process::id())); |
| 154 | std::fs::create_dir_all(dir.join("a")).unwrap(); |
| 155 | std::fs::write(dir.join("a/package-lock.json"), "{}").unwrap(); |
| 156 | std::fs::write(dir.join("README.md"), "hi").unwrap(); |
| 157 | let one = hash_files(&dir, &["**/package-lock.json".to_owned()]); |
| 158 | assert_eq!(one.len(), 64); |
| 159 | assert_eq!(one, hash_files(&dir, &["**/package-lock.json".to_owned()])); |
| 160 | assert_ne!(one, hash_files(&dir, &["**/*".to_owned()])); |
| 161 | assert_eq!(hash_files(&dir, &["**/Cargo.lock".to_owned()]), ""); |
| 162 | let _ = std::fs::remove_dir_all(&dir); |
| 163 | } |
| 164 | } |