Start smaller than you think
My first pipeline tried to do everything at once: test, build, scan, deploy, and notify. When it failed, I could not tell which part was at fault. I deleted it and started again with four steps I could explain out loud.
- Check out and set up a pinned Go version.
- Lint and test the service.
- Build and push a container image tagged with the commit SHA.
- Deploy a chosen image tag by hand.
That is the whole pipeline. It is not clever, but I can read it end to end and fix it when it breaks.
name: build
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
- run: go vet ./...
- run: go test ./...
image:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}Things that made it more reliable
- Pin the runner image and tool versions. An unpinned toolchain turns a working pipeline into a moving target.
- Tag images with the commit SHA.
latestis convenient until you need to know what is actually running. - Keep the deploy manual at first. A button I press after reading the diff is slower, but it taught me what the pipeline was doing.
- Fail early. If
go vetfails, there is no reason to build an image.
What I would add next
A smoke test that starts the built image and requests its health endpoint would catch the embarrassing case where the build passes and the binary cannot start. After that, a small deployment job that runs only on a tag, so releases are still deliberate.