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

Phase Three · DevOps Core · Class 24 of 42 · 27 min · Assumes Class 22

After this you can cache dependencies, hand an artifact between jobs and reuse a workflow, then choose between hosted and self-hosted runners.

New to this? Read Class 22 first. Already done it, or know the material? Carry on.

§1

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

Every runs-on: ubuntu-latest job starts on a fresh virtual machine from GitHub's fleet. Git, Docker, and common language tools are already installed. GitHub maintains the machine, and the job's files do not remain on it for the next job. That reduces the risk of one job leaving credentials or processes behind. It does not make shared Actions caches safe to trust: a later job can restore cached files, so never put secrets in a cache and treat restored content as untrusted.

A standard hosted runner cannot directly call a private endpoint in your VNet: it has no route there. If your plan supports it, a larger GitHub-hosted runner connected to an Azure VNet can provide that route while GitHub still maintains the runner. Standard runners also offer a limited set of machine sizes. Every new job must obtain its dependencies again unless a cache helps, and usage may incur charges according to the repository, runner, and plan. Compare the actual network, hardware, security, and cost requirements before choosing a runner.1

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.
Extension · §2

Self-hosted — the bill you take on

A self-hosted runner is a machine you operate with the GitHub runner agent installed. If that machine has a route to a private endpoint, a job on it can call that endpoint. You choose its hardware and installed tools, but you pay for the machine, maintenance, and idle capacity. Compare those costs with a larger VNet-connected hosted runner before assuming self-hosting is cheaper.

Now read the bill. That machine is yours: you patch it, monitor it, scale it, and rebuild it. A persistent runner can carry files, processes, or credentials from one job into the next. Avoid attaching one to a public repository: untrusted pull-request code could run on a machine with access to your network. Private repositories also need review of who can submit code or trigger workflows; "private" alone does not make every job trustworthy.2

Table 1 — Hosted against self-hosted
GitHub-hostedSelf-hosted
Machine stateFresh VM per job; external caches and artifacts can survivePersistent unless you build single-use runners
Network reachStandard: public route; eligible larger runners can connect to an Azure VNetWherever your machine has a route
HardwareFixed menu of sizesAnything you can rack or rent
UpkeepGitHub patches, scales, and pays the idle timeYou do — patching, scaling, monitoring, rebuilding
Public reposFresh job VM; still protect tokens and workflow permissionsAvoid: untrusted code can reach 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.

Start with the standard hosted runner. If a job needs private network access or a different machine size, evaluate an eligible larger hosted runner before operating your own. If self-hosting is necessary, use repository and workflow restrictions, isolation, patching, and preferably single-use machines. Measure actual run time and cost before adding infrastructure just to make a slow build faster.

Play

Play it through

Three minutes, two drags and a choice. A post-deployment health check must call the app's private endpoint. In this exercise, a VNet-connected larger hosted runner is unavailable, so you place and scope a self-hosted runner. The lesson also explains the hosted option to compare in a real project.

§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: a named file available 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

hashFiles('package-lock.json') changes the cache key when that file changes. A static key can restore outdated dependencies. Use a cache only for files you can recreate, never for secrets; restored content is untrusted. Upload a report as an artifact when another job or person needs that run's output. An artifact is a convenient delivery record, not proof that the file is accurate or tamper-proof. Check its producing workflow and retention settings before treating it as audit evidence. Cache helps speed; artifact carries a result.

§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
    permissions:
      contents: read
      id-token: write
    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 }}' }
      - env:
          TARGET_ENV: ${{ inputs.environment }}
        run: az deployment group create --resource-group "rg-campux-$TARGET_ENV" --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@<reviewed-commit-sha>
    with: { environment: nonprod }

This is a pattern, not a ready-to-run deployment: replace the commit placeholder with a reviewed SHA, provide the Bicep file and Azure variables, and configure the caller repository's OIDC trust and access to the reusable workflow. actions/checkout in the called workflow checks out the caller repository, so that repository must contain infra/main.bicep. Use a reviewed environment name and configure its approval rules before relying on a gate.

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. Use a composite action for steps inside a job, and a reusable workflow when you need to share jobs, runner selection, or environment gates. For a cross-repository caller, a reviewed commit SHA gives the most stable reference; update it deliberately when the shared workflow changes. A tag can communicate a release, but a mutable tag can change underneath callers.

Extension · §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 creates copies that can drift. A matrix writes the job once and expands it into one job per combination. They can run concurrently when runners are available, subject to max-parallel.

# 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
The matrix creates six jobs, but max-parallel: 4 allows at most four to run at once; capacity can lower the actual concurrency. Parallelism can reduce wall-clock time but still consumes runner time. Use fail-fast: false when you need every combination's verdict.
Extension · §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.

Extension · §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.

# illustrative npm publish steps; configure the package registry and authentication first
permissions:
  contents: read
  packages: write
steps:
  - uses: actions/checkout@v7
  - run: npm ci
  - run: npm publish                # after a reviewed version bump

For npm, set the package's publishConfig.registry to https://npm.pkg.github.com and configure a scoped package name plus authenticated npm registry access in the workflow. The steps above omit that setup, so copying them alone will not publish to GitHub Packages. Bump and review the version before the release job; a publish job should not silently create a new version in its checkout.

The version number is the whole discipline, and it has rules. Semantic versioning — MAJOR.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.

In this fictional case, an integration test hangs long enough to consume hours of runner time. The team adds timeout-minutes: 20 to the shared workflow, tests a new version, and updates both repositories' pinned references. They also inspect the storefront run's step timings before buying a self-hosted runner. If dependency downloads dominate, a cache keyed on the lockfile is a cheaper experiment. Measure runs before and after; no speedup is guaranteed.

Fig. 2 · One workflow, two repositories — update both pinned callers
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 publish a version; update both callers measure cache benefit before adding a runner
The deploy logic lives once in a reusable workflow. With commit-pinned callers, a fix reaches each repository only after its reference is updated. If downloads dominate the run time, test a lockfile-keyed cache and compare measured step times before adding runner infrastructure.
Watch · Microsoft Learn

The official pages, and a CAMPUX overview

The runners page first; the reuse page when Lab 2 asks for it
Docs · GitHub

About self-hosted runners
docs.github.com/actions/concepts/runners/self-hosted-runners

Azure VNet-connected GitHub-hosted runners
GitHub's private networking guide

Dependency cache security
GitHub's cache guide

Reusing workflows
docs.github.com/actions/how-tos/reuse-automations/reuse-workflows

The two labs below let you inspect job boundaries, compare a cache miss with a hit, and run the same reusable workflow with two inputs. Use your run log to measure timing.

Lab 1 · Cache & artifact

Feel the difference between speed and evidence

~15 minutes · a repository you own · browser or terminal

The game simulated runner placement; this lab uses GitHub-hosted runners and does not provision a VM. Build a two-job workflow where an artifact carries a file across the job boundary, then add a cache and compare runs.

  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. On a new branch, add a job that installs a dependency, without a cache, and merge it:

      slow:
        runs-on: ubuntu-latest
        steps:
          - run: pip install --target ./deps "pandas>=2,<3"
          - 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. Replace the install step with this cache and conditional install. Merge, then run the workflow twice from the default branch:

          - id: pip-cache
            uses: actions/cache@v4
            with:
              path: ./deps
              key: deps-${{ runner.os }}-pandas-demo-v1
          - if: steps.pip-cache.outputs.cache-hit != 'true'
            run: pip install --target ./deps "pandas>=2,<3"
          - run: du -sh ./deps
    The lesson: with a new key, the first run installs and saves the directory; the next run restores it and skips the install. Compare the step times; the total run may still vary. This deliberately static demo key will not notice a new pandas release. In production, pin dependencies in a lockfile and include its hash in the key. Delete the demo workflow when finished.
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:
          - env:
              TARGET_ENV: ${{ inputs.environment }}
            run: printf 'Validating for %s\n' "$TARGET_ENV"
    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. On a test branch, rename the reusable workflow's input from environment to env without changing the caller. Compare the two files in a draft pull request; do not merge the experiment.

    The lesson: the caller still sends environment, which the updated reusable workflow no longer accepts. The workflow_dispatch example has no pull-request trigger, so opening the PR alone will not run it. Revert the experiment before merging. For workflows shared across repositories, pin a reviewed commit and update callers deliberately.
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 post-deployment health check must call a private app endpoint. A standard hosted runner has no route. In this exercise a VNet-connected larger hosted runner is unavailable, so you scope and maintain a self-hosted runner with that route. For a real deployment, compare both network-capable options and configure any environment approval gate explicitly.

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. Upload the report as an artifact so the approver can download the file produced by this run. Cache recreatable dependencies to save installation time. A cache can be evicted and is not a reliable way to deliver an approval report. An artifact is also not proof of correctness: the approver must review the report and know which workflow produced it, and retention must cover the review window.

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 fixes in measured cost order. If dependency downloads dominate, test a cache keyed on the lockfile and compare step times. If independent checks run in sequence, parallel jobs can reduce wall-clock time when runner capacity is available. If superseded commits still run, use concurrency with cancel-in-progress: true for PR validation. Keep required checks enforced while improving them. Consider different runner capacity only after the measurements identify a bottleneck it would solve.

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. For a private network requirement, compare an eligible larger VNet-connected hosted runner with self-hosting. If you self-host, restrict who can trigger jobs and budget for patching, isolation, and scaling; avoid public repositories.
  3. Cache recreatable files for speed; use an artifact to move a run's output between jobs or to a reviewer. Verify the producing workflow and retention before relying on a report.
  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. ↩