3/18 CI/CD with GitHub Actions

GitHub Classroom: https://classroom.github.com/a/OMa5YD8Y

This is a standalone lab.

In this lab, we’ll build a complete CI/CD pipeline for a simple FastAPI application using GitHub Actions. When you open a pull request, GitHub Actions will automatically run your tests, build your Docker image, and report the results back as a comment. When you merge to main, it will build and push your Docker image to Amazon ECR, making it available for deployment anywhere (which makes this a Continuous Delivery instead of Continuous Deployment).

By the end of this lab, you’ll have a better understanding of how industry teams automate their software delivery pipelines starting from pull requests to deployment ready images.

Prerequisites

You’ll need an AWS account with CLI access configured (you should already have this from previous labs).

Provided Code

Take a look through the provided code:

cicd-lab/
├── app/
│   ├── __init__.py
│   └── main.py          # FastAPI application
├── tests/
│   ├── __init__.py
│   └── test_main.py     # Pytest tests
├── Dockerfile           # Container definition
├── pyproject.toml       # Python dependencies (uv)
└── README.md

This should be a familiar setup that uses uv and Docker. The FastAPI application we’ve provided is a simple “echo” webserver. It has two endpoints GET /echo and GET /echo/<name> that return Hello, World! or Hello, <name>!. Importantly, we’ve included a new folder tests/ that implements unit tests using pytest. These will be used for our CI.

Understanding the CI/CD Workflow

Before we dive into implementation, let’s understand what we’re building:

  %%{init: {'theme':'dark'}}%%
graph LR
    A[Developer pushes code] --> B[GitHub detects change]
    B --> C{What changed?}
    C -->|Pull Request| D[CI Workflow]
    C -->|Main Branch| E[CD Workflow]

Continuous Integration (CI) runs on every pull request:

  • Runs tests
  • Reports results back to the PR

Continuous Deployment (CD) runs when code merges to main, which should effectively only ever be after a PR lands:

  • Runs tests
  • Builds the Docker image
  • Pushes the image to Amazon ECR

Part 1: Testing Locally

Before we automate anything, let’s verify our application works locally.

# build the docker image
docker build -t echo-api:local .
# run the container
docker run -d -p 8000:8000 --name echo-api echo-api:local
# test the endpoints
curl http://localhost:8000/echo
curl http://localhost:8000/echo/Student

You should see the output:

{"message":"Hello, World!"}
{"message":"Hello, Student!"}

Run the tests:

Now, let’s run the pytest suite. First, install dependencies locally:

uv sync

Then run pytest:

uv run pytest -v

Pytest will automatically look for any tests in our directory and execute them. The -v is simply to print verbose output. You can read more about pytest at its documentation if you’re interested. You should see something like:

tests/test_main.py::test_echo PASSED
tests/test_main.py::test_echo_name PASSED

====== 2 passed in 0.24s ======

Clean up:

docker stop echo-api
docker rm echo-api

Great! Now that we know everything works locally, let’s automate this with GitHub Actions.

Part 2: Creating the CI Workflow

GitHub Actions workflows are defined in YML files under .github/workflows/. This is the beauty of GitHub Actions, we don’t need to spin up any additional infrastructure to run our CI/CD pipelines, GitHub handles all of it for us automatically based on the files we define! Otherwise, you would need to spin up various pieces of infrastructure, such as nodes to watch the repository and worker nodes to execute each step in the pipelines.

Let’s create our first workflow. First, create the github workflow directory:

mkdir -p .github/workflows

Then, create a file .github/workflows/ci.yml and put the following inside:

name: CI - Pull Request Checks

on:
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Install project
        run: uv sync --locked --all-extras --dev

      - name: Run unit tests
        run: uv run pytest -v

Let’s break down what this workflow does:

Trigger: This is the on: object. Runs automatically when a pull request targets the main branch.

Job: Defines a single job test:. Runs on GitHub’s Ubuntu runners (free for public repos). This job has multiple steps:

  1. Checkout code: Gets your repository code using an action provided by GitHub
  2. Install uv: Installs uv using an action provided by Astral
  3. Install project: Runs uv sync to install dependencies for our project
  4. Run unit tests: Runs pytest to execute unit tests

If you’re interested, the workflow syntax documentation may be helpful in understanding what’s happening exactly as well as what is possible.

Commit and push this workflow:

git add .github/workflows/ci.yml
git commit -m "Add CI workflow for pull requests"
git push origin main

By pushing this to our repository, we’ve effectively deployed our GitHub workflow!

Part 3: Testing the CI Workflow

Now let’s see our CI in action. We’ll create a pull request and watch GitHub Actions work.

Create a new branch:

git checkout -b add-health-endpoint

Add a health check endpoint to app/main.py:

@app.get("/health")
def health():
    return {"status": "healthy"}

Add a test for the new endpoint in tests/test_main.py:

def test_health():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "healthy"}

Commit and push:

git add app/main.py tests/test_main.py
git commit -m "Add health check endpoint"
git push origin add-health-endpoint

Create a pull request:

  • Go to your repository on GitHub
  • Click “Pull requests” → “New pull request”
  • Select your add-health-endpoint branch
  • Click “Create pull request”

GitHub provides a nice UI to watch our workflow run. You should see a window in your PR that shows the status of the workflow being executed. You can click into this to get the exact logs that happened at each step of our workflow, which is great for debugging when your tests fail!

Although, wouldn’t it be even nicer to see the test results directly in the PR? Let’s add that.

Part 4: Adding PR Comments with Test Results

We’ll enhance our CI workflow to post test results as a comment on the PR. This gives reviewers immediate visibility into what passed or failed.

Update .github/workflows/ci.yml:

name: CI - Pull Request Checks

on:
  pull_request:
    branches: [ main ]

permissions:
  contents: read
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Install project
        run: uv sync --locked --all-extras --dev

      - name: Run tests and capture output
        id: pytest
        run: |
          set +e  # Don't exit on failure
          uv run pytest -v --tb=short > test_output.txt 2>&1
          TEST_EXIT_CODE=$?
          echo "exit_code=$TEST_EXIT_CODE" >> $GITHUB_OUTPUT
          cat test_output.txt
          exit $TEST_EXIT_CODE

      - name: Post test results to PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const testOutput = fs.readFileSync('test_output.txt', 'utf8');
            const exitCode = '${{ steps.pytest.outputs.exit_code }}';
            const status = exitCode === '0' ? '✅ All tests passed!' : '❌ Tests failed';

            const body = `## ${status}

            <details>
            <summary>Test Results</summary>

            \`\`\`
            ${testOutput}
            \`\`\`

            </details>`;

            github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: body
            });

What changed?

  1. Permissions: Added pull-requests: write so the workflow can comment on PRs.
  2. Run tests step: Now captures output to a file and stores the exit code.
  3. Post results step: Uses GitHub’s script action to post a formatted comment with test results.
  4. if: always(): Ensures the comment is posted even if tests fail. Otherwise, any steps after a step fails will not run.

Commit and push the update:

git checkout add-health-endpoint
git add .github/workflows/ci.yml
git commit -m "Add test results commenting to CI"
git push origin add-health-endpoint

Go back to your PR and watch the new workflow run. When it completes, you should see a comment appear with your test results!

Part 5: Setting up CD

Before we can push images to Amazon ECR, we need to create a repository and configure GitHub Actions with our AWS credentials.

Create an ECR repository:

Since everyone shares the same AWS account, include your PennKey in the repository name to avoid conflicts:

aws ecr create-repository --repository-name echo-api-<pennkey> --region us-east-1

You should see JSON output containing the repository URI. It will look something like 123456789012.dkr.ecr.us-east-1.amazonaws.com/echo-api-<pennkey>. Copy this URI — you’ll need it in the next step.

Add secrets to your GitHub repository:

  1. Go to your GitHub repo → “Settings” → “Secrets and variables” → “Actions”
  2. Click “New repository secret”
  3. Add AWS_ACCESS_KEY_ID with your AWS access key ID
  4. Add AWS_SECRET_ACCESS_KEY with your AWS secret access key
  5. Add ECR_REPOSITORY_URI with the repository URI from the previous step (e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/echo-api-<pennkey>)

Note: Using long-lived access keys as secrets is not best practice. In a production setup, you would use something like GitHub OIDC with IAM roles to avoid storing credentials entirely. We’re using access keys here for simplicity.

These secrets are encrypted and only available to your workflows. Never commit credentials to your repository!

Part 6: Creating the CD Workflow

Now let’s create a separate workflow (CD) that runs when code is merged to main. This will build and push our Docker image to Amazon ECR, which should ultimately represent a production ready image.

Create .github/workflows/cd.yml:

name: CD - Deploy to Amazon ECR

on:
  push:
    branches: [ main ]

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Install uv
        uses: astral-sh/setup-uv@v6

      - name: Install project
        run: uv sync --locked --all-extras --dev

      - name: Run unit tests
        run: uv run pytest -v

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ secrets.ECR_REPOSITORY_URI }}:latest

This workflow begins quite similarly to what we have defined for our CI workflow. Namely, we install uv and dependencies, then run tests. However, then, we additionally configure our AWS credentials, log in to ECR, and push our Docker image to the ECR repository we created. Note how we use secrets, GitHub Actions makes it easy for us to reference the secrets that we created earlier!

Note: You may have noticed that our CI workflow only runs tests but doesn’t build the Docker image, while our CD workflow does both. This is a cost/testing tradeoff. Building Docker images on every PR increases CI run time and resource usage, but it can catch Dockerfile or build issues earlier. In a production setup, you might choose to add a Docker build step to CI as well — it depends on how expensive your builds are versus how quickly you want to catch build failures.

Commit the CD workflow:

git checkout main
git add .github/workflows/cd.yml
git commit -m "Add CD workflow for ECR deployment"
git push origin main

Part 7: Watching the Full Pipeline

Now let’s see the complete CI/CD pipeline in action!

Merge your pull request:

  • Go to your PR on GitHub
  • Click “Merge pull request”
  • Confirm the merge

This will land your change onto the main branch, which should trigger the CD workflow we just created. You can go to the “Actions” tab on your repository to view the status of the job. You should see a job being executed (or already executed) that has the same name as the PR you merged. When it finishes, navigate to the Amazon ECR console and select your echo-api repository — you should now see an image there!

Recap

The life-cycle of code in this particular setup would be as follows:

  %%{init: {'theme':'dark'}}%%
sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub
    participant CI as CI Workflow
    participant CD as CD Workflow
    participant ECR as Amazon ECR
    participant Prod as Production

    Dev->>GH: Push branch & open PR
    GH->>CI: Trigger CI workflow
    CI->>CI: Run tests
    CI->>GH: Post test results as comment

    Dev->>GH: Merge PR to main
    GH->>CD: Trigger CD workflow
    CD->>CD: Run tests
    CD->>CD: Build Docker image
    CD->>ECR: Push image with tags

    Prod->>ECR: Pull latest image
    Prod->>Prod: Deploy container

Additional Resources


Build a complete CI/CD pipeline using GitHub Actions and Docker Hub

2026-03-18