Capstone: reading a real deploy.yml as a release-engineering case study
Read one real deploy workflow end to end — concurrency cancel-in-progress, a single artifact-free build-deploy job, cache-keyed state, a ~12-minute build — and judge it against the release-engineering bar: versioning, changelog, gates, and reversibility. Critique, not recall.
The pipeline you are about to read is real — it is the shape of the workflow that deploys a large static curriculum site, and it earned its current form the hard way. An earlier version sharded the build across six parallel jobs and handed ~420 MB artifacts between them on every run; that blew a free-tier artifact-storage quota, and once the quota was hit it blocked every upload — even a 163 KB file — until the platform recalculated usage six to twelve hours later, wedging every deploy. The fix was not a bigger quota; it was a structural rewrite to a single artifact-free build-deploy job that keeps state only in a build cache. That history is the point of this capstone: a release pipeline is judged not by whether it is green today but by how it fails, how it recovers, and whether you can answer “what shipped and how do I undo it” at 3 a.m. Read this workflow the way a senior reviews a colleague’s pipeline — find what it does well, what it leaves on the table, and what you would change before it carries something that moves money.
The workflow under review
Here is the shape of the pipeline, condensed to the load-bearing parts:
name: build-deploy
on:
push:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true # a newer push aborts an in-flight deploy
permissions:
contents: read
jobs:
build-deploy: # one job: plan → build → lint → deploy
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4 # state lives here, not in artifacts
with:
key: build-${{ hashFiles('content-hash') }}
path: dist/
- run: bun install --frozen-lockfile
- run: bun run build # full render ~12 min (incremental shorter)
- run: cat dist/lint-report.json # build-time linter gate
- run: ./deploy.sh dist/ # upload static output to the CDN/Pages hostWhat it does well, in this unit’s vocabulary:
concurrencywithcancel-in-progress: truekeyed on the ref means a newer push tomaincancels an older, still-running deploy of the same branch. You never get two deploys racing to publish, and you never waste a 12-minute build on a commit that has already been superseded. This is the right default for a single-target static deploy where only the latest state matters.- A single artifact-free job is the scar tissue from the Hook: plan, build, lint, and deploy run in one job and pass state through
actions/cache(a separate quota from artifacts), so there is no 420 MB hand-off to wedge. Keeping the whole pipeline in one job also means there is no inter-job artifact to expire or corrupt — the cost is that the steps cannot scale out, which for a ~12-minute build is an acceptable trade. permissions: contents: readis least privilege: the job’s token can read the repo and nothing else, so a compromised step cannot push commits or publish packages.- The build-time linter (
lint-report.json) is a release gate inside the build — content that violates the rules fails the run before anything ships.
Why is `concurrency: { group: deploy-${{ github.ref }}, cancel-in-progress: true }` the right choice for this single-target static-site deploy, and where would it be dangerous?
Judge it against the release-engineering bar
When you read any pipeline, try to separate two questions: “does this deploy correctly?” and “does this govern a release?” Most pipelines answer yes to the first and no to the second. The workflow is operationally sound, but read it against the four pillars of this unit and the gaps are clear — and instructive, because they are the difference between “deploys a static site” and “governs a release.”
- Versioning and tags: this pipeline deploys on every push to
mainwith no version, no tag, and no immutable ref. For a static site that is publishing latest-wins, that is a defensible choice — but it means there is no named release to point at, and “roll back to the last good version” has nothing to name. The mitigation that fits this design is the deploy host’s own immutable deployment history (each upload is a distinct, addressable deployment you can instantly re-promote), which substitutes for a git tag as the reversibility anchor. - Changelog and release record: there is no generated changelog and no release object, so “what changed in this deploy” is answerable only by reading the commit that triggered it. Acceptable for an internal site; a gap the moment external consumers or auditors need a per-release record.
- Environments and gates: the deploy runs straight to production on push with no environment, no required reviewer, and no wait timer. Every merged commit ships instantly with no human at release time and no soak. This is the single biggest gap against the unit: for anything beyond a low-stakes static site, you would put the deploy behind a
productionenvironment with a required reviewer and a short wait timer, and likely split build (on push) from deploy (gated). - Reversibility: there is no explicit rollback path in the workflow. Recovery depends entirely on the deploy host’s ability to re-promote a previous deployment. That works for a static CDN host, but it is implicit — a senior would make “re-deploy the previous known-good deployment” an explicit, tested, one-command path rather than a property they are assuming the platform provides.
A reviewer says this pipeline 'has no rollback story.' Given it deploys a static site to a CDN host with an immutable per-upload deployment history, what is the most accurate critique and fix?
▸Why this works
Why does a perfectly green, well-built pipeline still fall short of the unit’s bar? Because “builds and deploys correctly” and “governs a release” are different jobs. This workflow nails the operational layer — concurrency, least privilege, a lint gate, and a failure-hardened single-job structure earned from a real outage. But release engineering adds a governance layer on top: a named, immutable thing each deploy produces (a version/tag or an addressable deployment), a human-readable record of what changed, a deliberate human gate before production, and an explicit, tested way back. The static-site context makes some of these optional — latest-wins publishing genuinely does not need semver — but the moment the same pipeline carries something a user depends on or an auditor inspects, each omitted pillar becomes the gap an incident finds. Reading a real pipeline well means naming which omissions are justified by context and which are latent incidents.
- 01Summarize what this pipeline does well operationally and why each choice is sound for a single-target static deploy.
- 02Walk through the four release-engineering pillars and judge this pipeline on each, distinguishing context-justified omissions from latent incidents.
The capstone reads one real deploy workflow as a case study and judges it the way a senior judges a colleague’s pipeline. Operationally it is strong and its shape is earned: ref-keyed concurrency with cancel-in-progress cancels a superseded deploy so two never race and no long build is wasted; a single artifact-free build-deploy job passing state through actions/cache is the structural fix for a real outage where 420 MB inter-job artifacts exhausted a storage quota and wedged every upload for hours; permissions: contents: read is least privilege; and a build-time linter gates non-conforming content before it ships. Judged against this unit’s four pillars, though, the governance layer is thin. There is no version, tag, or immutable ref — acceptable for latest-wins static publishing, with the host’s immutable per-upload deployment history as the reversibility anchor. There is no generated changelog or release object, so “what changed” is only the triggering commit. The largest gap is gates: the pipeline ships straight to production on push with no environment, required reviewer, or wait timer, so every merge releases instantly with no human at release time and no soak — for anything beyond a low-stakes site you would gate the deploy behind a production environment and probably split build from deploy. And rollback is implicit, leaning on the platform’s re-promote feature rather than an explicit, tested one-command path. The lesson of reading a real pipeline is that being green today is not the bar: a release pipeline is judged by how it fails, how it reverts, and whether you can answer “what shipped and how do I undo it” under pressure — and the honest review names which omissions the context justifies and which are simply the next incident waiting. Now when you read any deploy.yml, you know which two questions to ask first — and how to tell a justified omission from a latent incident.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.