1. Courses
  2. Developer Foundations
  3. Ship a Project End to End
  4. Stop 1

40 minAdvanced

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 git and gh
  • 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=A1001 returns JSON, and GET /health reports 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 fetch and 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 . 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?

GitHub Issue #1: the ask git switch -c laptop Branch + 4 commits push, gh pr create GitHub PR #3: CI runs checks pass GitHub Review, fix, approve approved GitHub Squash merge to main git tag -a laptop Tag v1.0.0, push tag triggers it GitHub Release workflow deploys server Live: page + API users report GitHub Issue #5: bug report fix branch laptop PR #6 + test, v1.0.1 same path
The whole journey on one line. Each step happens in one place: your laptop, GitHub, or the server. A feature starts as issue #1 and ends live after the v1.0.0 tag. The bug report on the next day takes the same path again (dashed arrow), ending in v1.0.1.

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 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?

Quick check

Which gate is best placed to catch a page that shows "undefined" when the network is down?

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.

  1. Create the repo and the issue (on GitHub)

    gh repo create makes 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.

    Terminal
    gh 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. enhancement is one of GitHub's default labels; api is the one we just created.

  2. Scaffold the project

    One package.json with 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).

    Terminal
    npm 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' > .gitignore
    Output
    added 99 packages, and audited 100 packages in 9s
    
    31 packages are looking for funding
      run `npm fund` for details
    
    found 0 vulnerabilities

    Then edit package.json so it looks like this. "type": "module" makes every .js and .ts file an ES module (Prototypes, classes and modules). We pin TypeScript 5.9, the line this course uses. Plain npm i -D typescript would 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"]
    }

    The tsconfig options are the ones the Node docs recommend for type stripping: noEmit (Node runs the source, so tsc only checks), erasableSyntaxOnly (no TypeScript features that Node cannot simply delete, such as enum), and verbatimModuleSyntax (type-only imports must say import type). Add a short README and a one-line placeholder src/main.ts so the type check has a file to read, then run the checks once and make the first commit directly on main. 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 plain ok and not ok lines; npm run check runs the same three with the default reporter's tick marks.) With no tests yet, the runner finds zero and passes.

    Terminal
    npm run lint; npm run typecheck; npm test -- --test-reporter=tap
    Output
    > 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 0
    Terminal
    git add -A
    git commit -m "chore: scaffold project with TypeScript, ESLint and README"
    git push -u origin main
  3. 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: read gives 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 test

    Check 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.

    Terminal
    git 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 main
    Output
    verbose: 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 files

    On GitHub: PR #2 opens and two checks appear, check (22) and check (24), one per matrix entry. When both are green, merge with gh pr merge 2 --squash --delete-branch.

  4. 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)" } ] } }
      ]
    }
    Terminal
    gh api repos/{owner}/{repo}/rulesets --method POST --input ruleset.json

    On 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 set required_approving_review_count to 0 while practising.

  5. 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 Map gives one lookup per request (Performance and memory), and inputs are checked before use (Security and quality).

    Terminal
    git switch -c feat/1-stock-api
    TypeScript
    // 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' };
    }

    src/data/stock.json holds five rows such as {"siteId": "S014", "articleCode": "A1001", "name": "Basmati rice 5 kg", "qty": 42, "priceCents": 850}. Commit with a Conventional Commit message:

    Terminal
    npm test
    git add -A && git commit -m "feat(api): look up stock by siteId and articleCode"
  6. 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));
    }
    Terminal
    npm 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'
    Output
    stock 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)
    Terminal
    git add -A && git commit -m "feat(api): serve GET /api/stock and /health over HTTP"
  7. Add the page and open the PR

    The page is plain HTML with a form. web/config.js holds the API address; the release workflow overwrites it for production. The first version of app.js is 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)}`;
    });

    Serve web/ with any static server, for example npx serve web -l 8080, and try it. Then commit, push and open the PR. Closes #1 in the body links the PR to the issue and closes it on merge.

    Terminal
    git 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:

    Terminal
    npm run lint; npm run typecheck; npm test -- --test-reporter=tap
    Output
    > 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
  8. 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):

    Terminal
    gh 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
    +  }
     });
    Terminal
    git commit -am "fix(web): show errors and time out after 5 seconds"
    git push
    git log --oneline main..HEAD
    Output
    73ec384 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 articleCode

    We ran the page's code against the running API (in Node, with the two page elements stubbed), looking up three pairs:

    Output
    S014 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 record

    On 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 on main, and issue #1 closes.

    Terminal
    gh pr merge 3 --squash --delete-branch
    git switch main && git pull
    git log --oneline
    Output
    c8c16bd (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
  9. Add the release workflow and the Dockerfile

    One workflow, triggered only by a version tag, with three jobs. check runs the same checks again. If they pass, page and image run 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@v5

    The API_BASE_URL value goes through an env: entry instead of being pasted into the script. That is a habit worth keeping: values from ${{ }} expressions placed directly in a run: script can inject shell code if they ever contain user input. The .dockerignore file keeps node_modules, tests, the page and .git out 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.

    Terminal
    NODE_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/health
    Output
    found 0 vulnerabilities
    stock API v1.0.0 on port 3100, allowing http://localhost:8080
    {"ok":true,"version":"v1.0.0"}
  10. 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-pages environment. 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.

    Terminal
    gh 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=tag

    If 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.

  11. 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)
    Terminal
    npm 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-notes

    On GitHub: the tag push starts the Release workflow. Watch it with gh run watch. Expect check, then page and image side by side, in a few minutes in total. The run summary shows the site address, which for a project repo is https://YOUR-USER.github.io/store-stock-checker/. The image job pushes ghcr.io/your-user/store-stock-checker:1.0.0 and :latest (image names are lowercased). gh release create --generate-notes builds release notes from the merged PRs since the last tag.

  12. 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:packages scope, or make the package public in its settings.

    Terminal
    echo "$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/health

    Expected: {"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 an http:// 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.

  13. 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.

    Terminal
    git switch main && git pull
    git switch -c fix/5-long-article-codes
    TypeScript
    test('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');
    });
    Terminal
    npm test -- --test-reporter=tap
    Output
    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
    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 1

    The fix is one character, plus the same change in the page's pattern attribute:

    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)) {
    Output
    ok 6 - accepts the new 9-digit article codes (issue \#5)
    # tests 6
    # pass 6
    # fail 0

    Add a 1.0.1 section to the changelog ("Fixed: accept 9-digit article codes such as A100000123; they returned 400 (#5)"), bump the patch version with npm version patch --no-git-tag-version, open PR #6 with Closes #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).

    Terminal
    git 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 --decorate
    Output
    539c556 (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 README

    Five 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 is docker run with the 1.0.0 image, and re-running the v1.0.0 release workflow for the page.

store-stock-checker/ src/main.ts src/server.ts src/stock.ts src/data/stock.json API container Node 24 runs .ts web/index.html web/app.js web/style.css web/config.js Page GitHub Pages test/stock.test.ts test/server.test.ts eslint.config.js tsconfig.json workflows/ci.yml Checks every PR, in CI workflows/release.yml Dockerfile .dockerignore CHANGELOG.md Shipping on a v* tag package.json package-lock.json .nvmrc .gitignore README.md Shared setup same on every laptop
The final repository, 22 files, grouped by what each file is for. Source files become the API container, web files become the GitHub Pages site, tests and configs are what CI checks on every PR, the release workflow and Dockerfile ship it, and the shared setup makes every machine behave the same.
StepMain toolLearn moreWhat it protects against
Issue with acceptance criteriaGitHub IssuesIssues and ProjectsBuilding the wrong thing
Scripts, lockfile, .nvmrcnpmNode and npm"Works on my machine"
Lint, types, testsESLint, tsc, node:testTeam setup, Debug and test, TypeScriptSilly mistakes reaching review
Branch, small commits, PRgit, ghBranches, Pull requestsBroken main, unreadable reviews
Required CI and approvalRulesets, ActionsBranches and releases, Actions CISkipping the checks under pressure
Tag-triggered releaseActions, Pages, GHCRBranches and releases, Deploy and secureNot knowing what is live
Test-first hotfixnode:test, gitUndo, Debug and testThe 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 main or edited on the server.
  • Release workflow with permissions: write-all, or secrets pasted into run: 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.

JavaScript exerciseRuns in your browser with tests
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}`;
}
Ctrl + Enter runs
Show a solution
JavaScript
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.

Pass the exercise to finish this task.

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 with git tag -a and 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
  1. Issue #12, labelled incident: "All prices at S020 show $0.00 since v1.1.0. Other stores are fine."
  2. git switch main && git pull, then git switch -c fix/12-s020-prices.
  3. Failing test first: lookup('S020', 'A1001') returns a row whose priceCents is 850. Run npm test and see it fail.
  4. Fix the S020 rows in stock.json, and add a test that every row has a whole-number priceCents, so a missing field fails CI next time. Run npm run check.
  5. Changelog: "1.1.1, Fixed: S020 prices showed $0.00 because priceCents was missing (#12)". Run npm version patch --no-git-tag-version.
  6. 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.
  7. 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 /health says v1.1.1.
  8. Rollback: run the 1.1.0 image again with docker run, and re-run the v1.1.0 release workflow for the page.

Check yourself

In this project, what makes a change go live?

A bug report says 9-digit article codes fail. Why write the test before changing the pattern?

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?

Flashcards

Open my review

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.
Episode transcriptOne app, from issue to hotfix · about 11 minutes · tap any line to play from there
  1. NadiaImran, it's the morning after a release. Store staff call. A new bread article gives an error in the stock checker. What does your team do in the next hour?
  2. ImranIn some teams I've seen, a developer opens the server and edits the file. And then nobody knows what's running.
  3. NadiaThat's the story this episode is meant to replace. We take one tiny app from the first issue to a live release, and then fix that exact bug the safe way. Every earlier lesson shows up once.
  4. ImranWhat's the app?
  5. NadiaA store stock checker. You type a store code like S zero one four and an article code like A one zero zero one, and you see the name, the quantity and the price. A small Node API and one web page. About a hundred and twenty lines of code.
  6. ImranThat's tiny. Why not something bigger?
  7. NadiaSo you can see the process instead of the code. A fifty thousand line app takes the same path. The path is the lesson.
  8. NadiaHere's the picture. Imagine opening a small tea and snacks counter inside an existing Mangrove store. Someone writes down what it should sell. A carpenter builds it in the stockroom, away from customers. The store manager inspects it before it goes on the floor.
  9. NadiaEvery time, someone runs a checklist: power, the scale is calibrated, the price labels match the system. On opening day, the counter gets a number and a date in the store log.
  10. ImranAnd when a customer says on day two that the scale reads fifty grams heavy, nobody rebuilds the counter. A technician fixes that one thing and runs the same checklist again.
  11. NadiaExactly. The written request is the issue. The backstore is the branch. The manager is the reviewer. The checklist is CI. The log entry is the tag. And the technician is the hotfix.
  12. NadiaIf you can see your screen, the first diagram shows the whole journey as one column of ten boxes. If not, picture a ladder. Issue one at the top, then branch, pull request with CI, review, squash merge, tag, release workflow, and live at the bottom.
  13. NadiaBelow that, issue five, the bug report, and then a fix pull request with a dashed arrow going back up to the release workflow. Same path.
  14. ImranWhy does the order matter so much? Couldn't we add CI later?
  15. NadiaEach gate catches a different mistake, cheaply, before the next gate. The issue catches unclear goals. CI catches type errors and broken tests without a person spending time. Review catches what machines can't, like a missing error message. And the tag is the only thing that deploys.
  16. ImranWait. Merging to main doesn't deploy?
  17. NadiaNot in this project. Merging runs CI. Only pushing a version tag, like v one point zero point zero, starts the release. So if you ask what's live, the answer is one word: the latest tag.
  18. ImranI like that. I ask that question every week and get three different answers.
  19. NadiaLet's walk through the Build it in words. Step one, you create the repository with the GitHub command line tool, and you write issue one with acceptance criteria: unknown pairs return not found, bad codes return a four hundred error, the page shows a clear message.
  20. NadiaStep two, you scaffold. One package file with scripts for lint, type check and test, and one combined check script. A dot nvmrc file pins Node twenty-four. You run the checks once, they pass with zero tests, and that first commit goes straight to main. It's the only one that ever will.
  21. ImranAnd CI comes next, before any feature?
  22. NadiaYes, in its own tiny pull request. The workflow runs lint, the type check and the tests on Node twenty-two and Node twenty-four. You check the file with actionlint before pushing. Then you protect main with a ruleset: one approval, resolved conversations, squash merges only, and both CI checks required.
  23. ImranThen the feature.
  24. NadiaA branch named after the issue. The stock rule lives in its own module with no web code in it, so it's easy to test. A map gives one lookup per request, and the codes are checked with a pattern before use. Prices are whole numbers in cents.
  25. NadiaThen a tiny server with two routes, the stock lookup and a health check that reports the version. The test starts the server on port zero, which means any free port, so tests never clash. Then the page. Four commits, pushed, and the pull request says closes issue one.
  26. ImranAnd now my favourite part. The review.
  27. NadiaThe reviewer opens the page, turns off Wi-Fi, and tries a store that doesn't stock that article. For the not found case, the page shows undefined, undefined in stock at undefined, and a dollar sign followed by not-a-number. With no network, it shows nothing at all.
  28. ImranThat would confuse every cashier. Store internet is flaky half the day.
  29. NadiaSo the reviewer requests changes. The fix is on the same branch: show checking while waiting, give up after five seconds, and show the API's error message in red. The second review is quick because the diff is small and focused.
  30. ImranThen squash merge. Four commits become one on main.
  31. NadiaRight, and issue one closes automatically. Then a release pull request adds a workflow that runs only on version tags. It runs the checks again. If they pass, two jobs run side by side. One publishes the page to GitHub Pages. The other builds a container image of the API and pushes it to GitHub's container registry.
  32. ImranCan GitHub Pages run the API too?
  33. NadiaNo, that's a common wrong guess. Pages only serves static files. The page goes there. The API needs a real server or a container service, which runs the image the workflow published.
  34. NadiaTwo notes from the lesson. First, the GitHub side couldn't run from where the lesson was written, so those steps are clearly marked on GitHub, with what you should expect to see.
  35. ImranAnd the container itself?
  36. NadiaThe image build couldn't run either. So the lesson copied exactly what the Dockerfile copies, ran the same install, and started it on Node twenty-four. The health check reported version one point zero point zero.
  37. ImranYou mentioned a trap with Pages and tags.
  38. NadiaYes. The Pages environment, by default, only accepts deployments from the default branch. A run started by a tag gets refused with a message about environment protection rules. The fix is a tag rule on that environment that matches v star dot star dot star.
  39. ImranOkay. Tag v one point zero point zero, the workflow runs, page and API are live. Next morning, the bread article. That's the Mangrove example in the lesson: the product data team added new nine digit article codes the day after release.
  40. NadiaIssue five. The new article codes have nine digits, and the pattern only allows up to eight. The rule here is test first. You write a test that looks up the new bread article and expects a result. You run it, and it fails. Five pass, one fails.
  41. ImranWhy bother? The fix is one character.
  42. NadiaTwo reasons. If the test passed before your change, you'd know you hadn't found the real cause. And once it's in, nobody can quietly bring the bug back. Then you change eight to nine, in the server and in the page's input pattern, and all six tests pass.
  43. NadiaA changelog line, a patch version, pull request six with closes issue five, approval, merge, and tag v one point zero point one. Same path, about as long as a tea break.
  44. ImranHow did you know it was a patch and not a minor version?
  45. NadiaA fix with no new feature is a patch. The first practice task turns that rule into code you write in your browser: given the version and the merged commit titles, pick the next version.
  46. ImranSo fix means patch, feat means minor, and an exclamation mark means a breaking change, so major.
  47. NadiaExactly. And the second task is planning a hotfix of your own, step by step, including how you'd roll back.
  48. NadiaThe second diagram shows the final repository, twenty-two files, grouped by job. If you can't look, think of five drawers. Source files become the API container. Web files become the Pages site.
  49. NadiaTests and configs are what CI checks. The release workflow and Dockerfile ship it. And shared setup keeps every laptop the same.
  50. ImranFor my team, I'd ask three things. If I clone this repo on a new laptop, which three commands get me to green checks? What exactly deploys to production: a merge, a tag, or a person?
  51. ImranAnd for the last hotfix: was there a failing test first, and how long from report to live?
  52. NadiaGood. I'd add one: if this release is bad, what are the exact steps to go back, and who can do them? Here it's running the previous image tag.
  53. NadiaSomething to think about. A developer says, the fix is one line, it's urgent, let me push it straight to main and deploy from my laptop. What's lost? Take a second.
  54. ImranThe checks, the review, and the record. The server would run code that matches no tag. And the next real release might bring the bug back, because there's no test.
  55. NadiaExactly. And the deeper point: if the safe path is fast, nobody needs the shortcut. The lesson's rule of thumb is under thirty minutes from branch to live for a one line fix.
  56. ImranMy three takeaways. One, every change takes the same path: issue, branch, pull request with CI and review, squash merge, tag. Two, only one thing deploys, the version tag, so what's live has a one word answer. Three, a hotfix is just a small release, with a failing test first.
  57. NadiaOne nuance on the first. Put CI in before the first feature and make it required. A check that's optional gets skipped exactly when you need it.
  58. NadiaNext time, we turn this path into rules your whole team agrees on. The engineering playbook, and how to measure whether it's working.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Release checklist

Before, during and after a deploy: owners, go/no-go checks and the rollback trigger, as a checklist.

Open in the DevOps & TechOps Lab
Next stopYour team's engineering playbook
Was this lesson helpful?
Questions and discussion

Loading the discussion…