GitHub Actions / Events, execution, and data boundaries / Lesson 4
Runners, shells, and working directories
Make execution assumptions explicit instead of depending on a familiar label.
On this page
Lesson goals
- Separate hosted runner labels from toolchain identity.
- Review shell and filesystem assumptions in a portable job.
runs-on selects execution capacity
A job’s runs-on value selects eligible runner capacity, which can be GitHub-hosted or self-hosted under repository and organization policy. Labels identify routing characteristics; they do not independently prove trust, a fixed image, or availability. A job can queue because no permitted runner matches its labels even when its script is correct. Parcel’s learning examples use hosted labels only as illustrations. Do not add a self-hosted label as a shortcut around queues without reviewing the host’s network, identities, persistence, and access policy.
A hosted image is not your lockfile
Hosted image contents and latest labels change over time. Record the relevant image and tool versions for diagnosis, and select an explicit application toolchain through reviewed setup when needed. An explicit operating-system label still does not pin every preinstalled package forever. Keep build inputs declared and avoid relying on incidental utilities found on one machine. If a job needs a particular executable, establish that requirement in its setup contract rather than copying a successful local terminal session into YAML.
Choose the interpreter intentionally
The default shell and command wrapper depend on platform and configuration. Bash syntax does not automatically work in PowerShell, and error propagation can differ. Specify shell when its semantics matter, then understand how the runner invokes it. A working-directory default also applies only to supported run steps; it does not reconfigure arbitrary action inputs. The following fragment chooses bash and an explicit relative working directory, assuming that directory and the fixture script already exist after a separate checkout or generation step.
defaults:
run:
shell: bash
working-directory: fixture
steps:
- name: Check fixture
run: python check_fixture.py
This job fragment is incomplete and non-runnable. It does not create fixture/check_fixture.py, select a Python version, or check out source. Those missing prerequisites are explicit rather than hidden behind an asserted successful run.
Use platform-aware path construction
Hardcoded path separators, case assumptions, and absolute home directories make jobs fragile across operating systems and execution contexts. Use declared workspace paths or language-native path operations. A Linux filesystem usually treats App.py and app.py as different names, while behavior on other systems can differ. Avoid using a developer’s local home path as a CI contract. The complete local program below constructs a fixture path without touching a repository or relying on the actual host’s separator convention.
from pathlib import PurePosixPath, PureWindowsPath
parts = ("fixture", "reports", "result.json")
print(PurePosixPath(*parts))
print(PureWindowsPath(*parts).as_posix())
fixture/reports/result.json
fixture/reports/result.json
Both lines use an explicit logical path. Pure path objects perform no filesystem access and do not prove that a file exists on any runner. as_posix deliberately renders the Windows path with forward slashes for comparison.
Diagnose with narrow environment evidence
When a job differs from local behavior, collect the selected runner class, relevant tool versions, shell choice, working directory, and exact failing operation. Do not dump the full environment or recursively archive host directories, because they can contain secrets and unrelated data. Separate missing executable, wrong directory, permission denial, architecture mismatch, and queueing. Changing runner, interpreter, dependencies, and permissions simultaneously destroys diagnostic clarity. Fix the smallest incorrect assumption and retain enough evidence to confirm that the same revision now satisfies the intended contract.
Common errors and fixes
A script works in Bash but fails on a Windows runner.
- Why it happens
- The job relies on Bash syntax while the selected shell differs.
- How to fix it
- Select an available intended shell or write the script for the actual interpreter.
- Prevent it next time
- Review shell and quoting assumptions for every supported platform.
A job queues forever with an unfamiliar self-hosted label.
- Why it happens
- No eligible authorized runner matches the requested labels.
- How to fix it
- Inspect routing and capacity with the platform owner.
- Prevent it next time
- Treat runner labels and ownership as reviewed configuration.
A setup action still reads the wrong directory.
- Why it happens
- A run-step working-directory default was assumed to affect action-specific inputs.
- How to fix it
- Set the action’s documented path input separately.
- Prevent it next time
- Distinguish run defaults from uses action contracts.
Check your understanding
1. Does ubuntu-latest guarantee the same compiler next month?
Show worked solution
No. It selects a moving hosted environment, and installed tools can change. Record relevant versions and explicitly establish the application toolchain. Even a fixed OS label needs a reproducibility strategy for the inputs that affect release bytes.
2. A file exists locally as Report.json but a Linux job asks for report.json. What should change?
Show worked solution
Align the tracked filename and reference with the intended case, then verify the repository state. Do not fix this by broadening permissions or changing to a more privileged runner. The failure concerns path identity, not necessarily access authority.
Key takeaways
- Runner routing, image contents, shell, and paths are separate assumptions.
- Collect narrow environment evidence before changing execution boundaries.