Skip to content
CAMPUX Cloud Bootcamp Phase Three · Class Twenty-Two
Phase Three — DevOps Core
4 drills · 2 situations · 2 Labs
Aligned to AZ-400
Class Twenty-Two

GitHub Actions fundamentals

You have a branch, a pull request, and infrastructure code. Now put the first check in the repository: an automated job that runs when someone proposes a change, reports what passed or failed, and can be made a required gate before merge.

Phase Three · DevOps Core · Class 22 of 42 · 20 min · Assumes Class 17 and Class 18

After this you can write a GitHub Actions workflow that validates a pull request and inspect its result.

New to this? Read Class 17 and Class 18 first. Already done them, or know the material? Carry on.

§1

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.

Figure — The anatomy, nested
event pull_request fires the trigger workflow · one file (any name) job · lint own fresh runner (VM) step 1 · actions/checkout step 2 · run the linter steps share this machine job · validate own fresh runner (VM) step 1 · actions/checkout step 2 · az bicep build steps share this machine ∥ job · deploy needs: validate — waits starts only after green needs: jobs are parallel strangers on separate machines; steps are roommates in order
The one idea, drawn: the event fires the workflow; the jobs inside run in parallel, each on its own fresh runner, unless needs: holds one back; and the steps inside a job run in order on one shared machine. Copy the nesting by hand once — every Actions question you will field is asking which box a thing lives in.
Play

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.

§2

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.

§3

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.

Table 1 — Three containers, three jobs
ContainerWhat goes in itBehaviour
SecretsCredentials, keys, tokens — anything that grants accessStored encrypted and referenced with secrets.NAME. Log masking is best effort; a secret may be unavailable to a forked PR.
VariablesPlain configuration — region names, resource group names, flagsVisible to anyone who can read settings; fine, because nothing in them grants access. Read via vars.NAME.
EnvironmentsA named target — nonprod, production — that can have its own secrets, variables, and protection rulesA 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.

Later · §4

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.

§5

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.

Case File · Campux Retail

The robot joins the team

campux-platform gets its first workflow

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

Fig. 1 · The robot validates every pull request
pull request renames a storage parameter GitHub Actions · validate.yml builds the Bicep az bicep build runs on every PR nothing is deployed check: FAILED a module still used the old name — compile failed required: no merge till green compile failure caught on the pull request
The sample workflow compiles Bicep on an eligible pull request. A broken reference makes the job fail. Once this check is required in branch protection, a failed run blocks merge. Azure what-if and deployment require additional identity and are outside this first workflow.
Read · official references

See the workflow model in the source material

Use the module to practise; keep the GitHub reference handy while writing workflows
Microsoft Learn · Module

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

Lab 1 · Your first workflow

Make the robot do the checking

~15 minutes · a GitHub repository from Class 18 · browser or terminal

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.

  1. 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.
  2. 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 -la
    What to notice: two triggers — pull_request for the machine, workflow_dispatch so you can also fire it by hand from the Actions tab.
  3. 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.
  4. Read the finished run, then trigger it the second way:

    # in the run log, find:
    Actor:  <your-username>
    Event:  pull_request
    The 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.
Lab 2 · Secret handling

Use a secret without printing it

~10 minutes · same repository · Settings access required

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
The practice value is deliberately harmless. Never put a live credential in a public repository or print it in an Actions log. A secret is also unavailable to an ordinary workflow run from a forked pull request.
Think in systems

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.

Feedback loops

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?

Dependencies & coupling

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 constraint

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.

Second-order effects

A workflow with broad permissions is a supply-chain surface; the convenience today is the breach path the day a dependency is compromised.

What breaks when it scales

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.

On the job

A merge, not a ritual

You · Cloud Engineer · deploys are manual and scary

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.

Class Twenty-Two

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.

Drill 01Recall · triggers
A workflow must run validation on every pull request targeting main, and also let an engineer start it manually for debugging. Which trigger configuration is right?
Marked

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.

Drill 02Recall · jobs & runners
A workflow has two jobs: build writes a file to disk, and deploy expects to read it. There is no needs: between them. What actually happens?
Marked

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.

Drill 03Select three
Which three does a GitHub environment named production genuinely give you?
Marked

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.

Drill 04Spot the error
This deploy job passes review at a glance, but one line is a serious mistake. Which?
# 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
Marked

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.

Situation 01Write before you reveal
A release goes out with every check green — and production breaks within the hour. In the retrospective, a manager asks, genuinely puzzled: "CI passed. How can the deploy be bad? What is all this automation actually proving?" Answer the question honestly.
Green proves the checks passed. Start by saying exactly what the checks check.
Reasoning

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.

Situation 02Write before you reveal
A teammate is annoyed that their workflow-file changes keep getting held up in review: "It's YAML, not code. It doesn't even run in production — it just runs checks. Can we exempt .github/ from required review?" What do you say?
What can a workflow file read, and where can it send what it reads?
Reasoning

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.

Examination record · first attempt
0/4
Class Twenty-Two · Complete
Retain this much

Five things worth carrying out of this class

  1. 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.
  2. 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.
  3. Secrets hold sensitive values; variables hold plain configuration. An environment can scope both and enforce approval when its protection rules are configured and supported.
  4. 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.
  5. 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.
Notes
  1. 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. ↩
  2. 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. ↩
  3. 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. ↩