CI/CD — Practical
CI/CD — Practical patterns
Section titled “CI/CD — Practical patterns”GitHub Actions: full-pipeline example
Section titled “GitHub Actions: full-pipeline example”name: cion: push: { branches: [main] } pull_request: {}permissions: { contents: read, id-token: write, packages: write }concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: lint-test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: { POSTGRES_PASSWORD: pw } ports: ['5432:5432'] options: --health-cmd 'pg_isready' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: pnpm } - run: corepack enable - run: pnpm i --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm test --coverage - uses: codecov/codecov-action@v4
build: needs: lint-test runs-on: ubuntu-latest outputs: image: ${{ steps.meta.outputs.tags }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=sha,format=long type=ref,event=branch type=semver,pattern={{version}} - uses: docker/build-push-action@v6 with: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/${{ github.repository }}@${{ steps.meta.outputs.digest }} severity: CRITICAL,HIGH exit-code: '1' - uses: sigstore/cosign-installer@v3 - run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.meta.outputs.digest }}
deploy-staging: if: github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }} aws-region: eu-west-1 - uses: actions/checkout@v4 - run: ./scripts/deploy.sh staging ${{ github.sha }} - run: ./scripts/smoke.sh https://staging.example.com
deploy-prod: if: github.ref == 'refs/heads/main' needs: deploy-staging runs-on: ubuntu-latest environment: name: production url: https://example.com # Required reviewers gate happens here steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_PROD }} aws-region: eu-west-1 - uses: actions/checkout@v4 - run: ./scripts/deploy.sh prod ${{ github.sha }} - run: ./scripts/smoke.sh https://example.comReusable workflow
Section titled “Reusable workflow”on: { workflow_call: { inputs: { node: { type: string, default: '20' } } } }jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: ${{ inputs.node }}, cache: pnpm } - run: pnpm i --frozen-lockfile - run: pnpm test# callerjobs: test-matrix: uses: ./.github/workflows/test.yml with: { node: '20' }Caching strategies
Section titled “Caching strategies”- uses: actions/cache@v4 with: path: | ~/.pnpm-store node_modules key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-For Docker layer cache: cache-from/to: type=gha or type=registry for cross-CI.
Canary with Argo Rollouts
Section titled “Canary with Argo Rollouts”apiVersion: argoproj.io/v1alpha1kind: Rolloutmetadata: { name: api }spec: replicas: 10 strategy: canary: steps: - setWeight: 10 - pause: { duration: 5m } - setWeight: 25 - pause: { duration: 10m } - setWeight: 50 - pause: { duration: 10m } - setWeight: 100 analysis: templates: [{ templateName: success-rate }] startingStep: 1 args: [{ name: service-name, value: api }] selector: { matchLabels: { app: api } } template: ...Rollback script (immutable image)
Section titled “Rollback script (immutable image)”#!/usr/bin/env bashset -euo pipefailENV=$1TAG=$2kubectl -n $ENV set image deploy/api api=ghcr.io/org/api:$TAGkubectl -n $ENV rollout status deploy/api --timeout=5mRoll back: kubectl rollout undo deploy/api or re-run with previous tag.
Smoke test
Section titled “Smoke test”#!/usr/bin/env bashURL=$1for i in 1 2 3 4 5; do status=$(curl -sf -o /dev/null -w '%{http_code}' "$URL/healthz" || true) [ "$status" = "200" ] && exit 0 sleep $((i*2))doneecho "smoke failed"; exit 1Environment promotion (image SHA promotion)
Section titled “Environment promotion (image SHA promotion)”Don’t rebuild per env. Tag once with SHA, promote.
# build once in maindocker push ghcr.io/org/api:abc123
# promote to staginghelm upgrade api ./chart -n staging --set image.tag=abc123
# promote to prod after greenhelm upgrade api ./chart -n prod --set image.tag=abc123Skip CI on docs-only
Section titled “Skip CI on docs-only”on: push: paths-ignore: ['**.md', 'docs/**']Concurrency / cancel old runs
Section titled “Concurrency / cancel old runs”concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: trueCost-saving runner tips
Section titled “Cost-saving runner tips”- Use
ubuntu-latest(largest free pool). - Avoid macos / windows runners unless required.
- Self-hosted runners for big builds.
- Use spot/EC2 self-hosted for arm64 builds.
Common platform-specific tools
Section titled “Common platform-specific tools”- GitLab CI:
.gitlab-ci.yml, runners, environments, manual jobs. - CircleCI: orbs (reusable), workflows, contexts.
- Buildkite: agents, dynamic pipelines, plugins.
- Argo Workflows: K8s-native batch + CI.
- Tekton: K8s-native pipeline primitives.
- Spinnaker: deployment-focused, multi-cloud.
Deep — secure-build GitHub Actions workflow
Section titled “Deep — secure-build GitHub Actions workflow”- name: Build run: docker build -t $IMG .
- name: Trivy scan (fail on HIGH/CRITICAL) uses: aquasecurity/trivy-action@0.24.0 with: image-ref: ${{ env.IMG }} severity: HIGH,CRITICAL exit-code: '1' ignore-unfixed: true
- name: Generate SBOM run: trivy image --format cyclonedx --output sbom.cdx.json $IMG
- name: Cosign keyless sign env: { COSIGN_EXPERIMENTAL: '1' } run: | cosign sign --yes $IMG cosign attest --yes --predicate sbom.cdx.json --type cyclonedx $IMGDeep — matrix + concurrency + reusable workflow
Section titled “Deep — matrix + concurrency + reusable workflow”name: cion: pull_request: push: { branches: [main] }
concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true
jobs: test: strategy: fail-fast: false matrix: node: [18, 20, 22] os: [ubuntu-latest, macos-latest] exclude: [{ os: macos-latest, node: 18 }] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: ${{ matrix.node }}, cache: 'npm' } - run: npm ci && npm test
deploy: needs: test if: github.ref == 'refs/heads/main' uses: ./.github/workflows/deploy.yml secrets: inheritDeep — OIDC to AWS
Section titled “Deep — OIDC to AWS”permissions: id-token: write # mint OIDC JWT contents: read
jobs: deploy: environment: prod # gates + approval rules runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-prod role-session-name: gha-${{ github.run_id }} aws-region: me-central-1 - run: aws sts get-caller-identity - run: aws ecr get-login-password | docker login --password-stdin ...// IAM trust policy on gha-deploy-prod role{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:org/api-service:environment:prod" } } }]}Deep — Jenkins declarative pipeline
Section titled “Deep — Jenkins declarative pipeline”@Library('platform-shared@main') _pipeline { agent { label 'docker-builder' } options { timeout(time: 30, unit: 'MINUTES'); disableConcurrentBuilds() } environment { REGISTRY = 'registry.internal' AWS = credentials('aws-prod-oidc') } stages { stage('Build') { steps { sh 'docker build -t $REGISTRY/api:$BUILD_NUMBER .' } } stage('Test') { parallel { stage('Unit') { steps { sh 'npm test' } } stage('Integration') { steps { sh 'npm run test:int' } } stage('Scan') { steps { sh 'trivy image --exit-code 1 --severity HIGH,CRITICAL $REGISTRY/api:$BUILD_NUMBER' } } } } stage('Deploy') { when { branch 'main' } steps { deployToK8s(env: 'prod', image: "$REGISTRY/api:$BUILD_NUMBER") } } } post { failure { slackSend(channel: '#alerts', message: "Build ${env.BUILD_NUMBER} failed") } always { cleanWs() } }}Deep — Argo Rollouts canary with auto-analysis
Section titled “Deep — Argo Rollouts canary with auto-analysis”apiVersion: argoproj.io/v1alpha1kind: Rolloutmetadata: { name: api }spec: replicas: 10 strategy: canary: maxSurge: 25% maxUnavailable: 0 steps: - setWeight: 5 - pause: { duration: 5m } - analysis: { templates: [ { templateName: success-rate } ] } - setWeight: 25 - pause: { duration: 10m } - setWeight: 50 - pause: { duration: 10m } - setWeight: 100