I’d been treating GitHub Actions as a black box for years. PRs got checked, releases got published, green checkmarks appeared — someone had set it up, and it worked, so I never looked inside. This week I finally decided to actually learn it. My approach: skim the official Quickstart for the bare minimum, then read the two real workflow files in one of our internal repos line by line until every line made sense.
That second part turned out to be worth more than any tutorial. Not just because real files teach faster than hello-world examples — but because somewhere around line 25 of the first file, I found a bug. One that had been sitting in a workflow that runs on every single pull request, had never caused a failure, and was quietly waiting for the right conditions to break every PR in the repo at once.
To explain the bug, I need to walk through the concepts first. Conveniently, they’re the same concepts you need to read any workflow file.
The mental model: one file, four layers
A GitHub Actions workflow is a YAML file in .github/workflows/. Everything in it hangs off four ideas:
Trigger (on:) — what event starts this workflow. The two you’ll see everywhere:
on: pull_request: types: [opened, synchronize, reopened]runs on every PR (and every new push to an open PR — that’s synchronize), while on: push: branches: [main] runs when something lands on main. There are many more (cron schedules, manual dispatch), but these two cover most CI.
Permissions — the scope of the temporary token GitHub mints for each run. A read-only check job gets contents: read; a release job that pushes tags needs contents: write. Least privilege, declared right in the file.
Jobs — units of work, each on a fresh virtual machine (runs-on: ubuntu-latest). Multiple jobs run in parallel by default.
Steps — the sequence inside a job. Each step is either uses: (invoke a prebuilt action someone published — actions/checkout to clone your code, with with: for its parameters) or run: (execute shell commands directly on the VM). Give a step an id: and later steps can read its outputs via ${{ steps.myid.outputs.something }}.
That’s genuinely most of it. With those four layers, I could read the whole PR-check workflow: trigger on PR, check out code, install the package manager, install dependencies, then lint, type-check, and test. Any step exits non-zero, the job fails, the PR shows a red X.
Except one step made me pause:
- name: Set up Python run: | uv python install 3.12 export UV_INDEX_PRIVATE_PYPI_USERNAME=${{ secrets.PYPI_USERNAME }} export UV_INDEX_PRIVATE_PYPI_PASSWORD=${{ secrets.PYPI_PASSWORD }}
- name: Install dependencies run: | uv syncInstall Python, export credentials for our private package index, then sync dependencies in the next step. Reads perfectly naturally — set the variables, use the variables. I almost moved on.
Those two export lines are dead code, and have been since the day they were written. To see why — really see why, not just memorize the rule — you have to drop below GitHub Actions entirely, down to how operating systems handle processes. That’s where we’re going.
Layer 1: What an environment variable actually is
Forget CI for a minute. This layer is pure Linux/macOS.
Every process on your machine carries a private block of data, and part of it is the environment variable table — a bag of key=value pairs. Two rules govern it, and they’re the only two rules you need:
- Inheritance is a copy. When process A launches process B, B receives a copy of A’s environment table. A copy — not a shared reference.
- The copy is one-way and one-time. It happens at the instant B is spawned. After that, A changing its own table is invisible to B, and B changing its table is invisible to A. A child process can never modify its parent’s environment. There is no syscall for it. The direction of flow is parent → child, at spawn, once.
Now the two shell commands everyone types without thinking:
export — in a shell, FOO=bar on its own creates a shell-internal variable: not even child processes of that shell will see it. export FOO=bar flags it as “put this in my environment table,” so that children spawned from now on inherit it. That’s the entire feature. Its blast radius is exactly: this shell process, plus its future children. Nothing else, ever.
PATH — no magic at all. It’s an ordinary environment variable whose value is a colon-separated list of directories. When you type uv, the shell walks those directories in order looking for an executable file named uv, and runs the first one it finds. Which means every “command not found” in history is one of exactly two problems: the file isn’t on disk, or it’s on disk but its directory isn’t in PATH.
That’s the whole layer. Everything below is derivable from these rules.
Layer 2: What a job actually looks like as processes
When a GitHub Actions job runs, the process tree on that VM looks like this:
runner process (long-lived, orchestrates the whole job)├── step 1's shell (a bash process) ← destroyed when the step ends├── step 2's shell (a NEW bash) ← destroyed when the step ends└── step 3's shell (yet another new bash) ...For every run: step, the runner writes your commands into a temporary script file, then spawns a brand-new bash process to execute it. Now derive the bug from Layer 1:
- Where does step 2’s bash get its environment table? Copied from the runner (its parent) — rule 1.
export FOO=barin step 1 modified step 1’s bash’s own table. The runner’s table was never touched — rule 2, children can’t write upward.- Step 2’s bash copies from the runner. The runner has no
FOO. Therefore step 2 has noFOO.
So export doesn’t cross step boundaries — and notice that GitHub didn’t build any isolation mechanism to make that true. It’s just what processes do. GitHub did nothing, and this is the resulting behavior. That’s why the workflow snippet reads so naturally and is still wrong: it’s written as if steps share one shell, when in fact each step is a fresh child of a parent you never see.
The uv sync in the next step runs in a process that has never heard of those credentials.
Layer 3: How the official mechanisms smuggle state across anyway
If a child can’t modify its parent, how does anything get from step 1 into step 2’s environment? There’s only one move available: make the parent the middleman, and talk to it through a file — because disk belongs to no process and outlives all of them.
$GITHUB_ENV — before starting each step, the runner hands it an environment variable called GITHUB_ENV whose value is a path to a temp file. Your step writes lines like MY_VAR=hello into that file. When the step exits, the runner reads the file and adds those pairs to its injection list. When it spawns the next step’s bash, it copies its own environment as usual, plus everything on the list. Mechanically: the child leaves a note on disk for the parent; the parent relays it to the next sibling.
echo "MY_VAR=hello" >> "$GITHUB_ENV" # visible as $MY_VAR in every later step$GITHUB_PATH — the same mechanism, specialized for PATH. Write a directory path into it; the runner prepends it to PATH before spawning each subsequent step. This finally explains something that had confused me: if steps are isolated processes, how did uv itself survive from the setup-uv step to the next one? Because that action does exactly two things: writes the binary to disk (disk is durable, no trick needed) and appends the install directory to $GITHUB_PATH (so every later step’s freshly-built PATH can find it). Nothing was exported. The runner deliberately reconstructs the world for each step.
Workflow-level env: — simpler than either, because it doesn’t route through steps at all. The runner reads these pairs out of the YAML and puts them in its own environment table. Then rule 1 does all the work: every step bash it ever spawns inherits them automatically. This is why top-level env: feels “global” — it isn’t broadcast to each step; it’s the parent’s genes, and every child gets a copy for free.
env: UV_INDEX_PRIVATE_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} UV_INDEX_PRIVATE_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}That block is the correct fix for our bug — credentials are static, every step should see them, so they belong in the parent. Our other workflow (the release one) did exactly this, correctly. The broken version in the PR workflow was almost certainly a copy-paste that transplanted the intent but not the mechanism.
Test the model: five questions
A model you can’t make predictions with is just vocabulary. Try these before reading the answers.
Q1: Step 1 runs cd /some/dir. What’s step 2’s working directory?
The repo root, not /some/dir. The working directory is process state, same as exported variables — it dies with step 1’s bash. Step 2 is a new process that inherits the runner’s default.
Q2: Step 1 runs echo hi > /tmp/x.txt. Can step 2 read it?
Yes. Files live on disk; disk crosses process boundaries. All steps in a job share one VM.
Q3: Within a single step, export FOO=1 then echo $FOO — does it print 1?
Yes — one bash process, ordinary shell behavior. This is the sinister part of our bug: if uv sync had happened to be written in the same step as the exports, the code would have been correct. One step boundary is the difference between working and dead, and it’s invisible to the eye.
Q4: What’s the relationship between ${{ secrets.XXX }} and environment variables?
None — it operates a layer earlier. ${{ }} is text substitution performed by the runner while generating the temp script file, before bash even starts; by the time the shell runs, the literal value is already in the script. That’s why the syntax works everywhere (if:, with:, env:, run:): it’s template rendering, not runtime evaluation.
Q5: Then what is ${{ steps.release.outputs.tag }}?
A third channel: $GITHUB_OUTPUT, sibling of $GITHUB_ENV — same leave-a-note-for-the-parent file mechanism, different destination. GITHUB_ENV injects into later steps’ shell environments (use as $MY_VAR); GITHUB_OUTPUT goes into the runner’s context object, referenced from YAML via ${{ steps.<id>.outputs.<key> }}. Passing data to the YAML layer → outputs. Passing data to the shell layer → GITHUB_ENV.
The full map
| What you want to carry across steps | Correct mechanism | Why it works |
|---|---|---|
| Env var, static global value (e.g. credentials) | Workflow/job-level env: | Lives in the runner’s environment; all children inherit |
| Env var, computed at runtime | echo "K=V" >> "$GITHUB_ENV" | File relay; runner injects into later steps |
| An executable tool | Install to disk + $GITHUB_PATH | Disk is durable; runner rebuilds PATH per step |
| Data for later steps’ YAML expressions | $GITHUB_OUTPUT + steps.<id>.outputs | Runner’s context object |
| Files / build artifacts (same job) | Just write to disk | Same VM |
| Files across jobs | actions/upload-artifact / download-artifact | Different jobs = different VMs — they don’t even share disk |
That last row is the next trap waiting after this one: steps share a disk, but jobs don’t share anything — each gets a fresh VM. When you see a build job hand artifacts to a deploy job through upload/download actions, this is why.
One sentence holds the whole table together: when a process dies its memory dies with it, so state crosses process boundaries only through a file or through the parent. Every value-passing mechanism in GitHub Actions is a variation on that sentence.
Why it never failed
Here’s my favorite part. This workflow runs on every PR. The credentials it fails to pass are for authenticating against a private package index. Why has uv sync never once returned a 401?
Because the private index was declared with explicit = true in the project config — meaning the package manager only contacts it for dependencies explicitly pinned to it. And every dependency in this particular repo happens to come from public PyPI. The broken credential-passing and the index that needs credentials have simply never intersected. Two things quietly wrong in complementary ways, adding up to a green checkmark every single time.
Which also defines exactly how it would eventually blow up: the first time anyone adds a dependency from the private index, every open PR starts failing with an authentication error. And whoever hits it would see export CREDENTIALS sitting right there in the workflow file, looking correct, and go hunting for an expired secret or a broken index instead — because the real bug is a process-scoping subtlety that’s invisible unless you know steps don’t share shells.
The fix was five lines: delete the two dead exports, add the env: block at the top. The find was the education.
What I’d tell past me
Three things, in order of how much they surprised me:
- A green CI is not evidence the CI is correct. It’s evidence the broken paths haven’t been exercised yet. This bug had a 100% pass rate.
- The concept to internalize isn’t a GitHub Actions concept. It’s the OS process model: environment tables copy parent-to-child at spawn, one-way, one-time. Once that’s solid, step isolation,
$GITHUB_ENV,$GITHUB_PATH, top-levelenv:, and cross-job artifacts all stop being rules to memorize and become consequences you can derive. - Read your own repo’s workflows instead of another tutorial. Real files force you through triggers, permissions, secrets,
usesvsrun, and step outputs in twenty minutes — and unlike a tutorial, they might be wrong in instructive ways. Mine were.
Next up, I want to dig into what the second workflow in that repo does: fully automated semantic versioning and releases, where no human ever picks a version number. That post is now up — though you might first want to see what else I found when I kept reading.