The workflow, top half
One file, .github/workflows/ship.yml, and notesy tests, builds, ships, and deploys itself on every merge. First the CI half, straight from unit 7:
name: ship
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest| Choice | Lesson |
|---|---|
triggers on pushes to main and on pull requests | 7-2 |
| each step gates the next through exit codes | 7-1 |
| cheap steps before expensive ones | 7-3 |
Running on pull requests as well as main is what catches broken code before a merge rather than after. The bottom half adds the shipping stages.
The workflow, bottom half
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: docker login ghcr.io -u ada -p "${{ secrets.REGISTRY_TOKEN }}"
- run: docker build -t ghcr.io/ada/notesy:${{ github.sha }} .
- run: docker push ghcr.io/ada/notesy:${{ github.sha }}
- run: ./scripts/deploy.sh ${{ github.sha }}| Line | Role | Lesson |
|---|---|---|
needs: test | waits for the test job to pass | 8-1 |
if: github.ref == ... | pull requests get tested, only main deploys | 7-2 |
environment: production | environment-scoped variables | 8-2 |
secrets.REGISTRY_TOKEN | the encrypted registry credential | 8-2 |
| tagging with the commit sha | traceable images | 7-3 |
| pushing to GHCR | the CI-to-server hand-off | 6-1 |
deploy.sh connects to the lesson 9-1 VM and runs docker compose pull && docker compose up -d.
The rollback plan is already tested and ready: rerun deploy.sh with the previous sha, exactly as lesson 8-3 described.
The whole pipeline as a shell simulation
A step function reporting a stage and its outcome, where [ "$2" = "ok" ] makes the function's exit code match its report.
step() { echo "$1: $2" [ "$2" = "ok" ] } ( set -e step "checkout" "ok" step "test" "fail" step "build image" "ok" ) if [ $? -eq 0 ]; then echo "pipeline succeeded" else echo "pipeline failed, deploy skipped" fi
Output
checkout: ok test: fail pipeline failed, deploy skipped
| Stage | Reported | Ran |
|---|---|---|
| checkout | ok | yes |
| test | fail | yes, and aborted the run |
| build image | never reached | no |
The test stage fails, so set -e kills the run before anything ships. That is the entire value of the arrangement, since a failing test cannot produce a deployed image.
The five-stage pipeline, all green
With the tests fixed, every stage reports ok and the pipeline extends to five stages in order: checkout, test, build image, push image, and deploy.
step() { echo "$1: $2" [ "$2" = "ok" ] } ( set -e step "checkout" "ok" step "test" "ok" step "build image" "ok" step "push image" "ok" step "deploy" "ok" ) if [ $? -eq 0 ]; then echo "pipeline succeeded" else echo "pipeline failed, deploy skipped" fi
Output
checkout: ok test: ok build image: ok push image: ok deploy: ok pipeline succeeded
| Stage | Real-world command |
|---|---|
| checkout | actions/checkout@v4 |
| test | pytest |
| build image | docker build |
| push image | docker push |
| deploy | ./scripts/deploy.sh |
All five stages stay inside the ( set -e ... ) subshell, so a failure anywhere would still stop the run. The ordering is the fail-fast rule from lesson 7-3, with the cheapest checks in front of the expensive image work.
A pull request with failing tests
Walking the ship.yml logic, the test job fails on the pull request, the deploy job never runs, and production is untouched.
pull_request triggers the test job, which goes red. The deploy job requires needs: test to pass and github.ref to be main, and a pull request satisfies neither condition.
| Gate | Satisfied by a failing PR |
|---|---|
needs: test | no, the tests failed |
if: github.ref == main | no, it is a branch |
The author sees the red ✗, fixes the code, and only a green merge to main can ship. That is the entire safety model working as designed, with two independent gates rather than one.
The rollback command for notesy
The command is ./scripts/deploy.sh 41a9c02, since the deploy script takes a commit sha as its argument.
Every commit's image is still in GHCR under its sha tag, so rolling back is just deploying the old tag. The server pulls ghcr.io/ada/notesy:41a9c02 and compose swaps the containers in seconds.
| Action | Time | Lesson |
|---|---|---|
| deploy the previous sha | seconds | 8-3 |
| rebuild from a revert commit | minutes | avoid during an incident |
After service is restored, the bug gets fixed calmly, merged, and the pipeline ships the repair through the same path.
That closes the course. You have containerized an app, wired it to a database, pushed images to a registry, built a pipeline that tests and ships them, and rehearsed the recovery when a deploy goes wrong.