Frontend Labs
⋯

Part VII — Delivery

35. CI: build, test, scan, sign, publish

35.1 Pipeline shape

PR:     lint → unit tests (-race) → build image → scan → integration test (compose) → manifests validate
main:   ... same ... → push (sha tag) → sign + SBOM attest → bump image digest in deploy/overlays/dev
tag v*: promote: open PRs bumping staging/prod overlays to the SAME digest (build once, promote the artifact)

Best practice: build once, promote the same artifact. Never rebuild for prod. The digest that passed staging is the digest that ships. Rebuilding produces a different artifact that has never been tested.

35.2 GitHub Actions workflow

# .github/workflows/ci.yaml
name: ci
on:
  pull_request:
  push: { branches: [main] }

permissions: { contents: read }

concurrency: { group: "${{ github.workflow }}-${{ github.ref }}", cancel-in-progress: true }

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version-file: go.mod }
      - uses: golangci/golangci-lint-action@v8
      - run: go test -race -count=1 ./...
      - name: integration test against the real stack
        run: |
          docker compose up -d --build --wait
          go test -tags=integration ./test/integration/...
          docker compose down -v
      - name: validate manifests
        run: |
          for o in deploy/overlays/*; do
            kustomize build "$o" | kubeconform -strict -summary -ignore-missing-schemas
          done

  image:
    needs: test
    runs-on: ubuntu-latest
    strategy:
      matrix: { service: [catalog, orders, worker] }
    permissions:
      contents: read
      packages: write          # push to GHCR
      id-token: write          # keyless signing (OIDC)
      attestations: write
    outputs:
      digest-catalog: ${{ steps.out.outputs.digest-catalog }}
      digest-orders:  ${{ steps.out.outputs.digest-orders }}
      digest-worker:  ${{ steps.out.outputs.digest-worker }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        if: github.event_name == 'push'
        with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/acme/orbit-${{ matrix.service }}
          tags: type=sha,format=short,prefix=sha-
      - id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          build-args: |
            SERVICE=${{ matrix.service }}
            VERSION=${{ github.sha }}
          push: ${{ github.event_name == 'push' }}
          load: false
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          sbom: true
          provenance: mode=max
          cache-from: type=gha,scope=${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=${{ matrix.service }}
      - name: scan (fail on fixable CRITICAL)
        if: github.event_name == 'push'
        uses: aquasecurity/trivy-action@<release-sha>   # pin to the current release by commit SHA
        with:
          image-ref: ghcr.io/acme/orbit-${{ matrix.service }}@${{ steps.build.outputs.digest }}
          severity: CRITICAL
          ignore-unfixed: true
          exit-code: "1"
      - uses: sigstore/cosign-installer@v3
        if: github.event_name == 'push'
      - name: sign (keyless)
        if: github.event_name == 'push'
        run: cosign sign --yes ghcr.io/acme/orbit-${{ matrix.service }}@${{ steps.build.outputs.digest }}
      - id: out
        run: echo "digest-${{ matrix.service }}=${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT"

  deploy-dev:
    if: github.event_name == 'push'
    needs: image
    runs-on: ubuntu-latest
    permissions: { contents: write }
    steps:
      - uses: actions/checkout@v4
      - name: bump dev overlay to the new digests
        run: |
          cd deploy/overlays/dev
          kustomize edit set image \
            orbit-catalog=ghcr.io/acme/orbit-catalog@${{ needs.image.outputs.digest-catalog }} \
            orbit-orders=ghcr.io/acme/orbit-orders@${{ needs.image.outputs.digest-orders }} \
            orbit-worker=ghcr.io/acme/orbit-worker@${{ needs.image.outputs.digest-worker }}
          git config user.name "orbit-ci" && git config user.email "ci@acme.example"
          git commit -am "deploy(dev): ${GITHUB_SHA::7}" && git push

Notes:

  • permissions: are least-privilege per job. id-token: write only where signing happens.
  • Pin third-party actions by commit SHA in real repos (tags are mutable; compromised action tags have been used in real supply-chain attacks, e.g. tj-actions/changed-files in 2025). Let Dependabot or Renovate bump them.
  • Many teams keep deploy manifests in a separate config repo, so CI needs only write access to that repo, and app-repo commits don't trigger deploy loops. Either works; be consistent.
  • Add an ephemeral-cluster e2e job with helm/kind-action for platform-level changes (new CRDs, policies).

⚠️ Caveat (verify): Action major versions (checkout@v4, setup-go@v5, build-push-action@v6, golangci-lint-action@v8) were current when this was written. Check each action's releases page and pin to commit SHAs. The trivy-action placeholder must be filled in. kustomize edit set image name=repo@digest syntax depends on the kustomize version; run it locally once.


Generated from real-life-k8s-guide.md · 8 Oct 2026 16:36