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: writeonly 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-filesin 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-actionfor 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@digestsyntax depends on the kustomize version; run it locally once.