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.
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.
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:
| Thing | Name / value | Introduced |
|---|---|---|
| The application | parcel-api — a small shipment-tracking HTTP service | Part 1 — this page |
| Source repo | parcel-api on GitHub, trunk-based branching | Part 1 |
| Branch protection | main requires the build-and-test status check to pass | Part 1 |
| CI pipeline | .github/workflows/ci.yml — lint, test, build | Part 1 |
| Container image | parcel-api:<commit-sha>, built but not yet pushed anywhere | Part 1 (built) → Part 2 (pushed to a real registry) |
| Hosting target | not yet named | Part 2 |
| Deployment strategy | not yet chosen | Part 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 --pushThis 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.
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 greenOnce 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.
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 itselfNow 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-mergeLast, 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 commitNothing 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:
| Part | What it does with Part 1's artifacts |
|---|---|
| 2 — Infrastructure as Code | Provisions the registry and hosting target the parcel-api:<sha> image this pipeline builds actually gets pushed to and run on |
| 3 — Deployment Strategy | Chooses and implements the rollout strategy that ships the exact image this pipeline produces |
| 4 — Observability | Instruments the /healthz and /shipments routes built here with real metrics and dashboards |
| 5 — Incident Response | Runs a real incident against parcel-api and ships the fix back through this same gated pipeline |
| 6 — Security Hardening | Adds dependency scanning, secret management, and supply-chain checks directly into this ci.yml |
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.
parcel-api repo with an initial scaffold commit on mainmkdir parcel-api, git init -b main, add .gitignore, then gh repo create parcel-api --private --source=. --remote=origin --push.main.parcel-api: /healthz, POST /shipments, GET /shipments/:idpackage.json and src/index.js exactly as shown above.npm start boots the service and curl localhost:8080/healthz returns {"status":"ok"}.tests/shipments.test.js exactly as shown above.npm test reports 3 passing, 0 failing.eslint.config.js and the Dockerfilenpm run lint exits clean and docker build -t parcel-api:local . succeeds locally..github/workflows/ci.yml with the build-and-test jobmain and watch the first run go greengit push -u origin main, then gh run watch.build-and-test passing on main.build-and-test requiredgh api --method PUT .../protection command exactly as shown above.main protected with that check required.git checkout -b add-version-to-healthz, commit, push, gh pr create --fill --base main.gh pr checks shows build-and-test running or passed, visible directly on the PR.gh pr merge.build-and-test is red.git revert HEAD --no-edit, push, gh pr checks --watch, then gh pr merge --squash --delete-branch.maingh run list --branch main --limit 1, then attempt the direct-push sequence shown above.git reset --hard origin/main cleans up locally.main protected, build-and-test required and green, a Docker image builds locally tagged by commit SHA, no resource on main that skipped review.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
- 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.contextscan name it. Pushing the scaffold tomaindirectly gives thebuild-and-testcontext something to point at; branch protection goes on immediately afterward. 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 againstmain, which is what branch protection actually reads. Push alone never populates a PR's own checks list the way reviewers expect to see it.- The job's name in the workflow file,
build-and-test, has to exactly equal the string inrequired_status_checks.contexts— not the workflow's top-levelname: 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. - That the gate applies to every ref update to
main, not just to pull requests — including a push from the repo owner withenforce_admins: trueset. 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.