CAMPUX Cloud Bootcamp Phase Three · Class Twenty-Four
Phase Three — DevOps Core
Reading 40 min · Drills 6 · 2 Labs
Aligned to AZ-400
Class Twenty-Four

Runners & advanced pipelines

Every workflow you have written so far ran on a machine you never thought about — this class is about that machine, what it costs, what it can leak, and the patterns that keep ten pipelines from becoming ten problems.

§1

Hosted runners — what you get, what you don't

Every runs-on: ubuntu-latest you have typed summoned a fresh virtual machine from GitHub's fleet: booted for your job, preloaded with an enormous toolbox — Git, Docker, the Azure CLI, half a dozen language runtimes — used once, and destroyed. You never patched it, never paid its idle hours, and never inherited anything from the previous job, because there was no previous job. That disposability is the quiet security feature of everything you built in the last two classes: a fresh machine has no leftover credentials, no poisoned cache, no drift. For most teams, most of the time, hosted runners are simply the correct answer, and the interesting question is only what they cannot do.

They cannot reach your private network — a hosted runner lives in GitHub's cloud, so the Class Fourteen private endpoint or an internal-only service is invisible to it. They come in fixed shapes, so a build that wants serious cores, unusual memory, or a GPU is out of luck at the standard sizes. They start from a blank slate every time, so a gigantic toolchain must be fetched on every run — that is a speed tax §3 will address. And they are metered: public repositories run free, private ones burn a minutes allowance and then a credit card, with Windows and macOS minutes billed at a multiple of Linux.1 Those four limits — network, hardware, startup state, and money — are the complete list of reasons to consider the alternative. Everything else is a reason not to.

Self-hosted runner
A machine you own — a VM, a server, a container — running GitHub's runner agent, picking up jobs from your repository. You gain its network position, its hardware, and its persistence; you take on its patching, its scaling, and everything a malicious job could do to it.
§2

Self-hosted — the bill you take on

A self-hosted runner solves all four limits at once. Put the agent on a VM inside the Campux VNet and the pipeline can reach the private endpoints of Class Fourteen. Choose the hardware and the build gets whatever cores or GPU it needs. The machine persists between jobs, so the toolchain is already there. And your own compute may be cheaper than metered minutes at scale. Teams reach for it for exactly these reasons, and sometimes they are right to.

Now read the bill. That machine is yours: you patch it, monitor it, scale it, and rebuild it when it fills up with the residue of a thousand builds — because unlike a hosted runner, it remembers. Persistence is the feature and the flaw in one property: whatever job ran at nine o'clock leaves files, processes, and possibly credentials on the machine that runs the ten o'clock job. And the sharpest edge has a name in GitHub's own documentation: never attach a self-hosted runner to a public repository. A public repository accepts pull requests from anyone on the internet, a pull request can modify a workflow, and a workflow is a program your runner executes — which chains into "any stranger can run code on a machine inside your network". The hosted runner absorbs that risk by being disposable and GitHub's problem; the self-hosted runner hands it to you whole.2

Table 1 — Hosted against self-hosted
GitHub-hostedSelf-hosted
Machine stateFresh VM per job, destroyed after — nothing survivesPersistent — fast, and contaminated by every previous job
Network reachPublic internet onlyWherever you put it — including inside your VNet
HardwareFixed menu of sizesAnything you can rack or rent
UpkeepGitHub patches, scales, and pays the idle timeYou do — patching, scaling, monitoring, rebuilding
Public reposFree minutes, safely sandboxedNever — stranger's PR code executes on your machine

The decision rule is short: hosted until a named requirement — private network, specific hardware, or minutes-at-scale economics — forces the question, and then self-hosted with the discipline of a production system, because that is what it is. "It would be a bit faster" is not a named requirement; it is how teams acquire an unpatched Linux box with production network access and a job queue anyone in the org can feed.

§3

Caching and artifacts — same shape, different job

Two Actions features move files around, and confusing them costs real debugging time. A cache makes the next run faster: dependencies that take minutes to download get stored against a key and restored on the following run. An artifact carries results — within a run between jobs (which, as Drill 02 of Class Twenty-Two taught, are strangers on separate machines), or out of the run to a human who downloads them.

# cache: speed across runs — keyed on the lockfile
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}

# artifact: truth within and beyond a run
- uses: actions/upload-artifact@v4
  with:
    name: whatif-report
    path: whatif-output.txt
# ...and in a later job:
- uses: actions/download-artifact@v4
  with:
    name: whatif-report

The key line deserves the attention. hashFiles('package-lock.json') means the cache is valid exactly as long as the dependency list is unchanged — edit the lockfile and the key changes, the old cache misses, and a fresh download rebuilds it. Get the key wrong in the other direction — too static — and you serve stale dependencies forever while wondering why the pipeline disagrees with your laptop. The rule of thumb: cache things you can afford to lose (it is a speed optimisation, and caches are evicted after a week unused), artifact things you need to trust — a compiled template, a what-if report for an approver, test results an auditor might ask for. Cache is speed; artifact is evidence.

§4

Reusable workflows and composite actions

The moment Campux has two repositories, the deploy workflow wants to exist twice — and Class Nineteen already taught you what copies do: they drift. The fix is a reusable workflow: a workflow whose trigger is workflow_call, which other workflows invoke like a function, passing inputs and secrets explicitly.

# platform repo: .github/workflows/deploy-bicep.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with: { client-id: '${{ vars.AZURE_CLIENT_ID }}', tenant-id: '${{ vars.AZURE_TENANT_ID }}', subscription-id: '${{ vars.AZURE_SUBSCRIPTION_ID }}' }
      - run: az deployment group create --resource-group rg-campux-${{ inputs.environment }} --template-file infra/main.bicep

# any other repo calls it in three lines:
jobs:
  deploy-nonprod:
    uses: campux/campux-platform/.github/workflows/deploy-bicep.yml@main
    with: { environment: nonprod }

Its smaller sibling is the composite action — a bundle of steps (an action.yml in its own folder or repository) for sequences that repeat inside jobs, like "log in to Azure and select the subscription". The division of labour: composite action for a reusable step sequence, reusable workflow for a whole job with its runner, environment, and gates. Extraction has the same discipline as Bicep modules — do it when the third copy appears, version the shared thing with tags rather than @main once others depend on it, and remember that a shared workflow is now an interface: changing its inputs breaks every caller at once, which is exactly the power and exactly the risk.

§5

Pipeline hygiene

Three settings separate pipelines built by professionals from pipelines that merely work. None takes more than two lines, and all three exist because of a failure somebody paid for.

timeout-minutes
The default job timeout is measured in hours. A hung job — a prompt waiting for input that never comes, a network call that never returns — burns paid minutes and blocks the queue the whole time. timeout-minutes: 15 on every job converts a silent all-nighter into a fast, visible failure.
concurrency
Two merges in quick succession mean two deploys running at once — the Terraform lock fight from Class Twenty-One, recreated inside your own pipeline. concurrency: { group: deploy-prod, cancel-in-progress: false } queues them one at a time; for PR validation, cancel-in-progress: true stops wasting minutes checking commits already superseded.
permissions
Every job gets an automatic GITHUB_TOKEN, and its default powers are broader than most jobs need. Declare permissions: contents: read at the top and grant more only where a job earns it — Class Eight's least privilege, applied to the pipeline's own credential.

Boring pipelines are the achievement.

Notice what all three have in common: they cost nothing when things go well and everything happens the day they are missing. That is the character of this whole class — runners, caches, reuse, hygiene — none of it demos well, all of it decides whether the twentieth repository is as safe as the first. A pipeline that is fast, quiet, and identical every time is not a lack of ambition. It is the ambition.3

Case File · Campux Retail

One deploy workflow, two repositories

campux-platform lends its pipeline to campux-storefront

Campux's e-commerce team has been watching the platform repository's pipeline with envy, and this week their campux-storefront repository gets the same treatment — without copying a line. The deploy logic moves into deploy-bicep.yml behind a workflow_call trigger, and both repositories now call it with three lines and an environment name. The OIDC setup from Class Twenty-Three carries over untouched; the storefront's federated credential simply names its own repository in the subject. One workflow to review, one to fix, two products deploying through it.

The same week delivers the hygiene lessons on schedule. A hung integration test burns four hours of minutes overnight — timeout-minutes: 20 lands in the shared workflow the next morning, fixing both repositories in one commit, which is rather the point. And a proposal to stand up a self-hosted runner "because the storefront build is slow" dies in review when someone asks the §2 questions: no private-network need, no special hardware, and the actual slowness is npm downloading the same packages every run. A cache keyed on the lockfile cuts the build from nine minutes to four, and the unpatched-server-inside-the-VNet future quietly fails to happen.

Watch · Microsoft Learn

The official pages, and a CAMPUX overview

The runners page first; the reuse page when Lab 2 asks for it
CAMPUX overview video

A tour of a live run's machine (what ubuntu-latest actually ships with), a cache hit cutting a build in half, and one reusable workflow called from two repositories will live here. Video to be added.

Lab 1 · Cache & artifact

Feel the difference between speed and evidence

~15 minutes · the hello-github repository · browser or terminal

Build a two-job workflow where an artifact carries a file across the job boundary, then add a cache and watch the second run get faster.

  1. On a branch, add .github/workflows/carry.yml — two jobs, one file passed between them:

    name: Carry a file
    on: workflow_dispatch
    jobs:
      produce:
        runs-on: ubuntu-latest
        steps:
          - run: echo "built at $(date)" > report.txt
          - uses: actions/upload-artifact@v4
            with: { name: report, path: report.txt }
      consume:
        runs-on: ubuntu-latest
        needs: produce
        steps:
          - uses: actions/download-artifact@v4
            with: { name: report }
          - run: cat report.txt
    What to notice: without the artifact pair, consume would find nothing — it is a different machine. The needs: gives order; the artifact gives the file.
  2. Merge, run it from the Actions tab, and open the run page.

    On screen: consume prints the timestamp written by produce, and the run's summary page offers report as a downloadable file — the same artifact, available to humans. That download is how a what-if report reaches an approver in a real pipeline.
  3. Now make something worth caching. Add a job that installs a chunky dependency, uncached:

      slow:
        runs-on: ubuntu-latest
        steps:
          - run: pip install --target ./deps "pandas"
          - run: du -sh ./deps
    What to notice: run it once and note the install step's duration in the log — this is the tax a fresh machine pays every single run.
  4. Add a cache in front of the install, keyed on the dependency name, and run twice more:

          - uses: actions/cache@v4
            with:
              path: ./deps
              key: deps-pandas-v1
          - run: pip install --target ./deps "pandas"
    The lesson: run one reports Cache not found and pays full price; run two reports a cache hit and the install collapses to seconds. Then note the flaw you built: the key is static, so this cache would never notice a new pandas version — which is why real keys hash the lockfile, per §3.
Lab 2 · Reusable workflow

Write the pipeline once, call it twice

~15 minutes · same repository · continues Lab 1

Extract a checking job into a workflow_call workflow with an input, then call it twice with different values — the same mechanics Campux uses across two repositories, demonstrated inside one.

  1. Create the reusable piece at .github/workflows/greet-reusable.yml:

    name: Reusable greeter
    on:
      workflow_call:
        inputs:
          environment:
            required: true
            type: string
    jobs:
      greet:
        runs-on: ubuntu-latest
        steps:
          - run: echo "Validating for ${{ inputs.environment }}"
    What to notice: the only trigger is workflow_call — this workflow cannot start itself. It exists to be called, and the input is its contract.
  2. Create the caller at .github/workflows/caller.yml:

    name: Call it twice
    on: workflow_dispatch
    jobs:
      check-dev:
        uses: ./.github/workflows/greet-reusable.yml
        with: { environment: dev }
      check-prod:
        uses: ./.github/workflows/greet-reusable.yml
        with: { environment: prod }
    What to notice: a job whose body is uses: instead of steps: — the entire job is delegated. The local ./ path works within a repository; across repositories it becomes org/repo/.github/workflows/file.yml@tag.
  3. Merge and run the caller from the Actions tab.

    On screen: two parallel jobs, check-dev / greet and check-prod / greet, each printing its own environment — one definition, two invocations, visible in the run graph.
  4. Now feel the interface risk: rename the input from environment to env in the reusable file only, and run again.

    The lesson: both callers fail immediately — you changed a shared contract and every consumer broke at once. That blast radius is why shared workflows get versioned with tags once real repositories depend on them, and why changing one is a reviewed event, not a quick edit. Rename it back before you leave.
On the job

The pipeline grows up

You · Cloud Engineer · a deploy needs a private network

A deployment has to reach a resource that lives inside a private network the hosted runners cannot see. You stand up a self-hosted runner inside that network — patched and least-privileged like production — and split the pipeline into gated stages so the risky step waits for a human. The pipeline grows up from "run some scripts" into a controlled release.

Class Twenty-Four

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 · runners
What happens to a GitHub-hosted runner when its job finishes?
Marked

B. Ephemerality is the design: one job, one fresh VM, then destruction. That is why nothing persists between runs without a cache or artifact — and it is a security property, not just housekeeping, because no credential, file, or compromise outlives the job that created it. A describes what people assume (and what makes them mistake caches for magic); C would be a security catastrophe GitHub goes to lengths to prevent; D does not exist. Hold the contrast with §2: a self-hosted runner is precisely a machine for which B stops being true, and everything in its bill follows from that.

Drill 02Recall · cache vs artifact
A pipeline must hand its what-if report to a human approver, and separately its dependency install is slow. Which pairing is right?
Marked

B — cache is speed, artifact is evidence. The report is a record a human must be able to trust and retrieve: that is an artifact, attached to the run, downloadable from its summary page. The dependencies are bulk data you can always re-download, worth keeping only to save time: that is a cache, keyed on the lockfile. Swap them (A) and the approver's evidence can silently vanish — caches are evicted after a week unused and offer no guarantees — while the dependencies get uploaded as a useless per-run download. C and D each flatten a real distinction: artifacts are not fast shared storage, and caches are not reliable delivery. Choosing correctly is a two-second decision once you say the six-word rule.

Drill 03Select three
Which three are legitimate reasons to run a self-hosted runner?
Marked

Network, hardware, economics — the three named requirements from §2. The second option inverts reality: controlling the machine means owning its risks — patching, persistence between jobs, and everything a malicious workflow can plant — while the hosted runner's disposability is doing silent security work for you. The last option is the exact configuration GitHub's documentation tells you never to build: a public repository means anyone's pull request can execute code on your machine, and "community capacity" is precisely the attack traffic. If a reason does not name one of the three requirements, the answer is hosted — cheaper in attention, which is the currency pipelines actually spend.

Drill 04Spot the error
This proposal is about to be approved. One line is a serious mistake. Which?
# memo: runner for the open-source SDK build
1.  The campux-sdk repository is public, so community
    PRs can contribute fixes.
2.  Builds are slow on hosted runners, so we will register
    a self-hosted runner for the repository.
3.  The runner VM will live in the campux VNet so it can
    also publish to the internal registry.
4.  We will give the runner agent a dedicated service
    account instead of a personal login.
Marked

Line two — and line three turns it from bad to catastrophic. A public repository accepts pull requests from anyone; a pull request can carry workflow changes; and a self-hosted runner executes what workflows say. Approve this memo and any stranger on the internet has a code-execution path onto a Campux machine — one that line three has thoughtfully placed inside the VNet, next to the internal registry and everything Class Fourteen made private. This is the configuration GitHub's documentation flatly tells you not to create.

The other lines are fine or good: a public SDK is a normal choice (A), VNet placement is legitimate for a private repository's runner and is the usual reason to self-host (C), and a dedicated service account is correct hygiene (D). The repair is to split the two needs: hosted runners for the public repository's community builds — sandboxed, disposable, free — and, if internal publishing truly needs a VNet runner, a separate private repository feeds it. The memo's mistake is joining "public" and "self-hosted" in one sentence; the reviewer's job is to never let that sentence survive.

Situation 01Write before you reveal
The storefront team's PR validation takes twenty-two minutes, and engineers have started merging with "skip the checks, it's a one-liner" energy. You are asked to fix the pipeline. Where do you look first, and in what order?
Do not guess. The run page already knows where the twenty-two minutes went.
Reasoning

Measure before touching anything. Open the slowest recent runs and read the per-step timings GitHub already recorded — the twenty-two minutes are itemised, and the itemisation usually embarrasses somebody's assumption. The classic distribution: two minutes of actual tests wrapped in twenty minutes of setup — dependency downloads on a fresh machine every run, a toolchain reinstalled from scratch, a checkout dragging full history it never reads. Name the top two costs by the numbers before proposing anything, because the fix for "downloads are slow" and the fix for "tests are slow" are different work, and guessing wrong burns your credibility along with the minutes.

Apply the cheap fixes in cost order. Dependency downloads: a cache keyed on the lockfile — §3, often the whole story, and the case file's nine-to-four-minutes win was exactly this. Independent checks running in sequence: split into parallel jobs, since jobs are parallel strangers by default and the wall-clock is the longest job, not the sum. Superseded commits still validating: concurrency with cancel-in-progress: true. Only after those: harder choices like trimming the test suite for PRs and running the full sweep on merge. Notice what is not on the list — a self-hosted runner is the last resort, not the first instinct, because §2's bill does not shrink for impatience.

Then say why it mattered. The real incident is not the twenty-two minutes; it is the "skip the checks" culture forming around them — a slow gate teaches people to climb the fence, and every climbed fence is an unreviewed change in production's direction. Fast checks are not a convenience; they are what keeps the Class Eighteen protections politically survivable. Close the loop by reporting the before-and-after run times to the team: pipelines get slow again the moment nobody is watching the number.

Situation 02Write before you reveal
You find the deploy workflow copy-pasted into a third repository — three near-identical files, already drifting (one has the timeout fix, two do not). The engineer who copied it says: "Extracting a shared workflow is a whole project. The copies work today." How do you respond, and what do you actually do?
You have seen this argument before, two classes running. What did drift cost each time?
Reasoning

Concede the true part first. The copies do work today, and extraction is not free — the shared workflow becomes an interface, and Lab 2 demonstrated that changing an interface breaks every caller at once. "It works today" is not wrong; it is just an answer to the wrong question. The right question is what the copies cost per change, and the evidence is already in hand: the timeout fix exists in one file out of three. Every future fix — a security patch to the login step, a new hygiene setting, next quarter's action version bump — must now be found, applied, and verified three times, and the miss rate is already one hundred percent minus one.

Point out that the threshold has already been crossed. The working rule from §4 and from Bicep modules alike: extract at the third copy. This is the third copy. And the drift on display is not hypothetical — the two repositories without the timeout are the ones that will burn a night of minutes when their integration test hangs, and the one with it already proved the fix mattered. Drift in a deploy workflow is drift in the thing that touches production; of all the files to have three diverging versions of, this is the worst candidate.

Then shrink the "whole project" to its real size. The extraction is one afternoon, not a project: move the most complete copy behind workflow_call with an environment input, replace three files with three three-line callers, tag a v1 so callers pin a version instead of chasing @main. Offer to pair on the first repository and let them do the other two — the person who felt the friction becomes the owner of the fix rather than the target of a decree. The closing line worth saying: we are not adding process, we are deleting two files that can betray us.

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

Five things worth carrying out of this class

  1. Hosted runners are fresh, disposable VMs — patched by GitHub, contaminated by nothing, and the correct default. Their four limits: private networks, hardware, per-run startup cost, and metered minutes.
  2. Self-hosting answers a named requirement — network, hardware, or economics — and hands you the whole bill: patching, persistence, scaling, and never, ever a public repository, whose strangers' PRs would execute on your machine.
  3. Cache is speed, artifact is evidence: cache what you can afford to lose (keyed on the lockfile), artifact what someone must trust — reports for approvers, files crossing the job boundary.
  4. Reusable workflows (workflow_call) end copy-paste drift at the third copy — but a shared workflow is an interface: version it with tags, and treat changes as reviewed events, because every caller breaks together.
  5. Three lines of hygiene per job — timeout-minutes, concurrency, least-privilege permissions — cost nothing when things go well and everything the day they are missing. Boring pipelines are the achievement.
Notes
  1. The specific numbers — included minutes per plan, the billing multiplier for Windows and macOS runners, the hosted VM sizes — shift often enough that printing them here would date the page within a year. Treat the structure as settled (public free, private metered, non-Linux costs more, larger runners exist at a premium) and look up the current figures when you are the one paying the bill.
  2. The full mitigation story for self-hosted runners is deeper than one warning: ephemeral (single-use) self-hosted runners, just-in-time registration, and isolation into their own network segment all reduce the persistence and blast-radius problems, at the cost of rebuilding the very convenience that made self-hosting attractive. If your team goes down this road, read GitHub's security hardening guide end to end first — this class gives you the shape of the risk, not the complete armour.
  3. Azure DevOps calls roughly the same concepts agents, agent pools, pipeline caching, and templates; the hosted-versus-self-hosted trade and the public-repository warning translate almost word for word. As with Class Twenty-Two, treat the sibling system as vocabulary, not a new discipline — and expect AZ-400 to quiz the concepts in either dialect.