Workshops ... Part 12: CI/CD with GitHub Actions

Part 12: CI/CD with GitHub Actions

We can deploy by hand, but every manual deploy is a chance to ship something that doesn't pass the tests. We finish with a pipeline that runs the tests on every push and updates the running server when they pass on main. The repo is already on GitHub from Part 1: Frontend with Lovable, so GitHub Actions is the natural home for it.

The pipeline structure

We want four jobs that run in this order:

  • Backend unit tests and frontend tests, in parallel.
  • The slower backend integration tests, only if both fast jobs pass.
  • Deploy, only if the integration tests pass and we're on main.

Ask the assistant:

Write a GitHub Actions workflow at .github/workflows/ci-cd.yml. Run backend unit
tests (uv run pytest) and frontend tests (npm test) in parallel. Then run the
backend integration tests (uv run pytest tests_integration/). On push to main, if
everything passed, deploy: authenticate to AWS with OIDC by assuming an IAM role
(no stored keys), then use SSM to rebuild and restart the app container on the EC2
instance.

The test jobs install each stack and run its tests.

The backend job shows the pattern for both:

  test-backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.14'
      - name: Install uv
        run: curl -LsSf https://astral.sh/uv/install.sh | sh
      - name: Install dependencies
        working-directory: ./backend
        run: uv sync
      - name: Run backend tests
        working-directory: ./backend
        run: uv run pytest

The frontend job mirrors it with setup-node and npm ci / npm test. The ordering between jobs is the part that matters, and we express it with needs. The integration job waits for both fast jobs, and deploy waits for integration.

  test-backend-integration:
    needs: [test-backend, test-frontend]
    # ... checkout, python, uv, then:
    # run: uv run pytest tests_integration/

  deploy:
    needs: [test-backend-integration]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

We run the cheap tests first and gate the slow ones behind them. A broken unit test then fails in under a minute instead of after the whole suite.

Deploy from the pipeline

The deploy job updates the EC2 instance we stood up in Part 11: Deploy to AWS with infrastructure as code. Because the instance has the SSM role, the pipeline doesn't need an SSH key. It still has to prove to AWS that it's allowed to send the command. We do that with OpenID Connect (OIDC) instead of stored AWS keys.

The old approach creates an IAM user, generates a long-lived access key, and pastes it into GitHub as a secret. That key sits there forever, works from anywhere, and leaks the moment a log or a fork exposes it.

With OIDC, GitHub signs a short-lived token for each workflow run and gives it to AWS. AWS trusts GitHub as an identity provider and checks that each token comes from your repository. It then exchanges the token for temporary credentials that expire when the run ends. There's no standing secret to rotate or leak.

You set this up once in IAM:

  • add GitHub's OIDC provider (token.actions.githubusercontent.com)
  • create a role whose trust policy lets your repository assume it, scoped to the main branch
  • give that role permission to call ssm:SendCommand

The workflow then requests an OIDC token and assumes that role:

  deploy:
    needs: [test-backend-integration]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # let the job request an OIDC token
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ secrets.AWS_REGION }}
      - name: Pull, rebuild, and restart on the instance
        run: |
          aws ssm send-command \
            --instance-ids ${{ secrets.INSTANCE_ID }} \
            --document-name "AWS-RunShellScript" \
            --parameters 'commands=[
              "cd /opt/snake-arena",
              "git pull",
              "docker build -f backend/Dockerfile -t snake-arena .",
              "docker stop snake-arena || true",
              "docker rm snake-arena || true",
              "docker run -d --restart always -p 80:8000 --env-file app.env --name snake-arena snake-arena"
            ]'

The permissions: id-token: write line is what lets the job request the token. Without it, the OIDC handshake fails.

The workflow now stores only non-sensitive identifiers as GitHub secrets, set under the repo's Settings → Secrets and variables → Actions:

  • AWS_DEPLOY_ROLE_ARN for the role the workflow assumes
  • AWS_REGION for the region the stack runs in
  • INSTANCE_ID for the instance the stack created

Get the instance ID from the stack:

aws cloudformation describe-stack-resources --stack-name snake-arena \
  --query "StackResources[?ResourceType=='AWS::EC2::Instance'].PhysicalResourceId"

Ship it

Commit the workflow and push:

git add .github/workflows/ci-cd.yml
git commit -m "Add CI/CD pipeline"
git push

Open the Actions tab and watch the run. On a pull request the tests run and deploy is skipped. On a push to main the tests run and then the server pulls the new code and restarts. That's the full loop: a change goes from your editor through tests to the live site without a manual deploy.

What we built, what we left out, and where to take it next is in Where to go from here.

Questions & Answers

Sign up to ask questions, track your progress, and get access to other workshops · Already have an account? Sign in