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}