Skip to main content

wrkflw_executor/
github_env_files.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5/// Parsed results from all 4 GitHub Actions environment files after a step runs.
6#[derive(Default)]
7pub struct StepEnvironmentUpdates {
8    /// Key-value pairs from GITHUB_OUTPUT
9    pub outputs: HashMap<String, String>,
10    /// Key-value pairs from GITHUB_ENV
11    pub env_vars: HashMap<String, String>,
12    /// Path entries from GITHUB_PATH (one per line)
13    pub path_entries: Vec<String>,
14    /// Accumulated markdown from GITHUB_STEP_SUMMARY
15    pub step_summary: String,
16}
17
18/// Check whether `s` looks like a valid GHA environment file key: `[a-zA-Z_][a-zA-Z0-9_]*`.
19///
20/// This validates keys used in GITHUB_OUTPUT and GITHUB_ENV files (e.g. `MY_VAR=value`),
21/// NOT step IDs — step IDs additionally allow hyphens (see `STEPS_OUTPUT_PATTERN`).
22fn is_valid_identifier(s: &str) -> bool {
23    let mut chars = s.chars();
24    match chars.next() {
25        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
26        _ => return false,
27    }
28    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
29}
30
31/// Parse the GitHub Actions key-value file format used by GITHUB_OUTPUT and GITHUB_ENV.
32///
33/// Supports two formats:
34/// - Simple: `key=value`
35/// - Multiline heredoc: `key<<DELIMITER\nline1\nline2\nDELIMITER`
36pub fn parse_github_kv_file(content: &str) -> HashMap<String, String> {
37    let mut result = HashMap::new();
38    let lines: Vec<&str> = content.lines().collect();
39    let mut i = 0;
40
41    while i < lines.len() {
42        let line = lines[i];
43
44        // Skip empty lines
45        if line.is_empty() {
46            i += 1;
47            continue;
48        }
49
50        // Check for heredoc format: key<<DELIMITER
51        // The key must be a valid identifier (no '=' allowed) to avoid ambiguity
52        // with simple values that contain '<<'.
53        if let Some(heredoc_sep_pos) = line.find("<<") {
54            let key = &line[..heredoc_sep_pos];
55            let delimiter = &line[heredoc_sep_pos + 2..];
56
57            if !key.is_empty() && !delimiter.is_empty() && is_valid_identifier(key) {
58                // Collect lines until we find the delimiter
59                let mut value_lines = Vec::new();
60                i += 1;
61                while i < lines.len() {
62                    if lines[i] == delimiter {
63                        break;
64                    }
65                    value_lines.push(lines[i]);
66                    i += 1;
67                }
68                result.insert(key.to_string(), value_lines.join("\n"));
69                i += 1; // skip the closing delimiter
70                continue;
71            }
72        }
73
74        // Simple key=value format — split on first '=' only
75        if let Some(eq_pos) = line.find('=') {
76            let key = &line[..eq_pos];
77            let value = &line[eq_pos + 1..];
78            if !key.is_empty() {
79                result.insert(key.to_string(), value.to_string());
80            }
81        }
82
83        i += 1;
84    }
85
86    result
87}
88
89/// Parse GITHUB_PATH file — one path entry per non-empty line.
90pub fn parse_github_path_file(content: &str) -> Vec<String> {
91    content
92        .lines()
93        .map(|l| l.trim())
94        .filter(|l| !l.is_empty())
95        .map(|l| l.to_string())
96        .collect()
97}
98
99/// Read all 4 environment files using HOST paths from `job_env` and return parsed updates.
100///
101/// Missing or unreadable files are silently treated as empty — this is expected when
102/// steps don't write to them.
103pub fn read_step_environment_updates(job_env: &HashMap<String, String>) -> StepEnvironmentUpdates {
104    let mut updates = StepEnvironmentUpdates::default();
105
106    if let Some(path) = job_env.get("GITHUB_OUTPUT") {
107        if let Ok(content) = fs::read_to_string(Path::new(path)) {
108            if !content.is_empty() {
109                updates.outputs = parse_github_kv_file(&content);
110            }
111        }
112    }
113
114    if let Some(path) = job_env.get("GITHUB_ENV") {
115        if let Ok(content) = fs::read_to_string(Path::new(path)) {
116            if !content.is_empty() {
117                updates.env_vars = parse_github_kv_file(&content);
118            }
119        }
120    }
121
122    if let Some(path) = job_env.get("GITHUB_PATH") {
123        if let Ok(content) = fs::read_to_string(Path::new(path)) {
124            if !content.is_empty() {
125                updates.path_entries = parse_github_path_file(&content);
126            }
127        }
128    }
129
130    if let Some(path) = job_env.get("GITHUB_STEP_SUMMARY") {
131        if let Ok(content) = fs::read_to_string(Path::new(path)) {
132            updates.step_summary = content;
133        }
134    }
135
136    updates
137}
138
139/// Apply environment updates from a completed step to the job state.
140///
141/// - Stores step outputs keyed by step ID (for `${{ steps.<id>.outputs.<key> }}`)
142/// - Merges GITHUB_ENV entries into `job_env`
143/// - Prepends GITHUB_PATH entries to the PATH in `job_env`
144/// - Clears per-step files so the next step starts fresh
145pub fn apply_step_environment_updates(
146    job_env: &mut HashMap<String, String>,
147    job_user_env: &mut HashMap<String, String>,
148    step_outputs_map: &mut HashMap<String, HashMap<String, String>>,
149    step_id: Option<&str>,
150) {
151    let updates = read_step_environment_updates(job_env);
152
153    // Store step outputs keyed by step ID for ${{ steps.<id>.outputs.<key> }}
154    if let Some(id) = step_id {
155        step_outputs_map.insert(id.to_string(), updates.outputs);
156    }
157
158    // Merge GITHUB_ENV entries into job_env for subsequent steps.
159    // These are user-declared by definition (the step wrote them via
160    // `echo KEY=VAL >> $GITHUB_ENV`), so mirror into job_user_env too.
161    // GITHUB_PATH updates below modify PATH in job_env only — PATH is not
162    // a user-declared env var and must not leak into toJSON(env).
163    for (k, v) in updates.env_vars {
164        job_user_env.insert(k.clone(), v.clone());
165        job_env.insert(k, v);
166    }
167
168    // Prepend GITHUB_PATH entries to PATH for subsequent steps
169    if !updates.path_entries.is_empty() {
170        let current_path = job_env
171            .get("PATH")
172            .cloned()
173            .or_else(|| std::env::var("PATH").ok())
174            .unwrap_or_default();
175        let new_entries = updates.path_entries.join(":");
176        let new_path = if current_path.is_empty() {
177            new_entries
178        } else {
179            format!("{}:{}", new_entries, current_path)
180        };
181        job_env.insert("PATH".to_string(), new_path);
182    }
183
184    // Clear files so the next step doesn't re-process these entries
185    clear_step_files(job_env);
186}
187
188/// Truncate environment files between steps.
189///
190/// GITHUB_OUTPUT is per-step (not cumulative).
191/// GITHUB_ENV and GITHUB_PATH are cumulative *on disk* in real GHA, but we read back
192/// and merge their contents into `job_env` after each step. To avoid re-processing
193/// the same entries on the next step, we truncate them here as well.
194/// GITHUB_STEP_SUMMARY is intentionally not cleared — in real GHA, step summaries are
195/// cumulative (each step appends to the same file).
196pub fn clear_step_files(job_env: &HashMap<String, String>) {
197    for key in &["GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH"] {
198        if let Some(path) = job_env.get(*key) {
199            let _ = fs::write(Path::new(path), "");
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use tempfile::tempdir;
208
209    #[test]
210    fn parse_simple_kv() {
211        let content = "key=value\nother=thing";
212        let result = parse_github_kv_file(content);
213        assert_eq!(result.get("key").unwrap(), "value");
214        assert_eq!(result.get("other").unwrap(), "thing");
215    }
216
217    #[test]
218    fn parse_heredoc() {
219        let content = "body<<EOF\nline1\nline2\nEOF";
220        let result = parse_github_kv_file(content);
221        assert_eq!(result.get("body").unwrap(), "line1\nline2");
222    }
223
224    #[test]
225    fn parse_heredoc_custom_delimiter() {
226        let content = "msg<<DELIM_123\nhello world\nDELIM_123";
227        let result = parse_github_kv_file(content);
228        assert_eq!(result.get("msg").unwrap(), "hello world");
229    }
230
231    #[test]
232    fn parse_mixed_formats() {
233        let content = "simple=val\nmulti<<END\nfoo\nbar\nEND\nanother=baz";
234        let result = parse_github_kv_file(content);
235        assert_eq!(result.get("simple").unwrap(), "val");
236        assert_eq!(result.get("multi").unwrap(), "foo\nbar");
237        assert_eq!(result.get("another").unwrap(), "baz");
238    }
239
240    #[test]
241    fn parse_empty_input() {
242        let result = parse_github_kv_file("");
243        assert!(result.is_empty());
244    }
245
246    #[test]
247    fn parse_value_with_equals() {
248        let content = "url=https://example.com?a=1&b=2";
249        let result = parse_github_kv_file(content);
250        assert_eq!(result.get("url").unwrap(), "https://example.com?a=1&b=2");
251    }
252
253    #[test]
254    fn parse_empty_value() {
255        let content = "empty=";
256        let result = parse_github_kv_file(content);
257        assert_eq!(result.get("empty").unwrap(), "");
258    }
259
260    #[test]
261    fn parse_skips_blank_lines() {
262        let content = "\nkey=value\n\nother=thing\n";
263        let result = parse_github_kv_file(content);
264        assert_eq!(result.len(), 2);
265        assert_eq!(result.get("key").unwrap(), "value");
266    }
267
268    #[test]
269    fn parse_path_file() {
270        let content = "/usr/local/bin\n/opt/tools\n";
271        let result = parse_github_path_file(content);
272        assert_eq!(result, vec!["/usr/local/bin", "/opt/tools"]);
273    }
274
275    #[test]
276    fn parse_path_file_skips_blank_lines() {
277        let content = "\n/first\n\n/second\n";
278        let result = parse_github_path_file(content);
279        assert_eq!(result, vec!["/first", "/second"]);
280    }
281
282    #[test]
283    fn read_missing_files_returns_empty() {
284        let mut env = HashMap::new();
285        env.insert(
286            "GITHUB_OUTPUT".to_string(),
287            "/nonexistent/path/output".to_string(),
288        );
289        let updates = read_step_environment_updates(&env);
290        assert!(updates.outputs.is_empty());
291        assert!(updates.env_vars.is_empty());
292        assert!(updates.path_entries.is_empty());
293        assert!(updates.step_summary.is_empty());
294    }
295
296    #[test]
297    fn read_and_clear_round_trip() {
298        let dir = tempdir().unwrap();
299        let output_path = dir.path().join("output");
300        let env_path = dir.path().join("env");
301        let path_path = dir.path().join("path");
302        fs::write(&output_path, "version=1.2.3\n").unwrap();
303        fs::write(&env_path, "MY_VAR=hello\n").unwrap();
304        fs::write(&path_path, "/new/bin\n").unwrap();
305
306        let mut env = HashMap::new();
307        env.insert(
308            "GITHUB_OUTPUT".to_string(),
309            output_path.to_string_lossy().to_string(),
310        );
311        env.insert(
312            "GITHUB_ENV".to_string(),
313            env_path.to_string_lossy().to_string(),
314        );
315        env.insert(
316            "GITHUB_PATH".to_string(),
317            path_path.to_string_lossy().to_string(),
318        );
319
320        let updates = read_step_environment_updates(&env);
321        assert_eq!(updates.outputs.get("version").unwrap(), "1.2.3");
322        assert_eq!(updates.env_vars.get("MY_VAR").unwrap(), "hello");
323        assert_eq!(updates.path_entries, vec!["/new/bin"]);
324
325        clear_step_files(&env);
326        assert!(fs::read_to_string(&output_path).unwrap().is_empty());
327        assert!(fs::read_to_string(&env_path).unwrap().is_empty());
328        assert!(fs::read_to_string(&path_path).unwrap().is_empty());
329    }
330
331    #[test]
332    fn read_all_four_files() {
333        let dir = tempdir().unwrap();
334        let github_dir = dir.path().join("github");
335        fs::create_dir_all(&github_dir).unwrap();
336
337        fs::write(github_dir.join("output"), "result=ok\n").unwrap();
338        fs::write(github_dir.join("env"), "MY_VAR=hello\n").unwrap();
339        fs::write(github_dir.join("path"), "/new/path\n").unwrap();
340        fs::write(github_dir.join("step_summary"), "## Summary\nAll good").unwrap();
341
342        let mut env = HashMap::new();
343        env.insert(
344            "GITHUB_OUTPUT".to_string(),
345            github_dir.join("output").to_string_lossy().to_string(),
346        );
347        env.insert(
348            "GITHUB_ENV".to_string(),
349            github_dir.join("env").to_string_lossy().to_string(),
350        );
351        env.insert(
352            "GITHUB_PATH".to_string(),
353            github_dir.join("path").to_string_lossy().to_string(),
354        );
355        env.insert(
356            "GITHUB_STEP_SUMMARY".to_string(),
357            github_dir
358                .join("step_summary")
359                .to_string_lossy()
360                .to_string(),
361        );
362
363        let updates = read_step_environment_updates(&env);
364        assert_eq!(updates.outputs.get("result").unwrap(), "ok");
365        assert_eq!(updates.env_vars.get("MY_VAR").unwrap(), "hello");
366        assert_eq!(updates.path_entries, vec!["/new/path"]);
367        assert_eq!(updates.step_summary, "## Summary\nAll good");
368    }
369
370    #[test]
371    fn parse_value_containing_heredoc_marker() {
372        // A value like `url=https://example.com/path<<EOF` should be parsed as simple
373        // key=value, NOT as a heredoc, because the text before `<<` contains `=` and
374        // is therefore not a valid identifier.
375        let content = "url=https://example.com/path<<EOF";
376        let result = parse_github_kv_file(content);
377        assert_eq!(result.get("url").unwrap(), "https://example.com/path<<EOF");
378    }
379
380    #[test]
381    fn parse_unterminated_heredoc() {
382        // Unterminated heredoc should consume to EOF and produce the collected lines.
383        let content = "body<<EOF\nline1\nline2";
384        let result = parse_github_kv_file(content);
385        assert_eq!(result.get("body").unwrap(), "line1\nline2");
386    }
387
388    #[test]
389    fn parse_heredoc_in_output_format() {
390        // GITHUB_OUTPUT can use heredoc format for multiline values.
391        let content = "json<<EOF\n{\"key\": \"value\"}\nEOF\nversion=1.0";
392        let result = parse_github_kv_file(content);
393        assert_eq!(result.get("json").unwrap(), "{\"key\": \"value\"}");
394        assert_eq!(result.get("version").unwrap(), "1.0");
395    }
396
397    #[test]
398    fn apply_updates_merges_env_and_path() {
399        let dir = tempdir().unwrap();
400        let github_dir = dir.path().join("github");
401        fs::create_dir_all(&github_dir).unwrap();
402
403        fs::write(github_dir.join("output"), "artifact=build.tar\n").unwrap();
404        fs::write(github_dir.join("env"), "CC=gcc\n").unwrap();
405        fs::write(github_dir.join("path"), "/opt/gcc/bin\n").unwrap();
406
407        let mut job_env = HashMap::new();
408        job_env.insert(
409            "GITHUB_OUTPUT".to_string(),
410            github_dir.join("output").to_string_lossy().to_string(),
411        );
412        job_env.insert(
413            "GITHUB_ENV".to_string(),
414            github_dir.join("env").to_string_lossy().to_string(),
415        );
416        job_env.insert(
417            "GITHUB_PATH".to_string(),
418            github_dir.join("path").to_string_lossy().to_string(),
419        );
420        job_env.insert("PATH".to_string(), "/usr/bin".to_string());
421
422        let mut step_outputs_map = HashMap::new();
423        let mut job_user_env = HashMap::new();
424
425        apply_step_environment_updates(
426            &mut job_env,
427            &mut job_user_env,
428            &mut step_outputs_map,
429            Some("build"),
430        );
431
432        // Step outputs stored under step ID
433        assert_eq!(
434            step_outputs_map
435                .get("build")
436                .unwrap()
437                .get("artifact")
438                .unwrap(),
439            "build.tar"
440        );
441        // Env merged
442        assert_eq!(job_env.get("CC").unwrap(), "gcc");
443        // $GITHUB_ENV writes must mirror into user_env
444        assert_eq!(job_user_env.get("CC").unwrap(), "gcc");
445        // Path prepended
446        assert_eq!(job_env.get("PATH").unwrap(), "/opt/gcc/bin:/usr/bin");
447        // PATH updates from $GITHUB_PATH must NOT appear in user_env
448        assert!(
449            !job_user_env.contains_key("PATH"),
450            "PATH should not leak into user_env"
451        );
452        // Files cleared for next step
453        assert!(fs::read_to_string(github_dir.join("output"))
454            .unwrap()
455            .is_empty());
456        assert!(fs::read_to_string(github_dir.join("env"))
457            .unwrap()
458            .is_empty());
459        assert!(fs::read_to_string(github_dir.join("path"))
460            .unwrap()
461            .is_empty());
462    }
463
464    #[test]
465    fn apply_updates_no_duplicate_path_entries() {
466        let dir = tempdir().unwrap();
467        let github_dir = dir.path().join("github");
468        fs::create_dir_all(&github_dir).unwrap();
469
470        let output_path = github_dir.join("output");
471        let env_path = github_dir.join("env");
472        let path_path = github_dir.join("path");
473
474        let mut job_env = HashMap::new();
475        job_env.insert(
476            "GITHUB_OUTPUT".to_string(),
477            output_path.to_string_lossy().to_string(),
478        );
479        job_env.insert(
480            "GITHUB_ENV".to_string(),
481            env_path.to_string_lossy().to_string(),
482        );
483        job_env.insert(
484            "GITHUB_PATH".to_string(),
485            path_path.to_string_lossy().to_string(),
486        );
487        job_env.insert("PATH".to_string(), "/usr/bin".to_string());
488
489        let mut step_outputs_map = HashMap::new();
490        let mut job_user_env = HashMap::new();
491
492        // Step 1 writes /opt/tool to GITHUB_PATH
493        fs::write(&output_path, "").unwrap();
494        fs::write(&env_path, "").unwrap();
495        fs::write(&path_path, "/opt/tool\n").unwrap();
496        apply_step_environment_updates(
497            &mut job_env,
498            &mut job_user_env,
499            &mut step_outputs_map,
500            None,
501        );
502        assert_eq!(job_env.get("PATH").unwrap(), "/opt/tool:/usr/bin");
503
504        // Step 2 writes /opt/other to GITHUB_PATH
505        fs::write(&output_path, "").unwrap();
506        fs::write(&env_path, "").unwrap();
507        fs::write(&path_path, "/opt/other\n").unwrap();
508        apply_step_environment_updates(
509            &mut job_env,
510            &mut job_user_env,
511            &mut step_outputs_map,
512            None,
513        );
514
515        // /opt/tool should appear exactly once (not duplicated)
516        let path = job_env.get("PATH").unwrap();
517        assert_eq!(path, "/opt/other:/opt/tool:/usr/bin");
518    }
519}