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.
| GitHub Actions on g1t, part two: running workflows | 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 | } |