Stage 7 · Master
Phase 19 — Production Deployment
GitHub Actions
Pin every third-party action to an immutable commit, build a path-filtered matrix so eleven services don't all rebuild for a one-service change, and share steps through a reusable workflow instead of eleven near-identical copies.
Eleven Services Cannot Share One Linear Workflow File
Organization's own quality-gate.yaml, written back when it was the only service, runs lint, test, and build steps directly inline. Ten more services have since adopted a copy-pasted version of the same file, each with its own subtly drifted Go version pin and its own forgotten step. A change to organization's CI now requires remembering to make the same edit in ten other files — and it usually does not happen, which is exactly how quality-gate.yaml's action versions ended up inconsistent across services in the first place.
A Tag Reference on a Third-Party Action Is a Supply-Chain Trust Assumption
actions/checkout@v4 is a mutable tag an action's maintainer — or, in a compromised-account scenario, an attacker — can repoint to a different commit at any time, and the workflow would silently start executing different code on its next run. Pinning to the exact commit SHA a tag currently points to removes that trust assumption entirely; a comment recording which human-readable version the SHA corresponds to keeps the pin auditable.
name: service-ci
on:
workflow_call:
inputs:
service_path:
required: true
type: string
go_version:
required: true
type: string
permissions:
contents: read
jobs:
lint-and-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
with:
go-version: ${{ inputs.go_version }}
cache: true
cache-dependency-path: "${{ inputs.service_path }}/go.sum"
- name: golangci-lint
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v6.1.1
with:
working-directory: ${{ inputs.service_path }}
- name: go test (non-root runner user, race-enabled)
run: |
(cd "${{ inputs.service_path }}" && go test -race -shuffle=on -count=1 ./...)workflow_call turns the entire job block into a callable unit — every service's own workflow file becomes a one-line invocation of this file with its own service_path, instead of a full copy of these steps.
Unlike Lesson 1's Docker test stage, which had to deliberately drop root because the golang builder image starts as root, ubuntu-24.04 runners provided by GitHub already run job steps as an unprivileged runner account by default — there is nothing extra to configure here, but it is worth confirming rather than assuming, since a self-hosted runner (used later in this lesson for arm64 builds) may not have the same default.
Only Run CI for Services That Actually Changed
A pull request touching only services/gateway-service's router should not trigger a 45-minute run of all eleven services' test suites — that is minutes of CI minutes and, more importantly, minutes of feedback latency wasted on services nothing in the change could have affected.
name: ci
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
detect-changes:
runs-on: ubuntu-24.04
outputs:
services: ${{ steps.services.outputs.list }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
_shared: &shared
- 'pkg/**'
- 'go.work'
- '.github/workflows/service-ci.yaml'
organization:
- 'services/organization/**'
- *shared
user-service:
- 'services/user-service/**'
- *shared
auth-service:
- 'services/auth-service/**'
- *shared
gateway-service:
- 'services/gateway-service/**'
- *shared
flat-service:
- 'services/flat-service/**'
- *shared
resident-service:
- 'services/resident-service/**'
- *shared
maintenance-service:
- 'services/maintenance-service/**'
- *shared
payment-service:
- 'services/payment-service/**'
- *shared
complaint-service:
- 'services/complaint-service/**'
- *shared
notice-service:
- 'services/notice-service/**'
- *shared
visitor-service:
- 'services/visitor-service/**'
- *shared
notification-service:
- 'services/notification-service/**'
- *shared
staff-service:
- 'services/staff-service/**'
- *shared
- id: services
name: Drop the anchor key from the matrix input
run: |
echo "list=$(jq -cn --argjson changed '${{ steps.filter.outputs.changes }}' '$changed - ["_shared"]')" >> "$GITHUB_OUTPUT"
service-ci:
needs: detect-changes
if: needs.detect-changes.outputs.services != '[]'
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
uses: ./.github/workflows/service-ci.yaml
with:
service_path: services/${{ matrix.service }}
go_version: "1.26.x"Three things make this correct rather than merely plausible. Each filter key is spelled exactly like its directory under services/, because the matrix interpolates the key straight into service_path — a key abbreviated to user would resolve to a directory that does not exist, and the job would fail on checkout rather than on a real defect. The shared anchor is merged into every service's filter rather than standing alone: a change to pkg/ or go.work is not a service to build, it is a reason to build all of them. And because YAML anchors have to live on a real key, _shared is itself reported by paths-filter as a changed filter — so the jq step subtracts it before the matrix ever sees it. Without that subtraction, any pkg change would schedule a job for services/_shared, a directory that will never exist.
If the shared anchor were left out, a breaking change to pkg/logging could merge with an all-green PR: no service's own directory changed, so no service's suite ran. go.work belongs in that anchor for the same reason — adding a module to the workspace changes what every other module resolves against. The filter list tracks the module graph, not the folder names that happen to look self-contained.
Confirm the Filter Actually Skips Unrelated Services, Not Just That the Workflow Runs
✓ detect-changes
✓ service-ci (gateway)
(organization, user, auth CI jobs did not run — correctly skipped)Seeing exactly one matrix entry for a single-service change is the real confirmation the filter works — a workflow that runs successfully but happens to include every service every time would look identical in a naive 'did it pass' check.
Applied exercise
Onboard a newly extracted service without leaving a silent CI gap
A refactor splits reporting out of payment-service into its own module directory alongside the others. The directory is created, the module builds locally, and the pull request is green — because detect-changes has no entry for it, so no matrix job ever ran against it. Nothing failed; nothing was checked either.
- Add the filter entry for the new module, including the shared anchor so a pkg change still reaches it.
- Push a change touching only that module and confirm exactly one matrix job runs, named after its directory.
- Push a change touching only pkg and confirm every service's job runs, the new one included.
- Deliberately misspell the entry key (report instead of report-service) and record what the matrix job does when service_path resolves to a directory that does not exist.
Deliverable
An updated filter list, three workflow runs (scoped, platform-wide, and the deliberately misspelled one), and a note on which failure mode each run demonstrates.
Completion checks
- The module-only change triggers exactly its own matrix job and no others.
- The pkg change triggers every service's matrix job, the new one included.
- The misspelled-key run fails at checkout rather than passing silently, and the note explains why a loud failure is the better of the two outcomes.
Why does this lesson pin actions/checkout@11bd719... with a version comment, instead of using actions/checkout@v4 directly?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.