Module 2: Anatomy Of A Github Actions Workflow
2. A workflow block's complete anatomy
Description
This lesson dissects a complete workflow, field by field, running it with act as you read it — not an isolated fragment from documentation. You're going to see on, jobs, steps, runs-on, uses, with, and env (at all three levels where it can appear: workflow, job, and step) in a single real YAML file, and you're going to confirm with literal output, not just prose, what each one does.
Connection to the module
This is the module's densest lesson, and the foundation for everything that follows. Lesson 3 goes deeper specifically into on (what events exist, how to filter them); lesson 4 does the same with the particular case of schedule; lesson 5 goes deeper into uses/with (what an Action exactly is). Without this lesson's complete anatomy, those three lessons would be explaining loose pieces of something you never saw whole.
Analogy: a house's blueprint, not a loose brick
Looking at an isolated step —"this runs terraform plan"— is like looking at a house's loose brick: it's real information, but it doesn't tell you anything about where that room fits, or what connects it to the rest. A complete workflow is more like a house's full blueprint: on is the street address and the conditions under which someone can enter (is it daytime? do they have a key?); jobs are the rooms —each with its own purpose, some connected to each other, others completely independent; steps is the ordered walk-through within a specific room, door by door. Reading a workflow brick by brick (an isolated step, copied from Stack Overflow) is exactly like trying to understand a house by looking at a single brick: you're not technically wrong about that brick, but you know nothing about the house.
The complete workflow, uncut
This is this lesson's real file — it lives in a disposable lab, outside andes-cargo-infra/ (same as Module 1's "hello world"; Andes Cargo's first real workflow arrives in this module's project, lesson 8):
name: andes-cargo-ci-demo
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
TF_VERSION: "1.9.5"
jobs:
inspect-environment:
runs-on: ubuntu-latest
env:
REGION: us-east-1
steps:
- name: Check out the repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Show workflow-level and job-level context
run: |
echo "Terraform version pinned at workflow level: ${TF_VERSION}"
echo "AWS region pinned at job level: ${REGION}"
echo "Event that triggered this run: ${{ github.event_name }}"
echo "Run id: ${{ github.run_id }}"
- name: Show a step-level env var, scoped to this step alone
env:
STEP_ONLY: "visible-here-only"
run: |
echo "STEP_ONLY inside this step: ${STEP_ONLY}"
- name: Confirm the step-level var does not leak into this step
run: |
echo "STEP_ONLY outside that step: '${STEP_ONLY}'"
This single file has everything this lesson needs to dissect: three distinct events in on, env at all three possible levels (workflow, job, step), a uses with with, and four steps in sequence. Let's go field by field.
on — under what conditions this workflow runs
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
on is, always, the first field GitHub Actions —and act— evaluate. Nothing in the rest of the file matters if the current event doesn't show up here. This workflow listens for three distinct events: a push to main, a pull_request against main, and workflow_dispatch (the manual "Run workflow" button, with no additional filter). This module's lesson 3 dissects each of these three in depth, including exactly what branches: [main] does; for now, keep the central idea: on is a list of doors, and the event that triggers the run has to match at least one for the rest of the file to execute.
Workflow-level env — variables for every job
env:
TF_VERSION: "1.9.5"
An env block at the file's root (at the same level as on and jobs, not inside any specific job) defines environment variables available to every job in this workflow. It's the right place for something that's genuinely constant across the whole pipeline —like the Terraform version you're going to pin in Module 3— not for something only one job needs.
jobs — the unit of work
jobs:
inspect-environment:
runs-on: ubuntu-latest
...
jobs is a map (not a list): each key —here, inspect-environment— is a job's ID, the short identifier you use to reference it from the terminal (act -j inspect-environment) or from another job with needs: (Module 5). This workflow has a single job, but a real file can have several, each running in parallel by default —unless one declares needs: on another, forcing an order. That chaining is exactly what you're going to build in Module 5, when apply.yml needs to wait for plan to finish successfully before starting.
runs-on — what image the job runs on
runs-on: ubuntu-latest
runs-on tells the engine —real GitHub, or act on your machine— what operating system/image to run this specific job on. ubuntu-latest is, by far, the most common choice in real workflows; under act, your .actrc resolves that generic tag to the concrete image you pinned in Module 1: catthehacker/ubuntu:act-latest. A job can declare runs-on: windows-latest or runs-on: macos-latest if it needs that specific operating system —act also supports simulating them, with different images, though this guide doesn't use them: all of Andes Cargo's work runs on Linux.
Job-level env — variables only for this job
env:
REGION: us-east-1
An env block inside a specific job (at the same indentation as runs-on and steps, not the file's root) only applies to that job — if the workflow had a second job, REGION wouldn't exist there, unless that second job declares its own env. It's the right level for something specific to a task, like the AWS region a deployment job needs and a read-only job might not.
steps — the ordered sequence within a job
steps:
- name: Check out the repository
uses: actions/checkout@v4
with:
fetch-depth: 0
steps is a list (not a map, notice the dash - before each name), and the order they appear in is the exact order they run — top to bottom, one after another, never in parallel within the same job. Each step can have a name (optional but strongly recommended — it's what you see in act's output, in each line's 🐳) and exactly one of these two ways of doing work: run (a shell command) or uses (a reused Action). Never both at once in the same step.
uses and with — reusing code, with parameters
uses: actions/checkout@v4 doesn't run a shell command — it downloads and executes someone else's code (in this case, GitHub's team), published as a reusable Action in the Marketplace. actions/checkout is, by far, the most-used Action across all of GitHub Actions: it puts your repository's content inside the job's workspace — without it, every step would start in an empty directory, without seeing a single line of your code.
with: passes input parameters to that Action, the same way you'd pass arguments to a function. fetch-depth: 0 is a parameter specific to actions/checkout that tells it to fetch Git's full history, not just the last commit (the default value, 1, is faster but doesn't work if your workflow needs, for example, to compare against an earlier commit). This module's lesson 5 goes deeper into uses/with with more examples, including the Terraform Action you're going to use starting in Module 3.
Step-level env — variables only for that step
- name: Show a step-level env var, scoped to this step alone
env:
STEP_ONLY: "visible-here-only"
run: |
echo "STEP_ONLY inside this step: ${STEP_ONLY}"
This is the narrowest of the three levels: a variable declared in a specific step's env only exists during that step — not before, not in the following step. It's env's complete hierarchy in GitHub Actions: workflow (every job) → job (every step in that job) → step (only that step) — each more specific level can add new variables, without affecting the broader levels.
Running it: act -l first, without running anything
act -l
What to expect (literal output, executed to write this lesson):
Stage Job ID Job name Workflow name Workflow file Events
0 inspect-environment inspect-environment andes-cargo-ci-demo anatomy-demo.yml push,pull_request,workflow_dispatch
Notice the Events column: the three events from the on block appear together, comma-separated — this single job is going to run for any of the three, it doesn't need three separate jobs.
Running it: act push, with the complete output
act push
What to expect (literal output, executed to write this lesson; the Apple Silicon architecture warning from Module 1 is omitted for brevity, you already know it):
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Set up job
[andes-cargo-ci-demo/inspect-environment] 🚀 Start image=catthehacker/ubuntu:act-latest
[andes-cargo-ci-demo/inspect-environment] 🐳 docker pull image=catthehacker/ubuntu:act-latest platform= username= forcePull=true
[andes-cargo-ci-demo/inspect-environment] 🐳 docker create image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[andes-cargo-ci-demo/inspect-environment] 🐳 docker run image=catthehacker/ubuntu:act-latest platform= entrypoint=["tail" "-f" "/dev/null"] cmd=[] network="host"
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Set up job
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Main Check out the repository
[andes-cargo-ci-demo/inspect-environment] 🐳 docker cp src=/path/to/your/lab/. dst=/path/to/your/lab
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Main Check out the repository [26.732ms]
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Main Show workflow-level and job-level context
[andes-cargo-ci-demo/inspect-environment] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/1] user= workdir=
[andes-cargo-ci-demo/inspect-environment] | Terraform version pinned at workflow level: 1.9.5
[andes-cargo-ci-demo/inspect-environment] | AWS region pinned at job level: us-east-1
[andes-cargo-ci-demo/inspect-environment] | Event that triggered this run: push
[andes-cargo-ci-demo/inspect-environment] | Run id: 1
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Main Show workflow-level and job-level context [62.416167ms]
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Main Show a step-level env var, scoped to this step alone
[andes-cargo-ci-demo/inspect-environment] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[andes-cargo-ci-demo/inspect-environment] | STEP_ONLY inside this step: visible-here-only
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Main Show a step-level env var, scoped to this step alone [63.100167ms]
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Main Confirm the step-level var does not leak into this step
[andes-cargo-ci-demo/inspect-environment] 🐳 docker exec cmd=[bash -e /var/run/act/workflow/3] user= workdir=
[andes-cargo-ci-demo/inspect-environment] | STEP_ONLY outside that step: ''
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Main Confirm the step-level var does not leak into this step [59.551333ms]
[andes-cargo-ci-demo/inspect-environment] ⭐ Run Complete job
[andes-cargo-ci-demo/inspect-environment] ✅ Success - Complete job
[andes-cargo-ci-demo/inspect-environment] 🏁 Job succeeded
(github.run_id, in the output, is 1 — fixed under act, as you confirmed in Module 1. The path replacing docker cp src=... is your own lab's on your machine — variable, depends on where you cloned the project.)
Read it carefully for the three exact points this lesson wanted to demonstrate:
Terraform version pinned at workflow level: 1.9.5— confirmsTF_VERSION, declared in the root'senv, reached the step without the job having to redeclare it.AWS region pinned at job level: us-east-1— confirmsREGION, declared in the job'senv, also arrived, without being in the root'senv.STEP_ONLY inside this step: visible-here-onlyfollowed bySTEP_ONLY outside that step: ''— direct proof that a step-levelenvvariable doesn't survive into the following step. It's empty —not an error, exactly the expected behavior of the narrowest of the three scopes.
Also notice a technical detail worth understanding now, not discovering by accident: the docker cp src=... dst=... line in the Check out the repository step — under act, actions/checkout doesn't do a real git clone against any remote server. It copies your local folder's content into the job's container, with docker cp. That makes sense: there's no "remote GitHub" in this simulation, so act uses the only thing that does exist, your local working directory. It's the same real Action, the same uses: actions/checkout@v4 that would run on a real GitHub.com repository —where it would do a real git clone— solving the same problem (putting the repo's code in the job's workspace) with the mechanism available in each case.
Going deeper: why each env level exists
It's not a design whim that GitHub Actions has three levels of env instead of just one. Each level solves a real organizational problem:
- Workflow-level: for something genuinely constant across the whole pipeline —a tool's version, a global behavior flag. Changing it once changes every job's behavior.
- Job-level: for something specific to a task, but that job needs across several of its steps —the AWS region, an environment's name. Avoids repeating the same value in every step.
- Step-level: for something only one step genuinely needs, typically a sensitive or temporary value that shouldn't "contaminate" the rest of the job. You're going to use exactly this pattern in Module 4, when a secret enters a specific step's environment, not the whole job's.
Common mistakes
Confusing run with uses and putting both in the same step (syntax-based). What happens: someone writes a step with uses: actions/checkout@v4 and, at the same indentation, adds run: echo "hello", expecting both to run. Why it happens: it seems reasonable that a step could "first reuse code, then run an extra command." How to spot it: GitHub Actions (and act) are going to fail parsing the workflow, or —depending on the version— silently ignore one of the two. How to fix it: a step is one or the other, never both. If you need to reuse an Action and then run a command, those are two different steps, one after the other in the same steps list.
Writing a value with a colon inside a single-line run:, without quoting YAML understands (syntax-based, reproduced in this very lesson). What happens: a step like run: echo "text: value" —on a single line, without |— can fail parsing if YAML interprets the : followed by a space as the start of a new key inside a mapping, even while technically inside double quotes on the right-hand side. Reproduced for this lesson, when writing the STEP_ONLY step on a single line instead of the | block you see above, act responded: Error: workflow is not valid. 'anatomy-demo.yml': yaml: line 34: mapping values are not allowed in this context. How to spot it: the exact message mapping values are not allowed in this context, pointing at a line that does have a quoted string with a : inside. How to fix it: for any run that's going to print text with a colon, use the literal block run: | (like every step in this lesson) — inside a | block, every line is literal text, with none of a YAML mapping's scanning rules.
Believing jobs always runs in the order they're written in the file (conceptual). What happens: someone with two jobs in the same workflow assumes the second waits for the first to finish, simply because it's written after it. Why it happens: it superficially resembles steps, where order does matter. How to spot it: if you expect a second job to "wait its turn" without having explicitly declared needs:. How to fix it: by default, every job in a workflow runs in parallel, with no guaranteed order between them — order only exists if you declare it explicitly with needs: (Module 5). Sequential order is a property of steps within a job, not of jobs within a workflow.
Exercises
Exercise 1 — Predict the result of an impossible fourth level. A coworker asks you: "does an env level even more specific than step exist, for example at the level of a single command inside a multi-line run: |?" Answer them precisely, based on what you saw in this lesson.
See solution
No — the step level is the most specific one that exists in GitHub Actions. Inside a multi-line run: | block, every line shares exactly the same environment as the complete step; there's no way to declare a variable "only for the third line of this script." If you need that level of isolation, the only real option is splitting that run into separate steps, each with its own env block.
Exercise 2 — Explain actions/checkout's docker cp under act. Without looking at this lesson, explain to a colleague why act push's output shows docker cp instead of git clone for the step using actions/checkout@v4, and why this doesn't mean act is using a different version of the Action.
See solution
A complete answer sounds, roughly, like this: "actions/checkout normally clones your repository from GitHub's server into the runner's workspace — but under act, there's no GitHub server involved at all, everything runs on your machine. act solves that same problem —putting your code in the job's workspace— by directly copying your local folder with docker cp, instead of doing a remote git clone. It's exactly the same Action, the same version (v4), the same uses: in the YAML; what changes is the low-level mechanism it uses to achieve the same result, because the execution environment is different."
Exercise 3 — Diagnose the mapping values error. A colleague shows you this error: Error: workflow is not valid. 'ci.yml': yaml: line 12: mapping values are not allowed in this context, and swears their YAML "looks perfect." Without seeing their file, what would you ask them first, based on what you learned in "Common mistakes"?
See solution
You'd ask them first: "do you have any single-line run: (without the | block) that prints text with a colon inside, like run: echo "result: ok"?" It's, by far, this exact error's most common cause: YAML interprets the : followed by a space inside a single-line value as the start of a new key-value pair, even if it's technically inside quotes on the right side of another field. The fix is almost always changing that single-line run: to a run: | block.
Summary and next step
In this lesson you dissected a complete real workflow, field by field, running it with act as you read each piece: on as the trigger condition, jobs as the unit of work with its own runs-on, steps as the ordered sequence within a job, uses/with as the way to reuse code with parameters, and env at its three levels —workflow, job, step— confirmed with literal output that each level has exactly the scope it promises. You also reproduced, live, this layer's most common YAML error: a single-line run: with a colon inside.
Before moving on you should be able to: explain from memory what each of the seven main fields does; predict which step a given env variable is going to be available in based on what level it was declared at; and recognize the mapping values are not allowed error without having to look it up.
You have the complete anatomy. Lesson 3 goes deeper into the field that opens the whole file: on, with an infrastructure pipeline's three most common events.
Resources
- GitHub Docs — Workflow syntax for GitHub Actions — the complete official reference for every field in this lesson.
- nektosact.com — User Guide — official documentation for
act -landact push, used to run this lesson's workflow. - GitHub — actions/checkout — the official repository for the Action used in this lesson, with its complete parameter documentation (
with:).