Project: ship a small app end to end
Every earlier course on the Developer track taught one tool. This lesson uses all of them on one small project, in the order a real team would: an issue, a branch, commits, a pull request with a review, CI, a tagged release, a deployment, and a hotfix the next day. Follow along and you will have done, once, everything a product team's engineers do every week.
By the end you can
- Set up a TypeScript project with lint, type check and tests that run the same on every laptop and in CI
- Take a feature from issue to merged PR, including a review round, using
gitandgh - Write a CI workflow and a release workflow that deploys a page to GitHub Pages and an API image to the GitHub Container Registry
- Cut a release with a tag, a changelog and release notes
- Ship a hotfix the safe way: failing test first, small PR, patch version
The everyday picture
Think of opening a small new counter inside an existing Mangrove store, for example a tea and snacks corner. Somebody writes down what it should sell (the issue). A carpenter builds it in the stockroom, away from customers (the branch). The store manager inspects it before it goes on the floor (the review). A checklist is run every time: power, scale calibrated, price labels match the system (CI). On opening day the counter gets a number and a date in the store log (the tag and the release). And when a customer reports on day two that the scale reads 50 grams heavy, nobody rebuilds the counter. A technician fixes that one thing, the same checklist is run again, and the log gets a new line (the hotfix).
Nothing in that story is clever. It works because every change takes the same path, with the same checks, and leaves a record. That is what this lesson builds.
What we are building, and why it is this small
The app is a store stock checker. A staff member enters a store code (S014) and an article code (A1001, the product) and sees the name, quantity and price. It has two parts:
- An API in Node and TypeScript:
GET /api/stock?siteId=S014&articleCode=A1001returns JSON, andGET /healthreports the running version. Data comes from a small JSON file standing in for the database. Prices are integers in cents (Working with data). - A page in plain HTML and JavaScript that calls the API with
fetchand shows the answer as text (JavaScript in the browser, Security and quality).
It is small on purpose. The code is about 120 lines, so you can see the process: the same path a 50,000-line app takes. To keep the toolchain light, the API uses Node's built-in HTTP server and test runner, and Node runs the .ts files directly with type stripping. tsc still checks the types (TypeScript for a JavaScript team). In a real team project you would likely use NestJS (From script to service) and a framework for the page; nothing in the process changes.
One note before you start. Every command and output below was run for real, on Node 22 and Node 24, against a local stand-in for GitHub. The gh commands and anything that happens on GitHub's servers (Actions runs, Pages, the container registry) were not run for this lesson, so those steps are marked "on GitHub" and describe what you should expect to see.
Quick check
Node runs the .ts files directly by stripping the types. What still makes sure the types are correct?
Type stripping deletes the annotations without checking them, so a wrong type would run happily. The type check is a separate step, which is why it is one of the three CI checks and part of npm run check.
The path every change takes
Look at the diagram as a set of gates. Each gate catches a different kind of mistake, and it catches it cheaply, before the next gate:
- The issue catches unclear goals. If you cannot write the acceptance criteria in three lines, the work is not ready (Planning on GitHub).
- Small commits on a branch keep
mainalways releasable, and make the review readable (Everyday Git, Branches and merging). - CI on the pull request catches lint errors, type errors and broken tests on two Node versions, without a person spending time on them (GitHub Actions: continuous integration).
- Review catches what machines cannot: a missing error state, a confusing name, a risky shortcut (Pull requests and code review).
- The tag is the only thing that deploys. Merging to
maindoes not ship to users; taggingv1.0.0does. That makes "what is live right now?" a question with a one-word answer (Branching strategies, releases and versions). - The release workflow runs the checks again and then deploys, with tight permissions (Deploying and securing with GitHub Actions).
The hotfix at the bottom of the diagram is the test of the whole system. Under pressure, people want to skip gates: "just push the fix to the server". A team with a fast, trusted path does not need to, because the full path for a one-line fix takes minutes, not hours.
Quick check
In this project, a PR has just been squash-merged into main. What are users running now?
Here a merge only runs CI. The release workflow is triggered by a version tag and nothing else, so "what is live?" is always the latest tag. Teams that deploy on every merge can work well too, but they pick that rule on purpose and write it down.
Quick check
Which gate is best placed to catch a page that shows "undefined" when the network is down?
CI only catches what a test checks, and no test here turns off the network. A reviewer who runs the page and tries a missing item or no Wi-Fi finds it in minutes. Once found, a test can keep it fixed.
Build it
Every command and file, in order. Use Git Bash, macOS Terminal or a Linux shell; Node 22.18 or newer (Node 24 recommended). Replace YOUR-USER with your GitHub username. Choosing the next version number, which you do in steps 11 and 13, is also a Practice exercise you can run in your browser.
-
Create the repo and the issue (on GitHub)
gh repo createmakes the GitHub repository and clones it. The issue states the goal and how we will know it is done (GitHub: remotes, Planning on GitHub). GitHub Pages needs a public repository on a free plan; check your plan if you want a private one.Terminalgh repo create store-stock-checker --public --clone --description "Look up an article's stock at a store" cd store-stock-checker gh label create api --color 1d76db --description "The stock API" gh issue create --title "Look up an article's stock at a store" --label enhancement --label api \ --body "Staff enter a store code (S014) and an article code (A1001) and see name, quantity and price. Acceptance: a GET endpoint returns JSON; unknown pairs return 404; bad codes return 400; a page shows the result or a clear error."Expected: GitHub prints the new issue's link, ending in
/issues/1.enhancementis one of GitHub's default labels;apiis the one we just created. -
Scaffold the project
One
package.jsonwith every script the team needs, so "how do I run the checks?" always has the same answer:npm run check. Pin the Node version in.nvmrc(Node.js and npm).Terminalnpm init -y npm i -D typescript@^5.9.3 typescript-eslint eslint @eslint/js globals @types/node@24 echo 24 > .nvmrc printf 'node_modules/\n.env\n_site/\ncoverage/\n' > .gitignoreOutputadded 99 packages, and audited 100 packages in 9s 31 packages are looking for funding run `npm fund` for details found 0 vulnerabilitiesThen edit
package.jsonso it looks like this."type": "module"makes every.jsand.tsfile an ES module (Prototypes, classes and modules). We pin TypeScript 5.9, the line this course uses. Plainnpm i -D typescriptwould pick the newest major (7 at the time of writing), and the current typescript-eslint release accepts only TypeScript below 6.1, so check that pair whenever you upgrade either one.JSON{ "name": "store-stock-checker", "version": "0.0.0", "private": true, "description": "Look up one article's stock at one store: a small API and a page", "type": "module", "engines": { "node": ">=22.18" }, "scripts": { "start": "node src/main.ts", "dev": "node --watch src/main.ts", "test": "node --test", "lint": "eslint .", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck && npm test" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.19.1", "eslint": "^10.12.0", "globals": "^17.13.0", "typescript": "^5.9.3", "typescript-eslint": "^8.71.1" } }JSON{ "compilerOptions": { "target": "esnext", "module": "nodenext", "noEmit": true, "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "strict": true, "resolveJsonModule": true, "types": ["node"], "skipLibCheck": true }, "include": ["src", "test"] }JavaScript// eslint.config.js: recommended rules for JS and TS, plus browser globals for the page import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import globals from 'globals'; import { defineConfig } from 'eslint/config'; export default defineConfig( { ignores: ['_site/'] }, { files: ['**/*.{js,ts}'], extends: [js.configs.recommended, tseslint.configs.recommended] }, { files: ['web/**/*.js'], languageOptions: { globals: globals.browser } }, );The tsconfig options are the ones the Node docs recommend for type stripping:
noEmit(Node runs the source, sotsconly checks),erasableSyntaxOnly(no TypeScript features that Node cannot simply delete, such asenum), andverbatimModuleSyntax(type-only imports must sayimport type). Add a short README and a one-line placeholdersrc/main.tsso the type check has a file to read, then run the checks once and make the first commit directly onmain. It is the only commit that will ever go there without a PR. (In this lesson we run the three checks separately and pass--test-reporter=tap, which prints plainokandnot oklines;npm run checkruns the same three with the default reporter's tick marks.) With no tests yet, the runner finds zero and passes.Terminalnpm run lint; npm run typecheck; npm test -- --test-reporter=tapOutput> store-stock-checker@0.0.0 lint > eslint . > store-stock-checker@0.0.0 typecheck > tsc --noEmit > store-stock-checker@0.0.0 test > node --test --test-reporter=tap 1..0 # tests 0 # pass 0 # fail 0Terminalgit add -A git commit -m "chore: scaffold project with TypeScript, ESLint and README" git push -u origin main -
Add CI in its own small PR
CI comes first, so every later PR is checked. The workflow runs lint, the type check and the tests on Node 22 and 24 (GitHub Actions: continuous integration).
permissions: contents: readgives the job's token the least it needs (Deploying and securing).YAML# .github/workflows/ci.yml: lint, type check and test every PR and every push to main name: CI on: pull_request: push: branches: [main] permissions: contents: read # least privilege: CI only reads the code concurrency: group: ci-${{ github.ref }} cancel-in-progress: true # a new push cancels the old run for the same branch jobs: check: runs-on: ubuntu-latest strategy: matrix: node: [22, 24] # the two LTS lines we support steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} cache: npm - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm testCheck the file with actionlint (also from the CI lesson) before pushing; a typo in YAML otherwise shows up only as a failed run on GitHub.
Terminalgit switch -c ci/add-workflow actionlint -verbose git add .github && git commit -m "ci: lint, type check and test on Node 22 and 24" git push -u origin ci/add-workflow gh pr create --fill --base mainOutputverbose: Found total 0 errors in 24 ms for .github/workflows/ci.yml verbose: Found total 0 errors in 50 ms for .github/workflows/release.yml verbose: Found 0 errors in 2 filesOn GitHub: PR #2 opens and two checks appear,
check (22)andcheck (24), one per matrix entry. When both are green, merge withgh pr merge 2 --squash --delete-branch. -
Protect main
Now that CI exists, make it required. This ruleset (see Branching strategies, releases and versions) blocks direct pushes and force pushes to the default branch, requires one approval and resolved conversations, allows only squash merges, and requires both CI checks to pass on the latest code. We validated this JSON against GitHub's published REST API schema.
JSON{ "name": "protect main", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": true, "allowed_merge_methods": ["squash"] } }, { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ { "context": "check (22)" }, { "context": "check (24)" } ] } } ] }Terminalgh api repos/{owner}/{repo}/rulesets --method POST --input ruleset.jsonOn GitHub: the response is the new ruleset as JSON with an
id, and it appears under the repository's Settings, Rules, Rulesets. On a solo practice repo you cannot approve your own PR, so either add a second account as a collaborator or setrequired_approving_review_countto 0 while practising. -
Branch for the issue and build the stock lookup
The branch name carries the issue number. The business rule lives in its own module with no HTTP in it, so it is easy to test. A
Mapgives one lookup per request (Performance and memory), and inputs are checked before use (Security and quality).Terminalgit switch -c feat/1-stock-apiTypeScript// src/stock.ts: the business rule, with no HTTP in it, so it is easy to test import rows from './data/stock.json' with { type: 'json' }; export interface StockRow { siteId: string; // store, for example S014 articleCode: string; // article (product), for example A1001 name: string; qty: number; priceCents: number; // integer cents, never floating-point dollars } export type Lookup = | { ok: true; row: StockRow } | { ok: false; status: 400 | 404; error: string }; const bySiteAndArticle = new Map<string, StockRow>( rows.map((r: StockRow) => [`${r.siteId}:${r.articleCode}`, r]), ); const SITE_ID = /^S\d{3}$/; const ARTICLE_CODE = /^A\d{4,8}$/; export function lookup(siteId: string | null, articleCode: string | null): Lookup { if (!siteId || !SITE_ID.test(siteId)) { return { ok: false, status: 400, error: 'siteId must look like S014' }; } if (!articleCode || !ARTICLE_CODE.test(articleCode)) { return { ok: false, status: 400, error: 'articleCode must look like A1001' }; } const row = bySiteAndArticle.get(`${siteId}:${articleCode}`); return row ? { ok: true, row } : { ok: false, status: 404, error: 'no stock record' }; }TypeScriptimport { test } from 'node:test'; import assert from 'node:assert/strict'; import { lookup } from '../src/stock.ts'; test('finds stock for a known store and article', () => { const r = lookup('S014', 'A1001'); assert.ok(r.ok); assert.equal(r.row.qty, 42); assert.equal(r.row.priceCents, 850); }); test('returns 404 when the store does not stock the article', () => { assert.deepEqual(lookup('S020', 'A2040'), { ok: false, status: 404, error: 'no stock record' }); }); test('rejects badly formed input with 400', () => { const r = lookup('Downtown', 'A1001'); assert.equal(r.ok, false); assert.equal(!r.ok && r.status, 400); });src/data/stock.jsonholds five rows such as{"siteId": "S014", "articleCode": "A1001", "name": "Basmati rice 5 kg", "qty": 42, "priceCents": 850}. Commit with a Conventional Commit message:Terminalnpm test git add -A && git commit -m "feat(api): look up stock by siteId and articleCode" -
Serve it over HTTP, with a test that uses a real port
The server knows about requests and responses, not stock rules. Configuration comes from environment variables, so the same code runs on a laptop and in the container. The test starts the server on port 0, which means "any free port", so tests never clash.
TypeScript// src/server.ts: HTTP routes. Knows about requests and responses, not about stock rules. import http from 'node:http'; import { lookup } from './stock.ts'; export interface ServerOptions { allowedOrigin: string; // the page's origin, for CORS version: string; // shown by /health so we can check what is deployed } export function createServer({ allowedOrigin, version }: ServerOptions): http.Server { return http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); res.setHeader('access-control-allow-origin', allowedOrigin); if (req.method === 'GET' && url.pathname === '/health') { return send(res, 200, { ok: true, version }); } if (req.method === 'GET' && url.pathname === '/api/stock') { const result = lookup(url.searchParams.get('siteId'), url.searchParams.get('articleCode')); return result.ok ? send(res, 200, result.row) : send(res, result.status, { error: result.error }); } send(res, 404, { error: 'not found' }); }); } function send(res: http.ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }TypeScript// src/main.ts: read config from the environment and start listening import { createServer } from './server.ts'; const port = Number(process.env.PORT ?? 3000); const allowedOrigin = process.env.ALLOWED_ORIGIN ?? 'http://localhost:8080'; const version = process.env.APP_VERSION ?? 'dev'; createServer({ allowedOrigin, version }).listen(port, () => { console.log(`stock API ${version} on port ${port}, allowing ${allowedOrigin}`); });TypeScriptimport { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import type { AddressInfo } from 'node:net'; import { createServer } from '../src/server.ts'; const server = createServer({ allowedOrigin: 'https://example.github.io', version: 'test' }); let base = ''; before(() => new Promise<void>((done) => server.listen(0, () => { // port 0: any free port base = `http://localhost:${(server.address() as AddressInfo).port}`; done(); }))); after(() => new Promise<void>((done) => server.close(() => done()))); test('GET /api/stock returns the row as JSON with a CORS header', async () => { const res = await fetch(`${base}/api/stock?siteId=S014&articleCode=A1001`); assert.equal(res.status, 200); assert.equal(res.headers.get('access-control-allow-origin'), 'https://example.github.io'); assert.equal((await res.json()).name, 'Basmati rice 5 kg'); }); test('GET /health reports the version', async () => { const res = await fetch(`${base}/health`); assert.deepEqual(await res.json(), { ok: true, version: 'test' }); });Terminalnpm start # in a second terminal: curl -s 'localhost:3000/api/stock?siteId=S014&articleCode=A1001' -w ' (HTTP %{http_code})\n' curl -s 'localhost:3000/api/stock?siteId=S020&articleCode=A2040' -w ' (HTTP %{http_code})\n' curl -s 'localhost:3000/api/stock?siteId=Downtown&articleCode=A1001' -w ' (HTTP %{http_code})\n' curl -s 'localhost:3000/health' -w ' (HTTP %{http_code})\n'Outputstock API dev on port 3000, allowing http://localhost:8080 {"siteId":"S014","articleCode":"A1001","name":"Basmati rice 5 kg","qty":42,"priceCents":850} (HTTP 200) {"error":"no stock record"} (HTTP 404) {"error":"siteId must look like S014"} (HTTP 400) {"ok":true,"version":"dev"} (HTTP 200)Terminalgit add -A && git commit -m "feat(api): serve GET /api/stock and /health over HTTP" -
Add the page and open the PR
The page is plain HTML with a form.
web/config.jsholds the API address; the release workflow overwrites it for production. The first version ofapp.jsis the "happy path" only, which is what a reviewer will catch.JavaScript// web/app.js: read the form, call the API, show the answer as text (never innerHTML) import { API_BASE } from './config.js'; const form = document.querySelector('#lookup'); const result = document.querySelector('#result'); const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); form.addEventListener('submit', async (event) => { event.preventDefault(); const params = new URLSearchParams(new FormData(form)); const res = await fetch(`${API_BASE}/api/stock?${params}`); const body = await res.json(); result.textContent = `${body.name}: ${body.qty} in stock at ${body.siteId}, ${money.format(body.priceCents / 100)}`; });JavaScript// web/config.js: where the API lives. CI overwrites this file for production. export const API_BASE = 'http://localhost:3000';Serve
web/with any static server, for examplenpx serve web -l 8080, and try it. Then commit, push and open the PR.Closes #1in the body links the PR to the issue and closes it on merge.Terminalgit add -A && git commit -m "feat(web): stock lookup page" git push -u origin feat/1-stock-api gh pr create --base main --title "feat: stock lookup API and page" \ --body "Closes #1 What: GET /api/stock and /health, plus a page that calls it. How to test: npm start, npx serve web -l 8080, try S014 / A1001. Risk: low; new code only."On GitHub: PR #3 opens, linked to issue #1, and CI runs both matrix jobs. Locally, the same checks pass:
Terminalnpm run lint; npm run typecheck; npm test -- --test-reporter=tapOutput> store-stock-checker@0.0.0 lint > eslint . > store-stock-checker@0.0.0 typecheck > tsc --noEmit > store-stock-checker@0.0.0 test > node --test --test-reporter=tap ok 1 - GET /api/stock returns the row as JSON with a CORS header ok 2 - GET /health reports the version ok 3 - finds stock for a known store and article ok 4 - returns 404 when the store does not stock the article ok 5 - rejects badly formed input with 400 1..5 # tests 5 # pass 5 # fail 0 -
Respond to review
The reviewer runs the page, turns off Wi-Fi, and looks up a pair that does not exist. For the 404 the page shows
undefined: undefined in stock at undefined, $NaN, and when the network is down or very slow it shows nothing at all. Store Wi-Fi is often flaky, so they request changes (on GitHub):Terminalgh pr review 3 --request-changes --body "Please handle errors: a 404 shows 'undefined', and with no network the page hangs. Add a timeout."You fix it on the same branch. The diff shows exactly what changed, which makes the second review fast:
Diff@@ -8,7 +8,16 @@ const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD form.addEventListener('submit', async (event) => { event.preventDefault(); const params = new URLSearchParams(new FormData(form)); - const res = await fetch(`${API_BASE}/api/stock?${params}`); - const body = await res.json(); - result.textContent = `${body.name}: ${body.qty} in stock at ${body.siteId}, ${money.format(body.priceCents / 100)}`; + result.className = ''; + result.textContent = 'Checking...'; + try { + const res = await fetch(`${API_BASE}/api/stock?${params}`, { signal: AbortSignal.timeout(5000) }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error); + result.className = body.qty === 0 ? 'out' : ''; + result.textContent = `${body.name}: ${body.qty} in stock at ${body.siteId}, ${money.format(body.priceCents / 100)}`; + } catch (err) { + result.className = 'out'; + result.textContent = `Could not check stock: ${err.message}`; // network down, timeout, 404 + } });Terminalgit commit -am "fix(web): show errors and time out after 5 seconds" git push git log --oneline main..HEADOutput73ec384 fix(web): show errors and time out after 5 seconds 7b314c3 feat(web): stock lookup page 8c3bbcb feat(api): serve GET /api/stock and /health over HTTP d1bc459 feat(api): look up stock by siteId and articleCodeWe ran the page's code against the running API (in Node, with the two page elements stubbed), looking up three pairs:
OutputS014 A1001 -> Basmati rice 5 kg: 42 in stock at S014, $8.50 S014 A1002 -> Sunflower oil 2 L: 0 in stock at S014, $7.40 S020 A2040 -> Could not check stock: no stock recordOn GitHub: the reviewer approves (
gh pr review 3 --approve), CI is green on the new commit, and you squash-merge. The four branch commits become one commit onmain, and issue #1 closes.Terminalgh pr merge 3 --squash --delete-branch git switch main && git pull git log --onelineOutputc8c16bd (HEAD -> main, origin/main) feat: stock lookup API and page (#3) 2b5e9bd ci: lint, type check and test on Node 22 and 24 (#2) c81b7e9 chore: scaffold project with TypeScript, ESLint and README -
Add the release workflow and the Dockerfile
One workflow, triggered only by a version tag, with three jobs.
checkruns the same checks again. If they pass,pageandimagerun in parallel. Each job gets only the permissions it needs. The two official GitHub Pages actions are used at their current major versions; the three third-party Docker actions are pinned to full commit SHAs with the version in a comment, as Deploying and securing with GitHub Actions recommends.YAML# .github/workflows/release.yml: on a version tag, ship the page and the API image name: Release on: push: tags: ['v*.*.*'] permissions: contents: read jobs: check: # never ship a tag that fails the checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: { node-version: 24, cache: npm } - run: npm ci - run: npm run check page: needs: check runs-on: ubuntu-latest permissions: contents: read # job-level permissions replace the defaults pages: write # deploy to Pages id-token: write # prove the deploy comes from this workflow environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v7 - name: Point the page at the production API env: API_BASE_URL: ${{ vars.API_BASE_URL }} # a repository variable, set once run: | mkdir _site && cp web/* _site/ echo "export const API_BASE = '$API_BASE_URL';" > _site/config.js - uses: actions/upload-pages-artifact@v5 with: { path: _site } - id: deploy uses: actions/deploy-pages@v5YAMLimage: needs: check runs-on: ubuntu-latest permissions: contents: read packages: write # push to GitHub Container Registry steps: - uses: actions/checkout@v7 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} tags: type=semver,pattern={{version}} - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: APP_VERSION=${{ github.ref_name }}Dockerfile# Dockerfile: the API as a small container. Node 24 runs the .ts files directly. FROM node:24-alpine WORKDIR /app ENV NODE_ENV=production # Install production dependencies only, without running install scripts (safer) COPY package.json package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts COPY src ./src # The release workflow passes the tag, so /health can report it ARG APP_VERSION=dev ENV APP_VERSION=$APP_VERSION # Do not run as root USER node EXPOSE 3000 CMD ["node", "src/main.ts"]The
API_BASE_URLvalue goes through anenv:entry instead of being pasted into the script. That is a habit worth keeping: values from${{ }}expressions placed directly in arun:script can inject shell code if they ever contain user input. The.dockerignorefile keepsnode_modules, tests, the page and.gitout of the image.We could not run Docker from here, so we checked the image's contents another way: copy exactly what the Dockerfile copies into an empty folder, run the same install, and start it the same way on Node 24.
TerminalNODE_ENV=production npm ci --omit=dev --ignore-scripts NODE_ENV=production APP_VERSION=v1.0.0 PORT=3100 node src/main.ts curl -s localhost:3100/healthOutputfound 0 vulnerabilities stock API v1.0.0 on port 3100, allowing http://localhost:8080 {"ok":true,"version":"v1.0.0"} -
Configure Pages and the production API address (on GitHub)
Tell GitHub that Pages is published by a workflow, set the API address as a repository variable, and allow tags to deploy to the
github-pagesenvironment. By default that environment accepts only the default branch, so a tag-triggered deploy fails with "is not allowed to deploy to github-pages due to environment protection rules" until you add a tag rule.Terminalgh api repos/{owner}/{repo}/pages --method POST -f build_type=workflow gh variable set API_BASE_URL --body "https://stock-api.example.com" gh api repos/{owner}/{repo}/environments/github-pages --method PUT --input - <<'EOF' {"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}} EOF gh api repos/{owner}/{repo}/environments/github-pages/deployment-branch-policies --method POST -f name=main -f type=branch gh api repos/{owner}/{repo}/environments/github-pages/deployment-branch-policies --method POST -f 'name=v*.*.*' -f type=tagIf the environment already has a rule for
main, GitHub refuses the duplicate; you can ignore that error.In the web interface, the same settings live under Settings: Pages (Source: GitHub Actions), Secrets and variables, Actions (Variables tab), and Environments, github-pages (Deployment branches and tags). The API address must be a real HTTPS host that runs the image; see the next step.
-
Cut v1.0.0
Write the changelog entry and bump the version in the same PR as the release workflow (#4), merge it like any other change, then tag
main. An annotated tag (-a) records who tagged it, when, and a message (Branching strategies, releases and versions).Markdown# Changelog All notable changes to this project. Versions follow semantic versioning. ## [1.0.0] - 2026-10-07 ### Added - `GET /api/stock?siteId=&articleCode=` returns name, quantity and price in cents (#3) - `GET /health` reports the deployed version - Stock lookup page with error states and a 5 second timeout (#3) - CI on Node 22 and 24; release workflow for GitHub Pages and GHCR (#2, #4)Terminalnpm version 1.0.0 --no-git-tag-version # updates package.json and the lockfile only # ...commit, PR #4, review, squash-merge, then on main: git switch main && git pull git tag -a v1.0.0 -m "v1.0.0: first release of store-stock-checker" git push origin v1.0.0 gh release create v1.0.0 --verify-tag --generate-notesOn GitHub: the tag push starts the Release workflow. Watch it with
gh run watch. Expectcheck, thenpageandimageside by side, in a few minutes in total. The run summary shows the site address, which for a project repo ishttps://YOUR-USER.github.io/store-stock-checker/. Theimagejob pushesghcr.io/your-user/store-stock-checker:1.0.0and:latest(image names are lowercased).gh release create --generate-notesbuilds release notes from the merged PRs since the last tag. -
Run the API image on a server
The page is static, so GitHub Pages can host it. The API needs a machine. On any Linux server with Docker, or a container service such as Cloud Run, Azure Container Apps or a VPS, run the image. A new package on GHCR is private by default, so log in with a token that has the
read:packagesscope, or make the package public in its settings.Terminalecho "$CR_PAT" | docker login ghcr.io -u YOUR-USER --password-stdin docker run -d --name stock-api --restart unless-stopped -p 3000:3000 \ -e ALLOWED_ORIGIN=https://YOUR-USER.github.io \ ghcr.io/your-user/store-stock-checker:1.0.0 curl -s localhost:3000/healthExpected:
{"ok":true,"version":"v1.0.0"}. Put HTTPS in front (a reverse proxy such as Caddy or nginx, or the platform's own), because a page served over HTTPS cannot call anhttp://API. Automating this step from the workflow over SSH or OIDC is covered in Deploying and securing with GitHub Actions; doing it by hand once first is how you learn what to automate. -
The next morning: a bug report, a failing test, a hotfix
Issue #5 arrives: "New article A100000123 (brown bread) returns an error." The cause is the article code pattern, which allows 4 to 8 digits. The rule is test first: write a test that reproduces the report and watch it fail. That proves you found the real cause, and it stops the bug coming back.
Terminalgit switch main && git pull git switch -c fix/5-long-article-codesTypeScripttest('accepts the new 9-digit article codes (issue #5)', () => { const r = lookup('S014', 'A100000123'); assert.ok(r.ok, 'expected a stock row, got an error'); assert.equal(r.row.name, 'Brown bread 350 g'); });Terminalnpm test -- --test-reporter=tapOutputok 1 - GET /api/stock returns the row as JSON with a CORS header ok 2 - GET /health reports the version ok 3 - finds stock for a known store and article ok 4 - returns 404 when the store does not stock the article ok 5 - rejects badly formed input with 400 not ok 6 - accepts the new 9-digit article codes (issue \#5) --- location: '.../store-stock-checker/test/stock.test.ts:22:1' failureType: 'testCodeFailure' error: 'expected a stock row, got an error' code: 'ERR_ASSERTION' name: 'AssertionError' expected: true actual: false operator: '==' ... 1..6 # tests 6 # pass 5 # fail 1The fix is one character, plus the same change in the page's
patternattribute:Diff@@ -18,7 +18,7 @@ const bySiteAndArticle = new Map<string, StockRow>( ); const SITE_ID = /^S\d{3}$/; -const ARTICLE_CODE = /^A\d{4,8}$/; +const ARTICLE_CODE = /^A\d{4,9}$/; // new articles have 9 digits (issue #5) export function lookup(siteId: string | null, articleCode: string | null): Lookup { if (!siteId || !SITE_ID.test(siteId)) {Outputok 6 - accepts the new 9-digit article codes (issue \#5) # tests 6 # pass 6 # fail 0Add a
1.0.1section to the changelog ("Fixed: accept 9-digit article codes such as A100000123; they returned 400 (#5)"), bump the patch version withnpm version patch --no-git-tag-version, open PR #6 withCloses #5, get it approved and merged, then tag. A bug fix with no new features is a patch release (Node.js and npm explains semantic versioning).Terminalgit tag -a v1.0.1 -m "v1.0.1: accept 9-digit article codes" git push origin v1.0.1 gh release create v1.0.1 --verify-tag --generate-notes git log --oneline --decorateOutput539c556 (HEAD -> main, tag: v1.0.1, origin/main) fix(api): accept 9-digit article codes (#6) 47672d0 (tag: v1.0.0) ci: deploy page to Pages and API image to GHCR on tags (#4) c8c16bd feat: stock lookup API and page (#3) 2b5e9bd ci: lint, type check and test on Node 22 and 24 (#2) c81b7e9 chore: scaffold project with TypeScript, ESLint and READMEFive commits on
main, each one a reviewed PR except the scaffold, and two tags that say exactly what is live. If v1.0.1 had a problem, rolling back isdocker runwith the1.0.0image, and re-running the v1.0.0 release workflow for the page.
| Step | Main tool | Learn more | What it protects against |
|---|---|---|---|
| Issue with acceptance criteria | GitHub Issues | Issues and Projects | Building the wrong thing |
Scripts, lockfile, .nvmrc | npm | Node and npm | "Works on my machine" |
| Lint, types, tests | ESLint, tsc, node:test | Team setup, Debug and test, TypeScript | Silly mistakes reaching review |
| Branch, small commits, PR | git, gh | Branches, Pull requests | Broken main, unreadable reviews |
| Required CI and approval | Rulesets, Actions | Branches and releases, Actions CI | Skipping the checks under pressure |
| Tag-triggered release | Actions, Pages, GHCR | Branches and releases, Deploy and secure | Not knowing what is live |
| Test-first hotfix | node:test, git | Undo, Debug and test | The same bug coming back |
Bring it to your team
Walk a real repo through this list with one engineer. Each missing item is a small, concrete task.
- If I clone this repo on a new laptop, which three commands get me to green checks? Is that written in the README?
- What exactly deploys to production: a merge, a tag, or a person? Where is that written down?
- Which checks are required before merge, and can anyone bypass them?
- How do I find out which version is live right now, without asking anyone?
- For the last hotfix, was there a failing test before the fix? How long did it take from report to live?
- If this release is bad, what are the exact steps to go back to the previous one, and who can do them?
Red flags
- "We deploy from one developer's laptop."
- No tags, so nobody can say which commit is in production.
- CI exists but is not required, or is "usually red".
- Hotfixes pushed straight to
mainor edited on the server. - Release workflow with
permissions: write-all, or secrets pasted intorun:lines.
Practice
Pick the next version from the commit messages
Write nextVersion(current, messages). current is a version such as '1.0.0', and messages are the Conventional Commit subjects merged since that release (on main each one is a PR title). Follow semantic versioning: a fix bumps the patch number; a feat bumps the minor number and resets patch to 0; a ! after the type or scope (feat!:, feat(api)!:) or the words BREAKING CHANGE bumps the major number and resets the other two. The biggest bump wins. If only other types were merged (chore, docs, ci, test), nothing needs releasing: return null.
function nextVersion(current, messages) {
// Only handles fixes so far, and has a bug. Run it and read the failures.
const [major, minor, patch] = current.split('.');
return `${major}.${minor}.${patch + 1}`;
}
test('a fix bumps the patch number', () => {
expect(nextVersion('1.0.0', ['fix(api): accept 9-digit article codes (#6)'])).toBe('1.0.1');
});
test('version parts are numbers, so 9 + 1 is 10', () => {
expect(nextVersion('1.9.9', ['fix: round prices to cents'])).toBe('1.9.10');
});
test('a feature bumps minor and resets patch, and wins over a fix', () => {
expect(nextVersion('1.0.1', ['fix(web): show a timeout message', 'feat(web): look up stock in every store'])).toBe('1.1.0');
});
test('! or BREAKING CHANGE bumps major and resets the rest', () => {
expect(nextVersion('1.4.2', ['feat(api)!: rename siteId to storeId'])).toBe('2.0.0');
expect(nextVersion('2.3.0', ['refactor: new stock format BREAKING CHANGE'])).toBe('3.0.0');
});
test('only chores, docs and CI changes need no release', () => {
expect(nextVersion('1.0.0', ['chore: update README', 'docs: fix a typo', 'ci: cache npm'])).toBe(null);
});
Show a solution
function nextVersion(current, messages) {
let [major, minor, patch] = current.split('.').map(Number); // '9' + 1 would be '91'
let bump = null; // null, 'patch', 'minor' or 'major'
for (const msg of messages) {
const type = msg.match(/^(\w+)(\([^)]*\))?(!)?:/); // type, optional scope, optional !
if ((type && type[3]) || msg.includes('BREAKING CHANGE')) bump = 'major';
else if (type && type[1] === 'feat' && bump !== 'major') bump = 'minor';
else if (type && type[1] === 'fix' && bump === null) bump = 'patch';
}
if (bump === 'major') return `${major + 1}.0.0`;
if (bump === 'minor') return `${major}.${minor + 1}.0`;
if (bump === 'patch') return `${major}.${minor}.${patch + 1}`;
return null;
}Tools such as semantic-release and release-please apply this same rule to a repo's history, which is one reason teams agree on Conventional Commit titles.
Plan a hotfix, step by step
A week after v1.1.0, Ama reports: "Every price at store S020 shows as $0.00". You find that the new priceCents field is missing from S020's rows in stock.json, so the page divides undefined by 100. Write the plan you would post on the incident issue: every step from now until the fix is live, with the branch name, the test you write first, the PR description, the new version number and the commands to release it. Then say how you would roll back if the fix itself goes wrong.
- It starts with an issue that describes what users see, and links the PR to it with
Closes #N. - The first code change is a failing test that reproduces the report, run before the fix.
- The fix goes through the same PR, CI and review as any change; nothing is edited on the server.
- The new version is a patch,
1.1.1, tagged withgit tag -aand pushed, so the release workflow deploys it. - The changelog gets a "Fixed" line.
- The rollback plan names the previous image or tag (
1.1.0) and how to redeploy it.
Show a model answer
- Issue #12, labelled
incident: "All prices at S020 show $0.00 since v1.1.0. Other stores are fine." git switch main && git pull, thengit switch -c fix/12-s020-prices.- Failing test first:
lookup('S020', 'A1001')returns a row whosepriceCentsis 850. Runnpm testand see it fail. - Fix the S020 rows in
stock.json, and add a test that every row has a whole-numberpriceCents, so a missing field fails CI next time. Runnpm run check. - Changelog: "1.1.1, Fixed: S020 prices showed $0.00 because priceCents was missing (#12)". Run
npm version patch --no-git-tag-version. - PR #13: "Closes #12. What: restore priceCents for S020. How to test: look up S020 / A1001, expect $8.50. Risk: data only." CI green, one approval, squash merge.
git switch main && git pull,git tag -a v1.1.1 -m "v1.1.1: fix S020 prices",git push origin v1.1.1,gh release create v1.1.1 --verify-tag --generate-notes. Check/healthsaysv1.1.1.- Rollback: run the
1.1.0image again withdocker run, and re-run the v1.1.0 release workflow for the page.
Check yourself
In this project, what makes a change go live?
The release workflow's only trigger is a pushed version tag. Merging to main runs CI but deploys nothing; npm version with --no-git-tag-version only edits files; and the GitHub Release is the human-readable notes, created after the tag. One clear trigger is what lets anyone say what is live.
A bug report says 9-digit article codes fail. Why write the test before changing the pattern?
If the new test passes before your change, you have not found the cause. If it fails and then passes after a one-character fix, you know both that the fix works and that a later change cannot quietly bring the bug back. Our CI does not require a new test per PR (Working with AI coding assistants, later in this course, adds a step that can).
The release workflow's Pages job fails with "is not allowed to deploy to github-pages due to environment protection rules". What is the likely cause?
By default the github-pages environment accepts deployments from the default branch only. A tag-triggered run needs a tag rule such as v*.*.* on that environment. Pages needs pages: write and id-token: write, not contents: write. A missing variable would produce a broken config.js, not a protection error.
Flashcards
Key takeaways
- Every change takes the same path: issue, branch, small commits, PR with CI and review, squash merge, tag, release workflow.
- Put CI in first and make it required, so every later change is checked the same way on every Node version you support.
- Let one thing deploy. Here only a version tag ships, through a workflow with tight permissions, so "what is live?" has a one-word answer.
- Static pages go to GitHub Pages; an API needs a server or container service. Both come from the same tag.
- A hotfix is a small release: failing test first, one-line fix, patch version, the same gates.