CS375: Software Engineering - Continuous Integration and Continuous Deployment (CI/CD)

Activity Goals

The goals of this activity are:
  1. To explain the purpose of Continuous Integration (CI) and Continuous Deployment (CD) in a software team workflow
  2. To create a GitHub Actions workflow that automatically runs a test suite on every push
  3. To write and explain a Dockerfile that packages an application and its dependencies into a portable image
  4. To describe a deployment pipeline that builds and publishes a container image when changes are merged

Supplemental Reading

Feel free to visit these resources for supplemental background reading material.

The Activity

Directions

Consider the activity models and answer the questions provided. First reflect on these questions on your own briefly, before discussing and comparing your thoughts with your group. Appoint one member of your group to discuss your findings with the class, and the rest of the group should help that member prepare their response. Answer each question individually from the activity, and compare with your group to prepare for our whole-class discussion. After class, think about the questions in the reflective prompt and respond to those individually in your notebook. Report out on areas of disagreement or items for which you and your group identified alternative approaches. Write down and report out questions you encountered along the way for group discussion.

Model 1: A Continuous Integration Workflow with GitHub Actions

name: Python package

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.9"]
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install pytest pytest-cov
          if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
      - name: Test with pytest
        run: |
          pytest --cov

Questions

  1. This file lives at .github/workflows/automatedtesting.yaml in the repository. What event causes this workflow to run? How often would that happen on a four-person team?
  2. Read the steps list from top to bottom, and describe in your own words what each step does. Why must actions/checkout come first?
  3. What happens to a pull request if the pytest step fails? Why is it valuable to know this before merging, rather than after?
  4. The workflow runs on a fresh ubuntu-latest virtual machine every time. Why is it a feature that nothing is left over from the previous run? What kinds of "works on my machine" bugs does this catch?

Model 2: Packaging an Application with a Dockerfile

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Questions

  1. Each line of a Dockerfile creates a layer. Describe what each of the seven lines above contributes to the final image.
  2. Why do we COPY package*.json and run npm install before copying the rest of the source code? (Hint: Docker caches layers, and re-uses a cached layer if its inputs have not changed.)
  3. What is the difference between EXPOSE 3000 and actually publishing the port with docker run -p 3000:3000?
  4. How does shipping this image differ from shipping a README that says "install node 22, then run npm install"? Which is more reproducible, and why?

Model 3: The Pipeline: from Commit to Deployment

StageTriggerWhat HappensWho Notices If It Fails
1. Continuous IntegrationEvery push and pull requestCheckout, build, run unit tests and coverageThe author, within minutes, via a red X on the commit
2. Continuous DeliveryMerge to the main branchBuild the Docker image and push it to a registry (for example, the GitHub Container Registry ghcr.io)The team: the latest image is the release candidate
3. Continuous DeploymentSuccessful image publishThe hosting environment pulls and runs the new image (a cloud host, a lab server, or a container platform)The users -- which is why stages 1 and 2 gate this one!

Questions

  1. In your own words, what is the difference between continuous integration, continuous delivery, and continuous deployment?
  2. Why does each stage gate the next one? What could go wrong if a team deployed on every push without running the tests first?
  3. Suppose a bug reaches production anyway. How does this pipeline help you roll back quickly? (Hint: every merge produced a tagged image.)

Why CI/CD?

On a solo project, you know exactly what state your code is in, because you are the only one changing it. On a team, four people are pushing changes to the same repository every day, and any one of those changes can break something another person depends on. Continuous Integration (CI) is the practice of merging everyone’s work frequently – at least daily – and having a robot build the project and run the full test suite on every single push, so that a breaking change is caught within minutes of being written, while it is still fresh in its author’s mind and cheap to fix. Continuous Deployment (CD) extends the same idea to releasing: once the tests pass on the main branch, a machine (not a stressed-out human at midnight) packages and ships the new version automatically. This is how real teams ship software many times per day with confidence: not by being more careful than you, but by automating the checking.

We will build up a pipeline in three small steps: first automated testing (CI), then packaging with Docker, then automated delivery (CD).

Step 1: Continuous Integration with GitHub Actions

GitHub Actions is GitHub’s built-in automation service. You describe a workflow in a YAML file inside your repository under .github/workflows/, and GitHub runs it for you on a fresh virtual machine whenever the triggering event occurs. You do not need to install anything: if the file exists in your repository, it runs.

To add CI to a Python project:

  1. In your repository, create the directory .github/workflows/ (note the leading dot).
  2. Add a file named automatedtesting.yaml containing the workflow from the first model above.
  3. Commit and push. Then click the Actions tab on GitHub: you will see your workflow running. A green check mark means every step succeeded; a red X means a step failed, and clicking it shows you the console output of the failing step – exactly as if you had run pytest yourself.
  4. Now the fun part: push a commit that breaks a test on purpose. Watch the red X appear on the commit (and on any pull request containing it). This is your safety net.

The complete working example below contains a small Python program, a pytest test suite, and the workflow file wired together; browse the code and note where each piece lives in the directory tree.

Example: GitHub Automated Testing Workflow Example

For other languages (node.js, Java, and others), GitHub provides starter workflows: see the “Building and Testing” link in the readings, or click Actions, then New workflow in your repository and GitHub will suggest one that matches your project.

Step 2: Packaging with Docker

CI proves your code works on a clean machine. Docker takes the next step: it packages your application together with the exact operating system libraries, language runtime, and dependencies it needs into an image, so that the thing you tested is byte-for-byte the thing you deploy. A running instance of an image is called a container – think of the image as a class and the container as an object instance.

Work through the Dockerfile in the second model above, one layer at a time:

Line What it does
FROM node:22-slim Start from a published base image that already contains node.js 22 on a minimal Linux.
WORKDIR /app All subsequent commands run inside /app in the image.
COPY package*.json ./ Copy only the dependency manifest first…
RUN npm install …so that this slow step is cached and re-run only when the manifest changes.
COPY . . Now copy the application source code.
EXPOSE 3000 Document that the app listens on port 3000.
CMD ["node", "server.js"] The command that runs when a container starts.

Try it (Docker Desktop is a free install on Windows/macOS/Linux):

docker build -t myapp .        # build the image from the Dockerfile
docker run -p 3000:3000 myapp  # run it, publishing container port 3000 to your machine

Then browse to http://localhost:3000. The application is running in an isolated container: it does not matter what versions of node (or anything else) are installed on your laptop, and the identical image will behave identically on a teammate’s laptop or a cloud server. (The DatabaseMVCExample from the MVC activity ships with a Dockerfile and docker-compose.yml you can experiment with.) The Containers activity goes deeper on Docker as a shared development and testing environment – sharing folders with volumes, running a whole stack with docker-compose, and isolation for safety.

Step 3: Continuous Deployment

The last stage connects the first two: when a pull request is merged to main (and only after the CI tests pass), a second workflow builds the Docker image and pushes it to a container registry – a package repository for images, such as the GitHub Container Registry (ghcr.io) that comes free with your repository. A minimal deployment workflow looks like this:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v3
      - name: Log in to the GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push the image
        run: |
          docker build -t ghcr.io/${{ github.repository }}:latest .
          docker push ghcr.io/${{ github.repository }}:latest

From there, where the container actually runs is a configuration detail that varies by team: a cloud provider (AWS, Azure, Google Cloud, Render, Fly.io), a department server, or an orchestrator like Kubernetes simply pulls the newest image from the registry and restarts the container. The essential idea is the same everywhere: deployment is a pull of an already-tested artifact, not a rebuild from source on the production machine. Pushing an image to a registry is only one way to ship; the Publishing and Deployment activity compares it with publishing a package to npm, deploying a serverless function with Cloudflare Wrangler, and hosting a static site on GitHub Pages.

Notice how the three stages form a chain of gates: a change cannot be deployed unless it was merged, it should not be merged unless CI is green, and CI is green only if the tests pass. Your test plan is what gives those gates their meaning – which is why your project test plan requires a passing CI workflow.

Your Turn

  1. Add the CI workflow from Step 1 to your project repository (adapting it to your project’s language), push a commit, and confirm a green check appears under the Actions tab.
  2. Break a test on purpose, push, and take a screenshot of the red X. Then fix it. You have just experienced the entire point of CI.
  3. Write a Dockerfile for your project, build the image, and run it locally with docker run.
  4. (Challenge) Add the deployment workflow from Step 3 and confirm your image appears under Packages on your repository page.

Submission

I encourage you to submit your answers to the questions (and ask your own questions!) using the Class Activity Questions discussion board. You may also respond to questions or comments made by others, or ask follow-up questions there. Answer any reflective prompt questions in the Reflective Journal section of your OneNote Classroom personal section. You can find the link to the class notebook on the syllabus.