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
mainbranch - 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_ARNfor the role the workflow assumesAWS_REGIONfor the region the stack runs inINSTANCE_IDfor 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.