Skip to content
CAMPUX Cloud Bootcamp Phase Three · Class Twenty-Four
Phase Three — DevOps Core
Reading 50 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.

● Screen walkthrough Not yet recorded · ~8 min
Reel · 00:00 / 08:00

Matrix builds, a self-hosted runner, and environment gates — one push fans out to many jobs.

Placeholder — the class below stands alone until the reel lands
§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 standard hosted runner lives in GitHub's cloud, so the Class Fourteen private endpoint or an internal-only service is invisible to it. (One middle rung exists before self-hosting: GitHub's larger hosted runners can be joined to an Azure VNet, buying private reach while GitHub still owns the patching — at larger-runner prices. Name it in the design conversation before anyone builds a server.) 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

One runner rarely stays one runner. The moment two jobs want it at once the second waits, so self-hosted machines are pooled — and a runner group decides which repositories and workflows may draw from a given pool, so the finance team's runner is not quietly borrowed by an experiment in someone's fork. Scaling that pool by hand is a losing game, which is why serious self-hosted setups autoscale: ephemeral, single-use runners registered just in time, torn down after one job, spun up again on demand. GitHub's Actions Runner Controller does exactly this on Kubernetes. Read the shape of that answer twice — the mature fix for self-hosting's persistence problem is to rebuild the statelessness you gave up in §1. When the sophisticated version of a thing reinvents the default you left, the default was usually right for anyone who did not have to leave it.

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.

Play

Play it through

Three minutes, two drags and a choice. Watch a hosted runner fail to reach a private app service, then place a self-hosted runner inside the network and scope it before the deploy ever runs. It plays on its own and stops when it needs your hands.

§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@v7
      - 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

Matrix builds — one job, many jobs

The storefront has to pass its tests on the Node version running in production today and the one the platform team is about to move everyone to. Writing the test job twice is the copy-paste sin again in miniature: two blocks that must be kept in step by hand, drifting the first time someone edits only one. A matrix writes the job once and tells GitHub to fan it out — one definition, a job per combination, all running in parallel on separate machines the way Class Twenty-Two established jobs always do.

# one job definition, six actual jobs
strategy:
  fail-fast: false
  max-parallel: 4
  matrix:
    node: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]

Three Node versions times two operating systems is six jobs from nine lines, and the two knobs above are the ones that bite. fail-fast defaults to true, which cancels every sibling the instant one fails — efficient when you only need to know whether anything is broken, actively unhelpful when you need to know which combinations broke, because it hides five answers to save a minute. max-parallel caps how many run at once, which matters most on self-hosted pools with a finite number of machines — an uncapped matrix can swallow the whole fleet and starve every other pipeline. Two refinements earn their place: include adds a single extra combination without multiplying the grid (test one exotic edge, not all of it), and exclude removes a combination that makes no sense. The cost to watch is the obvious one — a matrix multiplies, so a careless third dimension turns six jobs into fifty and your minutes bill with them.

Fig. 1 · One definition fans out — the wall-clock is the slowest cell, not the sum
A matrix job fanning one definition into six parallel jobs test job strategy: matrix node × os 18 · ubuntu 20 · ubuntu 22 · ubuntu 18 · windows 20 · windows 22 · windows one file, six jobs — in parallel
Six machines run at once, so the matrix finishes when its slowest cell finishes, not when their durations add up — parallelism buys wall-clock, not free compute. Keep fail-fast: false when you need every combination's verdict; the default hides five answers to cancel early.
§6

Environments — where a human says yes

The reusable workflow in §4 already carries the word: environment: ${{ inputs.environment }}. That line is not decoration. A GitHub environment is a named target — nonprod, prod — that you can wrap in protection rules, and a job that references one stops at its gate before the first step runs. The most valuable rule is required reviewers: name one or two people, and a deploy to prod pauses, waits, and does nothing until one of them clicks approve. The Class Twenty-Three point lands here with force — the federated credential is handed to the job only after the yes, so "who can deploy to production" stops being a question about who can push code and becomes a question about who can approve, which is the separation an auditor is actually asking for.3

Two more rules pay their way. A wait timer holds the deploy for a set number of minutes — a deliberately boring pause that lets a bad release be caught before it lands. A deployment branch policy restricts which branches may deploy to the environment at all, so prod can refuse anything that is not main no matter how the workflow is triggered. And environment-scoped secrets mean the production credential physically cannot be read by a job targeting nonprod — the blast radius of a leaked secret shrinks to the one environment that holds it. This is the machinery behind the promise the "On the job" box made two screens up: the risky step waits for a human, and the waiting is configuration, not a convention someone has to remember. Azure Pipelines calls the same idea Environments with approvals and checks (Class Thirty-Nine); the vocabulary differs, the gate is identical, and AZ-400 expects you to recognise it in either dialect.

§7

Publishing what you build — packages and versions

Reuse has two halves. §4 shared pipeline logic; the other half is sharing code. When the storefront and the platform both need the same validation helpers, the wrong answer is a copied file — Class Nineteen's drift, again — and the right answer is to publish the helpers once as a versioned package that both repositories install like any other dependency. GitHub Packages is the registry sitting next to your repository: it hosts npm, NuGet, Maven, and RubyGems packages, and container images through ghcr.io. A workflow publishes to it with the same GITHUB_TOKEN you have been granting scopes to, this time with permissions: packages: write — least privilege, per Class Eight, applied to the act of publishing.

# publish job — the token needs one added scope
permissions:
  contents: read
  packages: write
steps:
  - uses: actions/checkout@v7
  - run: npm version patch          # bump the SemVer number
  - run: npm publish                # to GitHub Packages

The version number is the whole discipline, and it has rules. Semantic versioningMAJOR.MINOR.PATCH — is a promise to the people who install your package: bump the patch for a bug fix that changes nothing they rely on, the minor for a feature that adds without breaking, and the major only when you break something they will have to react to. Consumers pin a range in return — "the current 2.x, and pick up minors and patches automatically" — so a disciplined publisher can ship a fix to forty repositories without a single one editing a file, and an undisciplined one who slips a breaking change into a patch has just detonated all forty at three in the morning. This is the same interface contract §4 drew for reusable workflows, now applied to code: the number is the API, and breaking it quietly is the cardinal sin.

The Azure sibling is Azure Artifacts — feeds that host npm, NuGet, Maven, Python, and universal packages for Azure DevOps pipelines, with upstream sources that proxy the public registries so an outage upstream does not stop your build. GitHub Packages and Azure Artifacts are the same idea in two houses; SemVer is the language both speak, and the reason AZ-400 keeps a section for it. Publishing is where a great many candidates go blank in the interview — they can write a pipeline that deploys, but not one that ships a versioned thing other teams depend on, and the second is the more senior sentence to be able to say.

§8

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. Newer repositories default it to read-only; older ones still hand out write-everything — and either way, an unstated default is a decision someone else made. 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, matrices, caches, reuse, gates, packages, 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.4

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 twenty minutes to four, and the unpatched-server-inside-the-VNet future quietly fails to happen.

Fig. 2 · One workflow, two repositories — fixed in one commit
campux-platform uses: → campux-storefront uses: → reusable workflow deploy.yml · workflow_call timeout-minutes: 20 cache keyed on lockfile matrix where it helps Azure nonprod prod one workflow, both repos — one commit fixes both no self-hosted runner — a cache cut 20 min → 4
The deploy logic lives once, in a reusable workflow both repositories call with uses: — so a hung-test fix (timeout-minutes: 20) lands in both with one commit. And the "our build is slow, let's stand up a self-hosted runner" proposal dies on the §2 questions: no private-network need, no special hardware — the slowness was npm re-downloading every run, and a cache keyed on the lockfile cut the build from twenty minutes to four.
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

You placed and scoped a self-hosted runner above; the runner is only half the pipeline, so this lab builds the other half — the traffic on it. 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.
Think in systems

Zoom out: every runner and cache you add is a system to keep honest

You can reach for self-hosted runners, matrices, and caches. Now reason about what each adds to the whole system, because every one is a new surface to secure, patch, or invalidate correctly.

Feedback loops

"The build is slow, stand up a self-hosted runner" adds a server to patch and secure — which becomes the unpatched box in the VNet — when the slowness was usually npm re-downloading. What did the profile actually show?

Dependencies & coupling

Self-hosted runners couple your pipeline to machines you now own and must keep current; a managed runner keeps that dependency Microsoft’s problem.

The constraint

The pipeline’s real limit is usually the slowest step, not raw compute; caching the repeated work moves the wall further than a bigger runner does.

Second-order effects

A cache keyed too loosely serves stale artifacts, so you buy speed with the risk of building against yesterday — a bug that only appears intermittently, which is the worst kind.

What breaks when it scales

A workflow copied into every repo drifts; reusable workflows are what let one fix land everywhere at once instead of once per repository.

The engineer who ships is asked "is it fast?" The engineer who gets promoted is asked "and did you profile before you bought a server?" — and had the numbers.

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 01Judgement · matrix builds
A test job runs across three Node versions and two operating systems with strategy.matrix, left at its default. The Node 18 / Windows combination fails two minutes in. What happens to the other five combinations, still running?
Marked

B. §5's rule: fail-fast defaults to true, and one failing cell cancels every sibling in the matrix the instant it fails — efficient when the only question is whether anything is broken, actively unhelpful when the question is which combinations broke, because it hides five answers to save a minute. A treats the matrix as fully independent, which it only becomes once you set fail-fast: false; C invents an approval step that does not exist; D invents a per-OS boundary the setting does not draw — the cancellation is matrix-wide, not scoped to the failing dimension.

Set fail-fast: false the moment every cell's verdict matters — a cross-version compatibility check like this one is exactly that moment, since the reason to run six combinations is finding out which of the six actually break, not merely that one did.

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 · environment gates
A job references a GitHub prod environment. Which three are protection rules that environment can enforce before the job's first step runs?
Marked

Required reviewers, wait timer, deployment branch policy — the three named rules from §6. Required reviewers is the one that matters most: the job stops and does nothing until a named person clicks approve, and the federated credential from Class Twenty-Three is handed over only after that yes — so "who can deploy to production" becomes a question about who can approve, not who can push. The wait timer is a deliberately boring pause that gives a bad release time to be caught before it lands. The deployment branch policy means prod can refuse anything that is not main, no matter how the workflow was triggered.

The other two do not exist as environment protection rules, and believing they do is the dangerous mistake: GitHub does not watch a deployed service and roll it back for you, and an environment does not meter or cap spend — Actions billing is a separate concern entirely. If your release plan is quietly assuming either one, nothing is actually enforcing it, and you have designed a safety net with a hole in it.

Drill 04Spot the error · SemVer
This release is about to ship. One line breaks the promise semantic versioning makes to everyone who installs the package. Which?
# release notes: helpers-lib v2.4.1 -> v2.5.0
1.  Fixed a null-check bug in validateOrder() — no
    behaviour change for existing callers.
2.  Removed the deprecated validateOrderLegacy() export
    that three consuming repositories still import.
3.  Added a new optional `strict` parameter to
    validateOrder(), defaulting to false.
4.  Bumped MINOR: 2.4.1 -> 2.5.0.
Marked

B. §7's rule: patch for a fix that changes nothing callers rely on, minor for an addition that breaks nothing, major only when something breaks. Line two deletes an export that three repositories still call — every one of those callers gets a hard failure the moment they update, which is the exact definition of a breaking change, and breaking changes are a MAJOR bump, not a MINOR one. Ship this as 2.5.0 and the consumers who pinned "the current 2.x, pick up minors and patches automatically" — the whole point of SemVer, from §7 — update without a second thought and break at three in the morning.

The other lines are the discipline working correctly: a bug fix with no behaviour change is exactly what patch or minor covers (A is wrong to object to it), an optional parameter that defaults to false adds without breaking anyone, which is textbook minor (C is wrong), and 2.4.1 to 2.5.0 is a perfectly formed MAJOR.MINOR.PATCH bump — it is simply the wrong bump for what line two did (D is wrong). The fix is not the version syntax; it is either reverting the removal to a later major release, or keeping validateOrderLegacy() deprecated-but-present for one more cycle and shipping the real break as 3.0.0 where every pinned consumer notices before it happens.

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. Stop repeating yourself three ways: a matrix runs one job across many versions in parallel, a reusable workflow (workflow_call) ends copy-paste drift, and a published package with a SemVer number shares code — each an interface, versioned with tags, that breaks every caller at once when it changes.
  5. Gates and hygiene decide whether the twentieth pipeline is as safe as the first: an environment with required reviewers puts a human in front of production, and three lines per job — timeout-minutes, concurrency, least-privilege permissions — cost nothing until 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. One honest caveat on environments: the protection rules — required reviewers, wait timers, branch policies — are free on public repositories but on private ones they require a paid plan (GitHub Pro, Team, or Enterprise at the time of writing). The gate is the concept AZ-400 tests and the pattern every serious team uses; whether it costs you anything depends on your plan and your repository's visibility, so confirm before you design a release around it.
  4. 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.