Stage 7 · Master
Phase 2 — Organization Service
CI/CD
Automate everything the last nineteen lessons required a human to run by hand — four jobs, one native Postgres service container, and a secrets gate that only opens on main.
Four Jobs, Each With a Single, Nameable Failure Reason
A single job running lint, then unit tests, then integration tests, then a Docker build in sequence would make every failure report the same way — 'CI failed' — regardless of which of four completely different problems caused it. Four separate jobs mean a golangci-lint violation, a failing unit test, a broken integration test, and a Docker build failure each produce their own distinct red X in the same pull request, readable at a glance.
GitHub Actions' Native Service Containers, Deliberately Different From Testcontainers
The Integration Testing lesson used Testcontainers-go so a developer's laptop can run the exact same test suite without any CI-specific configuration. This workflow instead uses GitHub Actions' own services: block, which starts a Postgres container managed directly by the runner, reachable at localhost, before any step runs — a technique specific to this CI platform, worth knowing separately from the portable Testcontainers approach used everywhere else in this course.
name: organization-service-ci
on:
pull_request:
paths:
- "services/organization/**"
- "pkg/**"
- "go.work"
- "go.work.sum"
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- run: go install github.com/golangci-lint/cmd/golangci-lint@v1.61.0
- run: golangci-lint run --timeout=5m
working-directory: services/organization
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- run: go test -C services/organization ./... -v
integration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: hoa
POSTGRES_PASSWORD: hoa_ci_password
POSTGRES_DB: hoa_organization_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U hoa"
--health-interval=5s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- run: go install -tags 'pgx5' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
- run: migrate -path services/organization/migrations -database "pgx5://hoa:hoa_ci_password@localhost:5432/hoa_organization_test?sslmode=disable" up
- run: go test -C services/organization ./... -tags=integration -v
env:
ORGANIZATION_DATABASE_URL: postgres://hoa:hoa_ci_password@localhost:5432/hoa_organization_test?sslmode=disable
build-image:
runs-on: ubuntu-latest
needs: [lint, unit-test, integration-test]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: services/organization/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.ref == 'refs/heads/main' }}
tags: ghcr.io/hoa-platform/organization:${{ github.sha }}
integration-test's Postgres service container starts and reports healthy before any step in that job runs, thanks to the health-cmd options — no application-level retry loop is needed to wait for it, unlike a locally-run docker compose up in earlier lessons.
Why push Is Conditioned on github.ref, Not Just on the Job Succeeding
build-image runs on every pull request, exercising the full multi-arch Buildx path so a broken Dockerfile is caught before merge — but docker/login-action and the actual push are both gated behind github.ref == 'refs/heads/main'. A pull request from an external contributor should never be able to trigger a push to this platform's container registry, regardless of whether the build itself succeeds; only a merge to main, where secrets.GITHUB_TOKEN is available with appropriate scope, does.
Without the on.pull_request.paths filter, this entire workflow — including the multi-arch Docker build — would run on every pull request to this repository, even one that only touches an unrelated frontend file. Scoping paths to services/organization/, pkg/, and the two go.work files means a documentation-only or frontend-only PR never spends CI minutes rebuilding a Go service it didn't touch.
Validating the Workflow File Without Waiting for a Real PR
Applied exercise
Trigger the lint job's failure path deliberately, then confirm the other three jobs still ran
Confirm the four jobs really are independent, as the lesson claims, rather than one silently gating the others.
- On a disposable local branch, introduce one golangci-lint violation into services/organization (for example, an unused local variable) and push it.
- Observe, in the resulting pull request's checks, that lint fails while unit-test and integration-test still run and report their own independent pass/fail status.
- Confirm build-image does not run at all, since it declares needs: [lint, unit-test, integration-test] and lint failed.
- Revert the introduced violation.
Deliverable
A screenshot or short transcript of the four jobs' independent statuses on the same pull request, showing lint failed while the other two ran, and build-image was skipped.
Completion checks
- unit-test and integration-test show their own real pass/fail result, independent of lint's failure.
- build-image shows as skipped, not failed, confirming the needs: dependency behaved as expected.
Why is docker/login-action's execution conditioned on github.ref == 'refs/heads/main' instead of running on every pull request?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.