Skip to main content

actions_rs/
env.rs

1//! Runtime detection and typed access to the GitHub Actions environment.
2//!
3//! Nothing here mutates the process environment; every accessor is a cheap read of a `GITHUB_*` / `RUNNER_*` variable.\
4//! The canonical names are also exposed as constants in [`vars`] for callers who want raw access.
5
6use std::path::PathBuf;
7
8/// Canonical names of GitHub-provided environment variables.
9///
10/// Use these instead of stringly-typed literals to avoid typos.
11pub mod vars {
12    /// Always `"true"` while a step runs inside GitHub Actions; unset otherwise.
13    /// Set by the runner. Use it to no-op locally.
14    ///
15    /// Example: `true`
16    pub const GITHUB_ACTIONS: &str = "GITHUB_ACTIONS";
17    /// `"true"` under Actions and virtually every other CI (Travis, Circle, GitLab, …).
18    /// Broader and less reliable than [`GITHUB_ACTIONS`].
19    ///
20    /// Example: `true`
21    pub const CI: &str = "CI";
22    /// `"1"` when step-debug logging is enabled; otherwise unset.
23    ///
24    /// logging is enabled when the `ACTIONS_STEP_DEBUG` secret is `true`,
25    /// or the run was re-run with debug logging
26    ///
27    /// Example: `1`
28    pub const RUNNER_DEBUG: &str = "RUNNER_DEBUG";
29    /// OS of the runner image. One of `Linux`, `Windows`, `macOS`. Derived from the `runs-on` image.
30    ///
31    /// Example: `runs-on: ubuntu-latest` → `Linux`
32    pub const RUNNER_OS: &str = "RUNNER_OS";
33    /// CPU architecture of the runner. One of `X86`, `X64`, `ARM`, `ARM64`.
34    ///
35    /// Example: `runs-on: ubuntu-latest` → `X64`
36    pub const RUNNER_ARCH: &str = "RUNNER_ARCH";
37    /// Per-job temporary directory, emptied at the end of each job.
38    /// Platform path set by the runner.
39    ///
40    /// Example: `/home/runner/work/_temp` (Linux hosted runner)
41    pub const RUNNER_TEMP: &str = "RUNNER_TEMP";
42    /// Root of the pre-installed tool cache (Python, Node, …) on hosted runners.
43    ///
44    /// Example: `/opt/hostedtoolcache` (Linux hosted runner)
45    pub const RUNNER_TOOL_CACHE: &str = "RUNNER_TOOL_CACHE";
46    /// `owner/repo` of the repository the workflow runs in.
47    ///
48    /// Example: `octocat/Hello-World`
49    pub const GITHUB_REPOSITORY: &str = "GITHUB_REPOSITORY";
50    /// Login of the repository owner (the part before `/` in [`GITHUB_REPOSITORY`]).
51    ///
52    /// Example: `octocat`
53    pub const GITHUB_REPOSITORY_OWNER: &str = "GITHUB_REPOSITORY_OWNER";
54    /// Full 40-char commit SHA that triggered the run. For `pull_request`
55    /// events this is the PR's test-merge commit, not the PR head.
56    ///
57    /// Example: `ffac537e6cbbf934b08745a378932722df287a53`
58    pub const GITHUB_SHA: &str = "GITHUB_SHA";
59    /// Full ref that triggered the run. Branch push → `refs/heads/<branch>`;
60    /// tag → `refs/tags/<tag>`; pull request → `refs/pull/<n>/merge`.
61    ///
62    /// Example: push to `main` → `refs/heads/main`
63    pub const GITHUB_REF: &str = "GITHUB_REF";
64    /// Short form of [`GITHUB_REF`] with the `refs/heads/` or `refs/tags/`
65    /// prefix stripped.
66    ///
67    /// Example: push to `main` → `main`; tag `v1.2.0` → `v1.2.0`;
68    /// PR #42 → `42/merge`
69    pub const GITHUB_REF_NAME: &str = "GITHUB_REF_NAME";
70    /// Kind of ref that triggered the run: `branch` or `tag`.
71    ///
72    /// Example: push to `main` → `branch`
73    pub const GITHUB_REF_TYPE: &str = "GITHUB_REF_TYPE";
74    /// Source (head) branch of a pull request. Set **only** for
75    /// `pull_request` / `pull_request_target` events; empty/unset for `push`
76    /// and most other events.
77    ///
78    /// Example: PR from `feature/login` into `main` → `feature/login`
79    pub const GITHUB_HEAD_REF: &str = "GITHUB_HEAD_REF";
80    /// Target (base) branch of a pull request. Set **only** for
81    /// `pull_request` / `pull_request_target` events; empty/unset otherwise.
82    ///
83    /// Example: PR from `feature/login` into `main` → `main`
84    pub const GITHUB_BASE_REF: &str = "GITHUB_BASE_REF";
85    /// Name of the webhook event that triggered the run.
86    ///
87    /// Example: `push`, `pull_request`, `workflow_dispatch`, `schedule`
88    pub const GITHUB_EVENT_NAME: &str = "GITHUB_EVENT_NAME";
89    /// Filesystem path to the JSON file holding the full webhook event
90    /// payload (parse it yourself; this crate keeps it serde-free).
91    ///
92    /// Example: `/home/runner/work/_temp/_github_workflow/event.json`
93    pub const GITHUB_EVENT_PATH: &str = "GITHUB_EVENT_PATH";
94    /// Working directory containing the checked-out repository (after
95    /// `actions/checkout`). Default cwd for `run:` steps.
96    ///
97    /// Example: `/home/runner/work/Hello-World/Hello-World`
98    pub const GITHUB_WORKSPACE: &str = "GITHUB_WORKSPACE";
99    /// `name:` of the running workflow, or its file path if `name:` is
100    /// omitted.
101    ///
102    /// Example: `CI` (or `.github/workflows/ci.yml` if unnamed)
103    pub const GITHUB_WORKFLOW: &str = "GITHUB_WORKFLOW";
104    /// The job's *id key* from the workflow YAML (the `jobs:` map key), not
105    /// the human `name:`.
106    ///
107    /// Example: `jobs: { build: … }` → `build`
108    pub const GITHUB_JOB: &str = "GITHUB_JOB";
109    /// Unique id of the workflow run. Stable across re-runs of the same run;
110    /// usable to build a run URL.
111    ///
112    /// Example: `1658821493`
113    pub const GITHUB_RUN_ID: &str = "GITHUB_RUN_ID";
114    /// Count of runs of *this workflow* in the repo, incremented per run.
115    /// Unlike [`GITHUB_RUN_ID`] it does **not** change on a re-run.
116    ///
117    /// Example: `42`
118    pub const GITHUB_RUN_NUMBER: &str = "GITHUB_RUN_NUMBER";
119    /// Login of the account that initiated the run (on a re-run, the original
120    /// initiator, not whoever clicked re-run).
121    ///
122    /// Example: `octocat`
123    pub const GITHUB_ACTOR: &str = "GITHUB_ACTOR";
124    /// Base URL of the GitHub server — `https://github.com` on github.com,
125    /// the instance URL on GitHub Enterprise Server.
126    ///
127    /// Example: `https://github.com`
128    pub const GITHUB_SERVER_URL: &str = "GITHUB_SERVER_URL";
129    /// REST API base URL (Enterprise-aware).
130    ///
131    /// Example: `https://api.github.com`
132    pub const GITHUB_API_URL: &str = "GITHUB_API_URL";
133    /// GraphQL API endpoint (Enterprise-aware).
134    ///
135    /// Example: `https://api.github.com/graphql`
136    pub const GITHUB_GRAPHQL_URL: &str = "GITHUB_GRAPHQL_URL";
137}
138
139fn var(name: &str) -> Option<String> {
140    std::env::var(name).ok().filter(|v| !v.is_empty())
141}
142
143/// Whether the code is running inside GitHub Actions (`GITHUB_ACTIONS=="true"`).
144///
145/// # Examples
146///
147/// ```
148/// if actions_rs::env::is_github_actions() {
149///     actions_rs::log::info("on a runner");
150/// } // else: running locally — no-op
151/// ```
152#[must_use]
153pub fn is_github_actions() -> bool {
154    std::env::var(vars::GITHUB_ACTIONS).as_deref() == Ok("true")
155}
156
157/// Whether running in a CI environment (`CI=="true"`).
158///
159/// # Examples
160///
161/// ```
162/// // Broader than `is_github_actions` (also true on Travis, GitLab, …).
163/// let interactive = !actions_rs::env::is_ci();
164/// let _ = interactive;
165/// ```
166#[must_use]
167pub fn is_ci() -> bool {
168    std::env::var(vars::CI).as_deref() == Ok("true")
169}
170
171/// Whether step-debug logging is enabled (`RUNNER_DEBUG=="1"`).
172///
173/// # Examples
174///
175/// ```
176/// if actions_rs::env::is_debug() {
177///     actions_rs::log::debug("extra diagnostics");
178/// }
179/// ```
180#[must_use]
181pub fn is_debug() -> bool {
182    std::env::var(vars::RUNNER_DEBUG).as_deref() == Ok("1")
183}
184
185/// The runner operating system, parsed from `RUNNER_OS`.
186///
187/// # Examples
188///
189/// ```
190/// use actions_rs::RunnerOs;
191/// // Unrecognised values are preserved rather than lost.
192/// assert_eq!(RunnerOs::Other("Plan9".into()), RunnerOs::Other("Plan9".into()));
193/// ```
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum RunnerOs {
196    /// `RUNNER_OS == "Linux"`.
197    Linux,
198    /// `RUNNER_OS == "Windows"`.
199    Windows,
200    /// `RUNNER_OS == "macOS"`.
201    MacOs,
202    /// An unrecognised value (forward-compatible).
203    Other(String),
204}
205
206impl RunnerOs {
207    /// Read and parse `RUNNER_OS`. `None` when unset.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use actions_rs::RunnerOs;
213    /// match RunnerOs::from_env() {
214    ///     Some(RunnerOs::Linux) => { /* hosted ubuntu runner */ }
215    ///     Some(other) => { let _ = other; }
216    ///     None => { /* not on a runner */ }
217    /// }
218    /// ```
219    #[must_use]
220    pub fn from_env() -> Option<Self> {
221        var(vars::RUNNER_OS).map(|v| match v.as_str() {
222            "Linux" => RunnerOs::Linux,
223            "Windows" => RunnerOs::Windows,
224            "macOS" => RunnerOs::MacOs,
225            _ => RunnerOs::Other(v),
226        })
227    }
228}
229
230/// The runner CPU architecture, parsed from `RUNNER_ARCH`.
231///
232/// # Examples
233///
234/// ```
235/// use actions_rs::RunnerArch;
236/// assert_eq!(RunnerArch::X64, RunnerArch::X64);
237/// ```
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum RunnerArch {
240    /// 32-bit x86.
241    X86,
242    /// 64-bit x86.
243    X64,
244    /// 32-bit ARM.
245    Arm,
246    /// 64-bit ARM.
247    Arm64,
248    /// An unrecognised value (forward-compatible).
249    Other(String),
250}
251
252impl RunnerArch {
253    /// Read and parse `RUNNER_ARCH`. `None` when unset.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use actions_rs::RunnerArch;
259    /// if let Some(arch) = RunnerArch::from_env() {
260    ///     let _ = arch;
261    /// }
262    /// ```
263    #[must_use]
264    pub fn from_env() -> Option<Self> {
265        var(vars::RUNNER_ARCH).map(|v| match v.as_str() {
266            "X86" => RunnerArch::X86,
267            "X64" => RunnerArch::X64,
268            "ARM" => RunnerArch::Arm,
269            "ARM64" => RunnerArch::Arm64,
270            _ => RunnerArch::Other(v),
271        })
272    }
273}
274
275/// Typed, lazily-read accessors for the workflow run context.
276///
277/// This is a zero-sized handle; every method reads the corresponding
278/// environment variable on call, so values reflect the current process
279/// environment. Per the design decision, the webhook payload is exposed only
280/// as the *path* ([`Context::event_path`]) — parsing the JSON would require a
281/// serde dependency and is out of scope.
282///
283/// # Examples
284///
285/// ```
286/// let ctx = actions_rs::Context::new();
287/// // Each accessor is `Option`: `None` outside Actions, `Some` on a runner.
288/// match ctx.repository() {
289///     Some(repo) => println!("running for {repo}"),
290///     None => println!("not in GitHub Actions"),
291/// }
292/// ```
293#[derive(Debug, Clone, Copy, Default)]
294pub struct Context;
295
296impl Context {
297    /// Construct a context handle.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// let ctx = actions_rs::Context::new();
303    /// let _ = ctx.sha(); // each accessor is a fresh env read
304    /// ```
305    #[must_use]
306    pub fn new() -> Self {
307        Context
308    }
309
310    /// `owner/repo`, if set.
311    ///
312    /// # Examples
313    ///
314    /// ```
315    /// let ctx = actions_rs::Context::new();
316    /// if let Some(repo) = ctx.repository() {
317    ///     assert!(repo.contains('/'));
318    /// }
319    /// ```
320    #[must_use]
321    pub fn repository(&self) -> Option<String> {
322        var(vars::GITHUB_REPOSITORY)
323    }
324
325    /// `(owner, repo)` split from [`Context::repository`].
326    ///
327    /// # Examples
328    ///
329    /// ```
330    /// let ctx = actions_rs::Context::new();
331    /// if let Some((owner, repo)) = ctx.repo_parts() {
332    ///     assert!(!owner.is_empty() && !repo.is_empty());
333    /// }
334    /// ```
335    #[must_use]
336    pub fn repo_parts(&self) -> Option<(String, String)> {
337        let full = self.repository()?;
338        let (owner, repo) = full.split_once('/')?;
339        Some((owner.to_owned(), repo.to_owned()))
340    }
341
342    /// Repository owner login.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// if let Some(owner) = actions_rs::Context::new().repository_owner() {
348    ///     assert!(!owner.is_empty());
349    /// }
350    /// ```
351    #[must_use]
352    pub fn repository_owner(&self) -> Option<String> {
353        var(vars::GITHUB_REPOSITORY_OWNER)
354    }
355
356    /// Commit SHA that triggered the run.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// if let Some(sha) = actions_rs::Context::new().sha() {
362    ///     assert!(!sha.is_empty());
363    /// }
364    /// ```
365    #[must_use]
366    pub fn sha(&self) -> Option<String> {
367        var(vars::GITHUB_SHA)
368    }
369
370    /// Full git ref, e.g. `refs/heads/main`.
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// if let Some(r) = actions_rs::Context::new().git_ref() {
376    ///     assert!(!r.is_empty());
377    /// }
378    /// ```
379    #[must_use]
380    pub fn git_ref(&self) -> Option<String> {
381        var(vars::GITHUB_REF)
382    }
383
384    /// Short ref name, e.g. `main`.
385    ///
386    /// # Examples
387    ///
388    /// ```
389    /// if let Some(name) = actions_rs::Context::new().ref_name() {
390    ///     assert!(!name.is_empty());
391    /// }
392    /// ```
393    #[must_use]
394    pub fn ref_name(&self) -> Option<String> {
395        var(vars::GITHUB_REF_NAME)
396    }
397
398    /// `branch` or `tag`.
399    ///
400    /// # Examples
401    ///
402    /// ```
403    /// if let Some(t) = actions_rs::Context::new().ref_type() {
404    ///     assert!(t == "branch" || t == "tag");
405    /// }
406    /// ```
407    #[must_use]
408    pub fn ref_type(&self) -> Option<String> {
409        var(vars::GITHUB_REF_TYPE)
410    }
411
412    /// PR head ref (empty/`None` outside pull requests).
413    ///
414    /// # Examples
415    ///
416    /// ```
417    /// // `None` for `push` events; `Some(branch)` on a pull request.
418    /// let ctx = actions_rs::Context::new();
419    /// if let Some(head) = ctx.head_ref() {
420    ///     assert!(!head.is_empty());
421    /// }
422    /// ```
423    #[must_use]
424    pub fn head_ref(&self) -> Option<String> {
425        var(vars::GITHUB_HEAD_REF)
426    }
427
428    /// PR base ref (empty/`None` outside pull requests).
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// if let Some(base) = actions_rs::Context::new().base_ref() {
434    ///     assert!(!base.is_empty()); // e.g. "main"
435    /// }
436    /// ```
437    #[must_use]
438    pub fn base_ref(&self) -> Option<String> {
439        var(vars::GITHUB_BASE_REF)
440    }
441
442    /// Event name, e.g. `push`, `pull_request`.
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// let ctx = actions_rs::Context::new();
448    /// if ctx.event_name().as_deref() == Some("pull_request") {
449    ///     actions_rs::log::info("triggered by a PR");
450    /// }
451    /// ```
452    #[must_use]
453    pub fn event_name(&self) -> Option<String> {
454        var(vars::GITHUB_EVENT_NAME)
455    }
456
457    /// Path to the webhook payload JSON file.
458    ///
459    /// # Examples
460    ///
461    /// ```
462    /// // The crate is serde-free, so you parse the JSON yourself if needed.
463    /// if let Some(path) = actions_rs::Context::new().event_path() {
464    ///     let _payload = std::fs::read_to_string(path);
465    /// }
466    /// ```
467    #[must_use]
468    pub fn event_path(&self) -> Option<PathBuf> {
469        var(vars::GITHUB_EVENT_PATH).map(PathBuf::from)
470    }
471
472    /// Workspace directory (checked-out repo root).
473    ///
474    /// # Examples
475    ///
476    /// ```
477    /// if let Some(ws) = actions_rs::Context::new().workspace() {
478    ///     let _manifest = ws.join("Cargo.toml");
479    /// }
480    /// ```
481    #[must_use]
482    pub fn workspace(&self) -> Option<PathBuf> {
483        var(vars::GITHUB_WORKSPACE).map(PathBuf::from)
484    }
485
486    /// Workflow name.
487    ///
488    /// # Examples
489    ///
490    /// ```
491    /// if let Some(wf) = actions_rs::Context::new().workflow() {
492    ///     assert!(!wf.is_empty());
493    /// }
494    /// ```
495    #[must_use]
496    pub fn workflow(&self) -> Option<String> {
497        var(vars::GITHUB_WORKFLOW)
498    }
499
500    /// Current job id.
501    ///
502    /// # Examples
503    ///
504    /// ```
505    /// if let Some(job) = actions_rs::Context::new().job() {
506    ///     assert!(!job.is_empty()); // the `jobs:` map key, not its `name:`
507    /// }
508    /// ```
509    #[must_use]
510    pub fn job(&self) -> Option<String> {
511        var(vars::GITHUB_JOB)
512    }
513
514    /// Unique numeric run id.
515    ///
516    /// # Examples
517    ///
518    /// ```
519    /// if let Some(id) = actions_rs::Context::new().run_id() {
520    ///     let _url = format!("https://github.com/o/r/actions/runs/{id}");
521    /// }
522    /// ```
523    #[must_use]
524    pub fn run_id(&self) -> Option<u64> {
525        var(vars::GITHUB_RUN_ID)?.parse().ok()
526    }
527
528    /// Per-workflow incrementing run number.
529    ///
530    /// # Examples
531    ///
532    /// ```
533    /// if let Some(n) = actions_rs::Context::new().run_number() {
534    ///     let _ = n; // stable across re-runs, unlike `run_id`
535    /// }
536    /// ```
537    #[must_use]
538    pub fn run_number(&self) -> Option<u64> {
539        var(vars::GITHUB_RUN_NUMBER)?.parse().ok()
540    }
541
542    /// Login of the triggering user/app.
543    ///
544    /// # Examples
545    ///
546    /// ```
547    /// if let Some(actor) = actions_rs::Context::new().actor() {
548    ///     assert!(!actor.is_empty());
549    /// }
550    /// ```
551    #[must_use]
552    pub fn actor(&self) -> Option<String> {
553        var(vars::GITHUB_ACTOR)
554    }
555
556    /// Server URL (`https://github.com` or an Enterprise URL).
557    ///
558    /// # Examples
559    ///
560    /// ```
561    /// if let Some(url) = actions_rs::Context::new().server_url() {
562    ///     assert!(url.starts_with("http"));
563    /// }
564    /// ```
565    #[must_use]
566    pub fn server_url(&self) -> Option<String> {
567        var(vars::GITHUB_SERVER_URL)
568    }
569
570    /// REST API base URL.
571    ///
572    /// # Examples
573    ///
574    /// ```
575    /// if let Some(api) = actions_rs::Context::new().api_url() {
576    ///     assert!(api.starts_with("http"));
577    /// }
578    /// ```
579    #[must_use]
580    pub fn api_url(&self) -> Option<String> {
581        var(vars::GITHUB_API_URL)
582    }
583
584    /// GraphQL API URL.
585    ///
586    /// # Examples
587    ///
588    /// ```
589    /// if let Some(gql) = actions_rs::Context::new().graphql_url() {
590    ///     assert!(gql.starts_with("http"));
591    /// }
592    /// ```
593    #[must_use]
594    pub fn graphql_url(&self) -> Option<String> {
595        var(vars::GITHUB_GRAPHQL_URL)
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn os_parses_known_and_unknown() {
605        // Pure mapping is exercised via the match arms; here we only assert
606        // the unknown fallback shape since env mutation is global/unsafe.
607        assert_eq!(
608            RunnerOs::Other("Plan9".into()),
609            RunnerOs::Other("Plan9".into())
610        );
611    }
612
613    #[test]
614    fn repo_parts_splits() {
615        // repo_parts is pure given repository(); emulate via a temp env in the
616        // integration tests. Here assert the split helper logic indirectly.
617        let full = "octocat/Hello-World";
618        let (o, r) = full.split_once('/').unwrap();
619        assert_eq!((o, r), ("octocat", "Hello-World"));
620    }
621}