Skip to content

Assignment 3: Deployment and CI/CD

Possible Points Due Date
50 pts Wednesday, September 30th - 11:59pm

Overview

Getting code to run on your own machine is the easy half. This assignment is about the other half: getting it onto a public URL, automatically, with something standing between a broken commit and your users.

After this assignment you should be able to:

  • Deploy a Node service to a cloud provider and reach it from anywhere
  • Read a CI workflow and say exactly what it runs and when
  • Explain the difference between continuous integration, delivery, and deployment
  • Configure a deployment as code, in a reviewable file, rather than by clicking in a dashboard
  • Verify that a deploy landed, rather than assuming it did

You will work on Jokebox, a small joke API with a web UI. The app itself is deliberately simple. The pipeline around it is the point.

Using AI Coding Assistants

You are welcome to use AI assistants on this assignment. If you do, you must record it: add an entry to your Collaboration Log noting which tool you used and what for. Review the full Policy on the Use of AI Coding Tools before you start. You are responsible for anything an AI tool produces, so read and verify it.

Prerequisites

  • Node.js 24 or newer (node --version to check)
  • A GitHub account, already authenticated for git on your machine
  • A free Render account, which you will create during the tutorial
  • The Git workflow from Assignment 1: branch, commit, pull request, merge

Getting your repository

Your repository comes from UCF Code Classroom, the same app you used for Assignments 1 and 2. You are already enrolled, so there is no new link to accept: sign in, open Assignment 3, and your repository will be there.

This is an individual assignment. Everyone deploys their own instance.

If your repository is not there

Post on Ed Discussions. Do not create your own repository or fork Jokebox yourself - Code Classroom collects the repository it created for you, and work anywhere else will not be picked up.

Once you have it, get it running:

git clone https://github.com/UCF-CEN-5016/<your-assignment-3-repo>.git
cd <your-assignment-3-repo>
npm ci
npm run dev

Open the URL it prints. You should see the Jokebox UI, and be able to pull up a joke and reveal its punchline.

npm ci, not npm install

npm ci installs exactly the versions pinned in package-lock.json. That is what CI runs and what Render runs, so it is the only way to be sure that "it works on my machine" tells you anything about whether it will work anywhere else. Read README.md in the repository for a tour of the layout.

Some Definitions

Continuous Integration (CI) is the practice of automatically building and testing the codebase every time a change is proposed, so that a change which breaks something is caught at the moment it is introduced rather than weeks later.

Continuous Delivery means every change that passes CI is ready to release - the release itself is still a human decision.

Continuous Deployment removes that last manual step: anything that passes the pipeline goes to production automatically.

The distinction people usually blur is the one between delivery and deployment, and it is worth keeping straight: one ends with a button somebody chooses to press, the other has no button. By the end of Part 2 you will be running continuous deployment, which is why Part 4 asks you to prove the gate in front of it actually works.

If you want to read further, GitLab's CI/CD overview is a good summary.

Part 1: Run it locally and read the pipeline (5 pts)

Before deploying anything, get the project green on your own machine and work out what the automation already does.

npm test        # the full suite
npm run lint    # ESLint

Both should pass on a fresh clone. Then open .github/workflows/ci.yml and read it. You do not need to change it yet, but you should be able to answer: what events trigger it, what commands does it run, and what happens to a pull request whose tests fail?

Deliverable: a screenshot of npm test passing locally.

Part 2: Deploy to Render (15 pts)

Follow the deployment tutorial. It walks you through creating a Render account, deploying Jokebox from your repository, and turning on the setting that makes Render wait for CI before it ships anything.

When you are done you should have a public URL serving Jokebox, and https://<your-app>.onrender.com/healthz should return JSON reporting "status": "ok" along with the commit it was built from.

Deliverables: your live URL, and a screenshot of /healthz in a browser.

Part 3: Ship a feature through the pipeline (15 pts)

Now use the pipeline for what it is for. The UI has a category dropdown that does not filter anything yet - the API has no way to ask for a random joke from one category. You are going to add it.

How the app fits together

Not everyone in this class has built a web application before, so before you change anything, here is the shape of the thing.

There are two programs, not one. A browser on someone's laptop draws the page; a server somewhere else holds the data and answers questions about it. They are separate programs, possibly thousands of miles apart, and they communicate only by sending messages over HTTP. Every time you press "New joke", this round trip happens:

%% Shapes are left transparent on purpose. Material renders mermaid into a
%% closed shadow root and colours the label text from the active palette, so a
%% fixed fill would end up pale-on-pale in dark mode. Letting the page show
%% through means the text always sits on the page's own background, in both
%% themes and on GitHub.
flowchart TB
    subgraph browser["BROWSER"]
        UI["public/index.html + styles.css<br>structure and styling"]
        MAIN["public/main.js<br>asks for jokes, puts them on the page"]
    end

    subgraph server["SERVER - Node + Express"]
        APP["src/app.js<br>matches the URL to a handler"]
        ROUTES["src/routes/jokes.js<br>reads the request, picks a status code"]
        SVC["src/services/jokeService.js<br>the logic: pick, filter, find"]
        DATA[("src/data/jokes.json")]
        APP --> ROUTES --> SVC --> DATA
    end

    MAIN -- "(1) GET /api/jokes/random" --> APP
    SVC -. "(2) the joke, as JSON" .-> MAIN

    classDef file fill:transparent,stroke:#b08d1e,stroke-width:1.5px
    class UI,MAIN,APP,ROUTES,SVC,DATA file

    style browser fill:transparent,stroke:#9aa0ae,stroke-dasharray:4 3
    style server  fill:transparent,stroke:#9aa0ae,stroke-dasharray:4 3
  1. public/main.js, running in the browser, calls fetch("/api/jokes/random"), which sends an HTTP request across the network.
  2. On the server, app.js looks at the URL and hands the request to the matching handler in src/routes/jokes.js. A handler's job is deliberately narrow: read the request, call something, pick a status code.
  3. That handler calls randomJoke() in src/services/jokeService.js, which does the real work against src/data/jokes.json.
  4. The joke travels back as JSON - just text, shaped like {"setup": "...", "punchline": "..."} - and main.js puts it into the page.

The browser never touches jokes.json. It only ever sees what the API chose to put in the reply, which is exactly why GET /api/jokes can withhold punchlines: they are simply never sent.

You can watch the server half by itself, with no browser involved at all:

curl http://localhost:3000/api/jokes/random

curl is just a browser with no opinions about drawing. If that returns a joke, the server works, and anything wrong is in the browser half.

Why the server code is split into routes, services, and data

It would all fit in one file. It is split because the pieces have different jobs and very different testability.

src/services/ holds plain functions over plain data - no req, no res, no Express. You can call them directly, which is why the unit tests in tests/jokeService.test.js need no server and run in milliseconds. src/routes/ is the thin HTTP skin over those functions. And src/app.js never calls listen() - only src/server.js does - which is what lets the API tests import the app and drive it in-process without binding a port.

When you add the filter below, notice that the change lands in three different layers, and that each layer stays responsible for its own job.

What to change

Your change touches three of those layers: the service already supports it, the route needs to pass it through, and the browser needs to ask for it.

Work on a branch, open a pull request, let CI run, and merge only once it is green.

  1. Service layer. randomJoke() in src/services/jokeService.js already accepts a category option. Read it and confirm you understand what it does when the category matches nothing.
  2. Route. Change GET /api/jokes/random in src/routes/jokes.js to read an optional category query parameter and pass it through. If a category is supplied and matches no jokes, respond 404 with a JSON error, rather than returning an empty body.
  3. UI. In public/main.js, find the TODO(assignment) comment in loadJoke() and send the selected category through as a query parameter. Selecting a category should now change which jokes appear.
  4. Tests. Add at least two tests in tests/routes/jokes.test.js: one that /api/jokes/random?category=tech only ever returns a tech joke, and one that an unknown category returns 404.

Your change must pass npm run lint and npm test locally before you push.

Query parameters in Express

req.query.category holds the value of ?category=..., and is undefined when the caller did not supply one. Passing undefined through to randomJoke() gets you the unfiltered behaviour for free - which is the reason the option was written to be optional.

When does the change show up on Render?

Only after the merge. Render is connected to your main branch and nothing else, so commits on a pull-request branch are never deployed, even once CI is green on them. When you merge, the push trigger in ci.yml runs lint and tests on main, and Render waits for that run to pass before it builds and deploys. Until then your live app is still running the Part 2 code.

Repeats are fine

randomJoke() picks uniformly at random, so pressing "New joke" may show the same joke twice in a row, and with a category selected this will happen more often because the pool is smaller. That is the expected behaviour and you do not need to prevent it.

Deliverables: the merged pull request, and your deployed app filtering by category.

Part 4: Prove the gate actually works (15 pts)

A deployment gate you have never seen stop anything is a gate you are merely hoping is connected. In this part you break the build on purpose and watch what the pipeline does about it.

  1. Break something on a branch. Create a branch called break-the-build. Change the application code so that an existing test fails - for example, make GET /api/jokes leak punchlines, or have /healthz report something other than ok.

    Break the code, not the test

    Change application code so a test legitimately fails. Editing the test to make it fail, or deleting one, does not demonstrate anything and gets no credit.

  2. Open a pull request from that branch into main. Do not merge it.

  3. Watch CI fail. Screenshot the pull request showing the failed check, and open the failing run and read the log until you can say which assertion failed and why.
  4. Confirm nothing deployed. Check your Render dashboard and your live /healthz. The commit it reports should still be the one from Part 3. This is the gate doing its job: a broken commit exists on GitHub and never reached your users.

    Which gate are you actually testing?

    Two things stand between this commit and your users, and it is worth keeping them separate. The first is the CI check on the pull request itself: the red X that tells anyone looking at the PR not to merge it. That is the gate this part has you watch directly. The second is Render's After CI Checks Pass setting from Part 2, which only comes into play if a broken commit does land on main, for example through a direct push or because somebody merged a red PR anyway. Your PR never touches main, so this part does not trip that second gate, and you should not try to: putting broken code on main on purpose is exactly the habit the pipeline exists to break. Checking /healthz confirms the outcome both gates are there to guarantee.

    1. Close the pull request without merging, and delete the branch.
    2. Write it up. Add a file REFLECTION.md to your repository, 200-400 words, answering:
    3. What failed, and how did you work that out from the CI log?
    4. What would have reached your users if main deployed on every push with no checks?
    5. Jokebox deploys automatically once CI is green. Name one change you would not want shipped that way, and say what you would add to the pipeline to catch it.

Deliverables: the screenshot of the failed check, the screenshot or JSON of /healthz still on the old commit, and REFLECTION.md committed to main.

Extra Credit (5 pts)

Add a post-deploy smoke test. The repository ships scripts/smoke.js, which takes a base URL, checks that a deployed instance is really serving, and exits non-zero if it is not:

npm run smoke -- https://your-app.onrender.com

For the extra credit, add a GitHub Actions workflow that runs it against your live URL on a schedule (say, daily) and fails if the app is down. Put the URL in a repository variable rather than hardcoding it.

This is the piece Parts 1-4 leave out. CI proves the code was good when it was merged; only something that talks to the running deployment can tell you the deployment is still up.

Scheduled run failing on a cold start? Run git pull

A scheduled run nearly always finds a free Render app asleep, and the original smoke.js failed on the first response from a sleeping service. The current version waits up to three minutes for the app to wake before checking it.

On 25 September the fix was pushed to main in every Assignment 3 repository, as a commit that changes only scripts/smoke.js. It did not redeploy your app, so /healthz still reports your own commit. Pull it before your next push, or git push will be rejected:

git pull --no-rebase

--no-rebase merges it with any commits you have not pushed yet, rather than stopping to ask how to combine them.

Turning in the Assignment & Grading Rubric

Submit on Webcourses:

  • The URL of your deployed app
  • The URL of your GitHub repository
  • Screenshot: npm test passing locally (Part 1)
  • Screenshot: /healthz on your deployed app (Part 2)
  • Screenshot: the failed CI check on your break-the-build pull request (Part 4)
  • Screenshot or JSON: /healthz still reporting the pre-break commit (Part 4)
  • Your AI collaboration log, if you used AI tools

Everything else is read from your repository, so make sure your Part 3 work is merged into main and REFLECTION.md is committed before the deadline.

The grading for this assignment breaks down as follows:

  • Part 1: Running Locally & Reading the Pipeline - (5 points)
  • Part 2: Deploying to Render - (15 points)
  • Part 3: Shipping a Feature Through the Pipeline - (15 points)
  • Part 4: Proving the Gate Works - (15 points)
  • Extra Credit: Scheduled Smoke Test - (5 points)

Render's free tier sleeps

A free Render service spins down after about 15 minutes of inactivity, and the next request has to wait for it to wake - which can take the better part of a minute. A slow first response is expected and is not a broken deploy. Give it a moment before you conclude something is wrong, and mention it if you show your app to someone else.