Hands-On Labs · The Capstone · Part 1 of 6

Capstone Part 1 — Pipeline Foundation

This is the first of six parts that build one continuous project: parcel-api, a small shipment-tracking service you'll stand up, wire, deploy, watch, break, and secure over the rest of this capstone. Part 1 lays the two things every later part depends on — a real branching strategy enforced by branch protection, not just written in a wiki, and a working CI pipeline that lints, tests, and builds parcel-api automatically on every single push. By the end of this page you will have watched, with your own eyes, a pull request get blocked by a failing test and a direct push get rejected by GitHub itself.

☺ Explain it like I'm 10

Before a warehouse ships a single box, it needs two things nailed down: which shelf every finished box goes on, and an inspector standing at that shelf who checks every box the second it's set down — not once a week. This page builds both. The shelf is main, guarded so nothing lands on it unchecked. The inspector is a robot that lints, tests, and builds parcel-api the instant anyone pushes a change, and refuses to wave a broken box through no matter who's carrying it.

🦫Your host for this part: Benny the Beaver — the branching workflow from Version Control & Branching and the pipeline from CI/CD Pipelines were both his lessons; today he wires them together for real, against a real app, for the first time.
⚠ Where you're starting, and what you'll have when you're done

Starting: nothing — no repo, no app, no pipeline. Just a laptop with git, the GitHub CLI, Node.js, and Docker installed. Leaving this page: a parcel-api repository on GitHub running a small Express service with a real Jest test suite; a .github/workflows/ci.yml pipeline that lints, tests, and builds a Docker image on every push and every pull request; branch protection on main requiring that pipeline to pass before anything merges; and firsthand proof that both a broken test and a direct push to main get stopped cold. Part 2 picks up exactly here and gives the image this pipeline builds somewhere real to go.

What this part assumes, and what it produces

☺ Like you're 10: Just the tools on your desk and a GitHub account — nothing built yet.

You need five things installed locally: git, the GitHub CLI (gh, authenticated with gh auth login), Node.js (version 22, the current LTS as of this writing — swap in whatever's current when you actually do this), and Docker (Desktop or Engine — GitHub's own ubuntu-latest runners already ship with it, but you'll want it locally too, to build the image before you ever push). You also need a GitHub account with permission to create a repository. Nothing else needs to exist yet: no registry, no cloud account, no server to deploy to. Those arrive starting in Part 2.

The world model this whole capstone shares

Six parts, one project, so it's worth naming the shape once, here, so nothing surprises you later:

ThingName / valueIntroduced
The applicationparcel-api — a small shipment-tracking HTTP servicePart 1 — this page
Source repoparcel-api on GitHub, trunk-based branchingPart 1
Branch protectionmain requires the build-and-test status check to passPart 1
CI pipeline.github/workflows/ci.yml — lint, test, buildPart 1
Container imageparcel-api:<commit-sha>, built but not yet pushed anywherePart 1 (built) → Part 2 (pushed to a real registry)
Hosting targetnot yet namedPart 2
Deployment strategynot yet chosenPart 3

Keep that table in your head across all six parts: whenever a later page says "the build-and-test check" or "the image this pipeline builds," this is where those names were born.

Standing up the parcel-api repo

☺ Like you're 10: Make the shelf before you decide who's allowed to stack boxes on it.

Create the repo now, empty except for a .gitignore. Deliberately, branch protection does not go on yet — GitHub can't require a status check that has never run once, so the very first commit has to land on main directly, unprotected, to give the pipeline something to run against. Every commit after that one follows the rules.

mkdir parcel-api && cd parcel-api
git init -b main
printf "node_modules/\ncoverage/\n" > .gitignore
git add .gitignore
git commit -m "chore: initial commit"

gh repo create parcel-api --private --source=. --remote=origin --push

This capstone uses trunk-based development, the same strategy Version Control & Branching correlates most strongly with elite delivery performance: one long-lived branch (main), short-lived feature branches measured in hours, and a pull request for every change. GitFlow's long-lived develop and release/* branches don't get used here — there's no scheduled release train for a service this capstone intends to deploy continuously.

Building parcel-api: the app this pipeline builds and tests

☺ Like you're 10: A tiny web service that remembers packages, plus a checklist proving it actually works.

parcel-api is deliberately small — an Express service with three routes, an in-memory store, and a real test suite. Small is the point: every later capstone part adds to this exact app, so Part 1's job is a working skeleton, not a finished product. Create package.json:

{
  "name": "parcel-api",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "node src/index.js",
    "test": "jest",
    "lint": "eslint ."
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "@eslint/js": "^9.9.0",
    "eslint": "^9.9.0",
    "jest": "^29.7.0",
    "supertest": "^7.0.0"
  }
}
// src/index.js
const express = require("express");
const { randomUUID } = require("node:crypto");

const app = express();
app.use(express.json());

const shipments = new Map();

app.get("/healthz", (_req, res) => res.status(200).json({ status: "ok" }));

app.post("/shipments", (req, res) => {
  const { destination, weightKg } = req.body || {};
  if (!destination || typeof weightKg !== "number") {
    return res.status(400).json({ error: "destination and weightKg are required" });
  }
  const id = randomUUID();
  const shipment = { id, destination, weightKg, status: "created" };
  shipments.set(id, shipment);
  res.status(201).json(shipment);
});

app.get("/shipments/:id", (req, res) => {
  const shipment = shipments.get(req.params.id);
  if (!shipment) return res.status(404).json({ error: "not found" });
  res.status(200).json(shipment);
});

module.exports = { app };

if (require.main === module) {
  const port = process.env.PORT || 8080;
  app.listen(port, () => console.log(`parcel-api listening on :${port}`));
}
// tests/shipments.test.js
const request = require("supertest");
const { app } = require("../src/index");

describe("parcel-api", () => {
  test("GET /healthz returns 200", async () => {
    const res = await request(app).get("/healthz");
    expect(res.status).toBe(200);
  });

  test("POST /shipments creates a shipment, GET /shipments/:id reads it back", async () => {
    const created = await request(app)
      .post("/shipments")
      .send({ destination: "Pune, IN", weightKg: 2.4 });
    expect(created.status).toBe(201);
    expect(created.body.status).toBe("created");

    const fetched = await request(app).get(`/shipments/${created.body.id}`);
    expect(fetched.status).toBe(200);
    expect(fetched.body.destination).toBe("Pune, IN");
  });

  test("POST /shipments rejects a request missing destination", async () => {
    const res = await request(app).post("/shipments").send({ weightKg: 1 });
    expect(res.status).toBe(400);
  });
});

Add a minimal flat-config eslint.config.js (ESLint 9's default format) so npm run lint has something to check against, and a Dockerfile so the pipeline has something to build:

// eslint.config.js
const js = require("@eslint/js");
module.exports = [js.configs.recommended];
# Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 8080
CMD ["node", "src/index.js"]

Run npm install && npm test locally once before you push anything — three passing tests, no red output. See Testing in the Pipeline for how this same suite grows into the layered test strategy real pipelines run at scale.

Wiring the CI pipeline: lint, test, and build on every push

☺ Like you're 10: One robot, three checks, triggered the instant anyone pushes anything, anywhere.

Per CI/CD Pipelines, this is pipeline-as-code: the definition below is a plain YAML file, checked into the same repo it builds, reviewed and diffed exactly like the application code. Create .github/workflows/ci.yml:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: ["**"]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - run: npm ci
      - run: npm run lint
      - run: npm test -- --ci --coverage

      - name: Build the image (no push yet — Part 2 wires the registry)
        run: docker build -t parcel-api:${{ github.sha }} .

Two trigger blocks are doing different jobs. push: branches: ["**"] is the part the content brief for this page is really about — it fires on every push to any branch, so nothing you commit ever runs untested on your own machine before someone else sees it. pull_request: branches: [main] is what makes the result show up as a status check directly on a pull request against main, which is what branch protection below actually reads. You need both: push alone never populates a PR's checks list the way reviewers expect to see it.

Notice the job is named build-and-test — that exact string is the "context" branch protection will require in the next section, and the four steps map straight onto the front of the pipeline stages from CI/CD Pipelines: lint, unit test, build. Nothing here deploys, scans, or pushes an image anywhere — this pipeline stops at a verified local build, on purpose, because there's no registry to push to and nowhere to deploy until Part 2 builds one.

Feature branch short-lived, hours push GitHub Actions lint → test → build job: build-and-test status check 🔒 Branch protection required check + review green → squash merge main push to main runs the exact same pipeline again, every time
◆ Key idea

The build step tags the image with the immutable commit SHA — parcel-api:${{ github.sha }} — not latest, even though nothing consumes that image yet. This is "build once, promote everywhere" from CI/CD Pipelines started one part early: when Part 2 gives this image a registry to land in, the exact digest that passed today's tests is the one that ships, unrebuilt.

Turning on branch protection: making the pipeline a gate, not a suggestion

☺ Like you're 10: A checklist nobody's forced to follow gets skipped the first busy afternoon — this nails it to the door.

Push the scaffold above straight to main once — this is the one and only commit in this whole capstone that goes there unreviewed, and only because branch protection can't require a check that has never run:

git add .
git commit -m "chore: scaffold parcel-api with CI pipeline"
git push -u origin main

gh run watch     # follow the first Actions run live until it's green

Once that first run is green, GitHub has seen the build-and-test context and can require it. Turn on branch protection with the GitHub CLI (GitHub's newer "Rulesets" UI does the same job through a friendlier form under Settings → Rules — either path lands on the same enforcement):

gh api --method PUT repos/{owner}/{repo}/branches/main/protection \
  --input - <<'JSON'
{
  "required_status_checks": { "strict": true, "contexts": ["build-and-test"] },
  "enforce_admins": true,
  "required_pull_request_reviews": { "required_approving_review_count": 0 },
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false
}
JSON

{owner} and {repo} are literal — gh api fills them in from the repo you're sitting in. enforce_admins: true means even the repo owner can't bypass this, which is exactly what makes the direct-push proof later in this page work. required_approving_review_count is set to 0 deliberately: GitHub won't let you approve your own pull request, so a solo capstone with a nonzero count here would lock you out entirely. In a real team, set it to 1 or more — the build-and-test check is still doing the real gating work either way.

⚠ The one string that has to match exactly

required_status_checks.contexts has to equal the job's name in the workflow file — build-and-test — not the workflow's name: CI at the top, and not some other string you thought sounded right. Get this wrong and branch protection silently waits forever for a check that will never report under that name, and every pull request stays permanently un-mergeable with no obvious error pointing at why. If a PR looks stuck with a green pipeline, this mismatch is the first thing to check.

Proving the pipeline actually gates every push

☺ Like you're 10: Break something on purpose and watch the inspector actually stop you, not just watch.

Everything from here on follows trunk-based development for real: a short branch, a small commit, a pull request, a green check, a squash merge. First, the ordinary path:

git checkout -b add-version-to-healthz
# edit src/index.js: include a version field in the /healthz response
git commit -am "feat: report version in /healthz response"
git push -u origin add-version-to-healthz

gh pr create --fill --base main
gh pr checks --watch      # watch build-and-test populate on the PR itself

Now break it on purpose, on the same branch, to prove the gate is real and not decorative:

git commit -am "test: (temporary) break the shipment test on purpose"
git push

gh pr checks               # build-and-test now shows failing
gh pr merge                # GitHub refuses — something like:
                            # X Required status check "build-and-test" is failing — merge not allowed.

That refusal is the entire point of this page. Fix it, watch the check turn green, and merge for real:

git revert HEAD --no-edit
git push
gh pr checks --watch

gh pr merge --squash --delete-branch
gh run list --branch main --limit 1   # confirm the pipeline reran on main too, post-merge

Last, prove the gate applies even to you, even with admin rights, even on main itself:

git checkout main && git pull
echo "manual edit" >> README.md
git commit -am "test: direct push to main"
git push origin main
# remote: error: GH006: Protected branch update failed for refs/heads/main.
# remote: error: Required status check "build-and-test" is expected.

git reset --hard origin/main   # nothing landed remotely — drop the local commit

Nothing about that last command sequence is special-cased — it's the identical rule that blocked the broken pull request, applied to a push that skipped pull requests entirely. That's what "the pipeline gates every push," said precisely, actually means: not "usually," not "for pull requests specifically," but for every single ref update main ever receives.

What "done" looks like for Part 1, and where Part 2 picks up

☺ Like you're 10: A shelf that's actually guarded, and an inspector who's actually watching every box.

At the end of this part you have: a parcel-api repo with a working Express service and a passing Jest suite; a .github/workflows/ci.yml pipeline that lints, tests, and builds a Docker image tagged by commit SHA on every push and every pull request; branch protection on main requiring build-and-test to pass, enforced even against admins; and firsthand proof — not a claim — that a failing test blocks a merge and a direct push gets rejected. Nothing here gets thrown away:

PartWhat it does with Part 1's artifacts
2 — Infrastructure as CodeProvisions the registry and hosting target the parcel-api:<sha> image this pipeline builds actually gets pushed to and run on
3 — Deployment StrategyChooses and implements the rollout strategy that ships the exact image this pipeline produces
4 — ObservabilityInstruments the /healthz and /shipments routes built here with real metrics and dashboards
5 — Incident ResponseRuns a real incident against parcel-api and ships the fix back through this same gated pipeline
6 — Security HardeningAdds dependency scanning, secret management, and supply-chain checks directly into this ci.yml
🎬 At the Ship-It Guild
🦫

Benny the Beaver: Job's called build-and-test. Same string in the workflow file and in the branch protection rule — that's the whole trick that makes it a real gate.

🦊

Foxy: And if I just push straight to main instead of bothering with a PR?

🦫

Benny: Try it. GitHub bounces it — GH006, protected branch, required check expected. I broke a config once thinking enforce_admins didn't apply to the repo owner. It does.

🐢

Timmy: Good. I don't care whose commit it is — unverified is unverified. That's the same rule I run at deploy time, just moved a few steps earlier.

🦊

Foxy: So the pipeline isn't just fast feedback anymore. It's the only door onto main.

🦫

Benny: That's the whole point of Part 1. Everything else this capstone builds ships through that one door.

Milestones

☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."

Work these in order — each depends on the repo and pipeline state from the one before. Progress saves in this browser.

0 / 12 milestones complete
1Create the parcel-api repo with an initial scaffold commit on main
mkdir parcel-api, git init -b main, add .gitignore, then gh repo create parcel-api --private --source=. --remote=origin --push.
Done when: the repo exists on GitHub with one commit on main.
2Write parcel-api: /healthz, POST /shipments, GET /shipments/:id
Create package.json and src/index.js exactly as shown above.
Done when: npm start boots the service and curl localhost:8080/healthz returns {"status":"ok"}.
3Write the Jest suite covering create, read, and validation
Create tests/shipments.test.js exactly as shown above.
Done when: npm test reports 3 passing, 0 failing.
4Add eslint.config.js and the Dockerfile
Both exactly as shown above.
Done when: npm run lint exits clean and docker build -t parcel-api:local . succeeds locally.
Concept: Docker
5Add .github/workflows/ci.yml with the build-and-test job
Push and pull_request triggers, then lint → test → build steps, exactly as shown above.
Done when: the file is committed locally (not pushed yet).
6Push the scaffold to main and watch the first run go green
git push -u origin main, then gh run watch.
Done when: the Actions tab shows build-and-test passing on main.
7Turn on branch protection with build-and-test required
Run the gh api --method PUT .../protection command exactly as shown above.
Done when: the repo's Settings → Branches page shows main protected with that check required.
8Cut a short-lived branch, push a small change, open a PR
git checkout -b add-version-to-healthz, commit, push, gh pr create --fill --base main.
Done when: gh pr checks shows build-and-test running or passed, visible directly on the PR.
9Break a test on purpose and confirm the PR gets blocked
Push a commit that breaks a test, then run gh pr merge.
Done when: the merge is refused while build-and-test is red.
10Fix it, watch the check turn green, squash-merge
git revert HEAD --no-edit, push, gh pr checks --watch, then gh pr merge --squash --delete-branch.
Done when: the PR is merged and the branch is deleted.
11Confirm main reran, then try (and fail) a direct push to main
gh run list --branch main --limit 1, then attempt the direct-push sequence shown above.
Done when: the direct push is rejected with a protected-branch error, and git reset --hard origin/main cleans up locally.
Concept: this page's proving section
12Say out loud what state you're leaving for Part 2
Confirm: main protected, build-and-test required and green, a Docker image builds locally tagged by commit SHA, no resource on main that skipped review.
Done when: you can describe this state without looking anything up — it's the exact starting point Part 2 assumes.
✓ Checkpoint

1. Why does the very first commit to main in this walkthrough go in unprotected, and what specifically unlocks turning branch protection on afterward? 2. Name the two trigger blocks in ci.yml and explain what each one is actually for — why isn't one of them enough on its own? 3. What exact string has to match between the workflow file and the branch protection rule, and what happens if it doesn't? 4. What did pushing directly to a protected main prove that a blocked pull request alone didn't?

Check your answers
  1. GitHub can't require a status check that has never reported once, so the pipeline needs at least one real run to exist before required_status_checks.contexts can name it. Pushing the scaffold to main directly gives the build-and-test context something to point at; branch protection goes on immediately afterward.
  2. push: branches: ["**"] fires on every push to any branch, which is what makes the pipeline run automatically on every single push, not just reviewed ones. pull_request: branches: [main] is what surfaces the result as a status check directly on a pull request against main, which is what branch protection actually reads. Push alone never populates a PR's own checks list the way reviewers expect to see it.
  3. The job's name in the workflow file, build-and-test, has to exactly equal the string in required_status_checks.contexts — not the workflow's top-level name: CI. If they don't match, branch protection waits forever for a check that will never report under that name, and every pull request stays permanently un-mergeable with no obvious explanation.
  4. That the gate applies to every ref update to main, not just to pull requests — including a push from the repo owner with enforce_admins: true set. A blocked PR only proves the review path is gated; the rejected direct push proves there is no unguarded side door at all.

Part 1 gave you a repo, a branching strategy actually enforced by GitHub instead of just written down, and a pipeline that gates every push with proof, not a promise. Continue to Capstone Part 2 — Infrastructure as Code, where the image this pipeline builds finally gets somewhere to go. Or step back to Ship It — Start Here to see how this capstone's six parts fit the rest of the hands-on labs, and revisit Version Control & Branching and CI/CD Pipelines for the concepts behind what you just built.