The anatomy of a workflow
A workflow file is YAML, and YAML has one rule that trips everyone: indentation is meaning. Two spaces in means "this belongs to the line above". Tabs are not allowed. A job is a list of work that runs on one fresh machine; a step is one command or action inside that job, run in order. Read this small file from the top: on a pull request, run one job called check, on Ubuntu, with two steps.
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "The robot read the repository"
Move runs-on two spaces to the left and it no longer belongs to check; the run fails before it starts. That is the first error most people meet, and now you will recognise it.
Infrastructure code is reviewable, but its checks only help if someone runs them. GitHub Actions lets a repository run defined jobs for events such as a pull request. Put a YAML file in .github/workflows/, give it a trigger, and GitHub starts the job when that event occurs. The run can still fail, wait for approval, or be skipped by repository settings; inspect the result before trusting it.
- Workflow
- An automated process defined in a YAML file in .github/workflows/, started by an event, made of one or more jobs, each a sequence of steps run on a fresh machine called a runner.
One small, real workflow contains every concept in this class. This is Campux's first — it validates the Bicep from Class Twenty on every pull request:
# .github/workflows/validate.yml
name: Validate infrastructure
on:
pull_request:
branches: [ main ]
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Build Bicep (fails on errors)
run: az bicep build --file infra/main.bicep
- on — the trigger
- The event that starts the workflow: a push, a pull_request, a schedule (cron), or workflow_dispatch — a button a human presses.
- job
- A named unit of work. Multiple jobs run in parallel by default, each on its own machine; needs: chains them in order.
- runner
- The machine a job runs on. ubuntu-latest is a fresh virtual machine GitHub provides, used once and destroyed.1
- step
- One command (run:) or one reusable action (uses:). Steps in a job run in order and share the runner's filesystem.
- action
- A packaged, reusable step from the marketplace — actions/checkout@v7 clones your repository so later steps can see the code. You will use ten of these before you ever write one.
Read the flow top to bottom: an eligible pull request against main starts a job on a fresh runner; checkout supplies the code and Azure CLI compiles the Bicep. The sample only compiles. It does not run what-if, deploy, or contact an Azure subscription. If this exact job is configured as a required check on the protected branch, a failing result blocks merge. A first-time or forked contribution may need approval before its run starts.
Play it through
Four minutes, two things to do. Wire the workflow to a pull request against main, then open a PR with a broken change. A required failing check can block its merge. The game pauses when it needs your choice.
Contexts and expressions
Workflows need to know things about the run they are in — who opened the pull request, which branch, what secret to use. That information arrives through contexts, read with the ${{ … }} expression syntax. You need surprisingly few of them:
# the contexts you will actually use steps: - name: Show the facts of the run run: | echo "Actor: ${{ github.actor }}" echo "Branch: ${{ github.ref_name }}" echo "Event: ${{ github.event_name }}" - name: Deploy — but only from main if: github.ref_name == 'main' run: ./deploy.sh # secrets.NAME and vars.NAME — covered next section
The github context identifies the event, actor, and ref. An if: can condition a step or job; it is not an authorization boundary for a deploy by itself. Pair it with appropriate token permissions, environment rules, and a trusted event. secrets and vars supply configuration, but availability depends on the event and repository settings. For debugging, print only the specific non-sensitive fields you need; dumping an entire context can disclose data.
Secrets, variables, environments
The moment a workflow does anything real — deploys, publishes, talks to Azure — it needs configuration, and some of that configuration is sensitive. GitHub gives you three containers for it, and choosing the wrong one is a security decision made by accident.
| Container | What goes in it | Behaviour |
|---|---|---|
| Secrets | Credentials, keys, tokens — anything that grants access | Stored encrypted and referenced with secrets.NAME. Log masking is best effort; a secret may be unavailable to a forked PR. |
| Variables | Plain configuration — region names, resource group names, flags | Visible to anyone who can read settings; fine, because nothing in them grants access. Read via vars.NAME. |
| Environments | A named target — nonprod, production — that can have its own secrets, variables, and protection rules | A job declaring environment: production follows the rules actually configured for that environment before receiving its environment secrets. |
An environment can scope a deployment job and its secrets. A production approval gate works only when required reviewers are configured and supported by the repository's plan and visibility; naming an environment does not create an approval gate. Check the current GitHub plan rules before relying on one. A forked pull request normally receives no repository secrets, while a privileged trigger needs special care with untrusted code. For the validation workflow in this class, use no Azure credentials at all.
Secret masking is a backstop, not permission to print credentials. GitHub says redaction is best effort; a transformed, split, or derived value may escape it.2 Pass a secret only to the step that needs it, preferably through an environment variable or standard input. Never print it or interpolate it directly into a shell command.
Matrix builds and reuse
Workflows accumulate duplication fast: the same steps against three environments, the same validation in four repositories. Actions has two answers. A matrix runs one job definition many times with different inputs, in parallel:
# one job definition, two independent Bicep builds
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
template: [ infra/network.bicep, infra/storage.bicep ]
steps:
- uses: actions/checkout@v7
- name: Compile ${{ matrix.template }}
run: az bicep build --file "${{ matrix.template }}"
This illustrative matrix assumes those two files exist in the repository. Each copy of the job compiles one file without Azure credentials. A reusable workflow uses workflow_call so other workflows can invoke reviewed steps with declared inputs and secrets. Reuse helps when several repositories need the same process; it also makes the shared workflow a dependency whose changes deserve review.
The workflow file is production code
Here is the fact that separates teams that take CI seriously from teams that have incidents about it: the workflow file can read your secrets and deploy to your production. That sentence has an implication — whatever reviews production changes must review this file with the same care. A one-line edit to a workflow can widen a trigger, print a credential, skip a validation step, or point a deploy at the wrong subscription, and it will look like harmless YAML in a diff full of real code.
The pipeline is production code.
Review a workflow diff with four questions. Trigger: did on: change — does anything new start this workflow, and could an outsider's pull request start it? Permissions: what can the job now reach — new secrets, a new environment, a wider token? Steps: is anything echoed, uploaded, or sent somewhere that was not before? Gates: did a needs:, an if:, or an environment protection quietly disappear? Four questions, thirty seconds each — and they catch the class of bug that a thousand green runs never will, because the run only proves the workflow did what it says, not that it says the right thing.
The robot joins the team
In this case study, Campux adds the §1 validate.yml workflow and makes its validate job a required check after observing the check name in a real PR. It compiles Bicep; it does not post a what-if, which would need Azure authentication and permissions. A reviewer gets a clear syntax and type check before merge, not a preview of live resource changes.
Suppose a pull request renames a storage parameter but a module still uses the old name. The compile job fails and a properly configured required check blocks merge. The runtime is not a promised ninety seconds; runner availability and file size vary. Deployment identity and an Azure-backed preview come later, with Class Twenty-Three's OIDC workflow.3
See the workflow model in the source material
Introduction to GitHub Actions
learn.microsoft.com/training/modules/introduction-to-github-actions/
GitHub Docs — Understanding GitHub Actions
docs.github.com/actions/get-started/understanding-github-actions
Make the robot do the checking
You wired a pull-request trigger above. Now add a workflow to a repository you own, open a pull request, and inspect the run. Use your campux-platform repository from Class Eighteen, or another repository you can push to.
Start from an up-to-date default branch, then make a branch for the workflow. In your editor, create the file at the exact path shown next:
git switch main git pull git switch -c add-workflow
What to notice: workflows only run from .github/workflows/. A YAML file anywhere else in the repository is just a file.Create .github/workflows/hello.yml — a trigger, a job, two steps:
name: Hello CI on: pull_request: workflow_dispatch: jobs: greet: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v7 - name: Report the facts run: | echo "Actor: ${{ github.actor }}" echo "Event: ${{ github.event_name }}" ls -laWhat to notice: two triggers — pull_request for the machine, workflow_dispatch so you can also fire it by hand from the Actions tab.Commit, push, and open a pull request:
git add .github/workflows/hello.yml git commit -m "Add first workflow" git push -u origin add-workflow
On screen: open the PR on github.com. If repository settings allow the run, a check named Hello CI / greet appears. First-time contributions may need approval. Click through to the Actions tab to inspect each step.Read the finished run, then trigger it the second way:
# in the run log, find: Actor: <your-username> Event: pull_requestThe lesson: the ls -la output shows what the fresh runner checked out. For a pull-request run, checkout normally uses GitHub's test merge commit. After reviewing the PR, merge it so the workflow exists on the default branch. Then go to Actions → Hello CI → Run workflow. This manual run reports workflow_dispatch.
Use a secret without printing it
Store a harmless practice value and let a workflow check whether it is available. The workflow never displays the value. Use a real credential only when a real job needs it.
In the repository: Settings → Secrets and variables → Actions → New repository secret. Name it LAB_SECRET, value campux-rosebud-2026.
What to notice: after saving, GitHub will show you the name but never the value again — even you cannot read it back. Update or delete are the only options.On a branch, add .github/workflows/secret-check.yml:
name: Secret availability on: workflow_dispatch jobs: check: runs-on: ubuntu-latest permissions: contents: read steps: - name: Check availability without disclosure env: LAB_SECRET: ${{ secrets.LAB_SECRET }} run: | if [ -z "$LAB_SECRET" ]; then echo "Practice secret unavailable" exit 1 fi echo "Practice secret available; value withheld"What to notice: the expression supplies the value to one step through env. The shell uses the variable without echoing it or placing it in a command argument.Review and merge the workflow to the default branch, then start it from the Actions tab. If it reports the value is unavailable, verify the secret name and repository settings.
On screen: the successful log confirms availability but never shows the value. GitHub's log masking is only a backstop; this workflow does not depend on it.Delete the practice secret and the temporary workflow in a follow-up pull request.
The lesson: send a secret only to the step that needs it. During review, question any command that prints it, transforms it for logging, or puts it in a command-line argument.
Zoom out: automation is a system that runs whether or not anyone is watching
You can write a validate workflow. Now reason about what the pipeline does as a system, because a check people trust and a check people learn to ignore look identical until the day it matters.
A check that only validates buys confidence cheaply; a flaky check teaches people to re-run until green, which trains them to ignore red — including the red that is real. What keeps the signal trustworthy?
Every merge now depends on the workflow; when the runner or an action is down, delivery stops — the pipeline is infrastructure, not a convenience.
The pipeline’s throughput and trust are the ceiling on delivery speed; a slow or unreliable gate slows the whole team no matter how fast they code.
A workflow with broad permissions is a supply-chain surface; the convenience today is the breach path the day a dependency is compromised.
Copy-pasted workflows across repos drift, so one fix needs many commits — which is why reusability is a scaling requirement, not a nicety.
The engineer who ships is asked "did it pass?" The engineer who gets promoted is asked "and do you trust green enough to sleep?" — and made green mean something.
A merge, not a ritual
Someone still deploys by running commands on their laptop, and it shows. You move it into a GitHub Actions workflow: push to main, tests run, the deploy happens the same way every time. The next release is a merge, not a ritual — and when it is 2am, the pipeline does not have a bad day the way a tired human does.
Examination
Four drills, then two situations. The situations have no marking scheme — write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored; this is between you and the page.
B. The pull-request event runs validation for changes proposed for main; workflow_dispatch adds a manual run. A push-only trigger does not meet the stated pull-request requirement. C runs later on a schedule, and D depends on someone remembering. To actually block a bad merge, configure the resulting check as required in the branch rules.
Jobs are parallel strangers by default. Each job gets its own fresh runner, started at the same time unless needs: says otherwise — so deploy boots a machine that has never heard of build, and the file is simply not there. GitHub validates syntax, not intent, so nothing warns you (B). The fix is two-part: needs: build for ordering, and an artifact upload/download to carry the file between machines. Steps share a runner; jobs do not. Engineers who blur that line lose an afternoon to it, exactly once.
Scoped configuration, optional gates, and history. A job names an environment to receive its secrets and variables after any configured protection rules pass. A required reviewer is possible when the repository's plan and visibility support it; the environment name alone does not create approval. GitHub also records deployments to the environment. None of this proves that the deployed code works, and no separate repository copy is created.
# deploy.yml (excerpt)
1. jobs:
2. deploy:
3. runs-on: ubuntu-latest
4. environment: production
5. steps:
6. - uses: actions/checkout@v7
7. - run: echo "Deploying with key ${{ secrets.DEPLOY_KEY }}"
8. - name: Deploy
9. env:
10. DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
11. run: ./deploy.sh
Line seven. It prints a credential into a log. GitHub attempts to redact secrets, but masking is best effort and cannot make this safe. The step also interpolates the secret directly into shell code. Delete the line.
The rest can be valid: an Ubuntu runner can deploy, and environment: production scopes the job to that environment. The deploy step receives the key through its environment; deploy.sh must read DEPLOY_KEY without printing it. Avoid putting credentials in command arguments, where they can appear in process listings or diagnostics.
A strong answer concedes before it argues. The manager's puzzlement is fair, because "CI passed" is routinely oversold. Say plainly what green means: every check that exists, passed. Nothing more. CI is a list of specific questions asked automatically — does the Bicep compile, do the tests pass, does the linter object — and a green tick is the answer "no" to each. It has never been a certificate that the release is good; it is a certificate that the release is not bad in the ways we thought to ask about.
Then locate the actual failure: a missing question. Production broke in a way no check covered — a config value only production has, a dependency only production exercises, a load only real traffic produces. That is not automation failing; that is automation faithfully reporting on an incomplete list. The retrospective's real output is one new question added to the list, so this exact failure can never ship quietly again. Teams that do this after every incident compound; their CI is an archive of every way production has ever surprised them.
End with the reframe. CI's honest job is to make the cheap mistakes impossible so humans can spend review attention on the expensive ones — and, per §5, the pipeline itself is code that can be wrong. "What does it prove?" has a one-line answer worth saying verbatim: green proves we asked our questions and got good answers; it cannot prove we asked enough. The manager should leave less impressed by green ticks and more interested in what is on the list — which is exactly the right amount of impressed.
The trap is the premise. "It doesn't run in production" is exactly backwards. The workflow file runs with the repository's secrets in reach and, next class, with credentials into Azure itself. It decides what gets validated, what gets skipped, and where things get deployed. It is not near production power — for this repository it is among the most privileged text anyone writes. The friction your teammate resents is not bureaucracy misapplied to YAML; it is review applied precisely where it pays most.
Make the risk concrete. A trigger change can make a workflow run for outside contributions. A new step in a privileged workflow might disclose a secret or send it elsewhere. Removing a validation step can let a bad change through. Ordinary forked pull-request workflows do not receive repository secrets, but that is no reason to relax review of trusted deploy workflows. Review changes to .github/ with the same care as application code.
Then fix the legitimate complaint underneath. Slow review is a real problem with a better solution than no review. Offer the §5 checklist — trigger, permissions, steps, gates — which turns a workflow diff into a two-minute read, and add a CODEOWNERS line from Class Eighteen routing .github/ to the people who review it fastest and best. The answer to "review is slow" is making review fast; keep the requirement, fix the speed.
Five things worth carrying out of this class
- A workflow is a YAML file in .github/workflows/: a trigger (on:), jobs that run in parallel on fresh runners unless needs: orders them, and steps that run in sequence and share a machine.
- Contexts supply run facts such as github.actor and github.event_name. Print only the non-sensitive fields needed for debugging; if: controls a step but is not a security boundary by itself.
- Secrets hold sensitive values; variables hold plain configuration. An environment can scope both and enforce approval when its protection rules are configured and supported.
- Log masking is best effort. Never print a secret or put it in a command argument; pass it only to the step that needs it.
- The workflow file is production code: it reads secrets and directs deploys. Review its diffs with four questions — trigger, permissions, steps, gates — because green runs prove the checks passed, not that the checks are right.
- GitHub-hosted runners are ephemeral virtual machines — created for the job, destroyed after it — with Linux, Windows, and macOS options and a generous free allowance for public repositories and small private use. The ephemerality is a feature: nothing from the previous run survives, so every run starts from a provable clean slate. Class Twenty-Four covers what hosted runners cannot do, and the security bill you take on when you host your own. ↩
- GitHub describes secret redaction as best effort and warns that transformed or derived values may not be masked. Its guidance also recommends avoiding secrets on command lines. See Secure use reference and Using secrets in workflows. Treat masking as accident protection, never as a reason to log a credential. ↩
- This class teaches GitHub Actions because Campux's code lives on GitHub and hiring demand is broad — but the AZ-400 exam and plenty of employers also expect Azure Pipelines, its sibling inside Azure DevOps. The concepts map almost one-to-one (triggers, stages, agents, service connections), so treat the second system as an afternoon of vocabulary, not a new discipline. Which one "wins" varies by shop and by year; the model transfers, as usual. ↩
Write a GitHub Actions workflow that validates a pull request and inspect its result.
Play the lesson above to stamp this class.