Skip to content

CI/CD — Practical

name: ci
on:
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.com
.github/workflows/test.yml
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
# caller
jobs:
test-matrix:
uses: ./.github/workflows/test.yml
with: { node: '20' }
- 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.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { 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: ...
#!/usr/bin/env bash
set -euo pipefail
ENV=$1
TAG=$2
kubectl -n $ENV set image deploy/api api=ghcr.io/org/api:$TAG
kubectl -n $ENV rollout status deploy/api --timeout=5m

Roll back: kubectl rollout undo deploy/api or re-run with previous tag.

#!/usr/bin/env bash
URL=$1
for 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))
done
echo "smoke failed"; exit 1

Environment promotion (image SHA promotion)

Section titled “Environment promotion (image SHA promotion)”

Don’t rebuild per env. Tag once with SHA, promote.

Terminal window
# build once in main
docker push ghcr.io/org/api:abc123
# promote to staging
helm upgrade api ./chart -n staging --set image.tag=abc123
# promote to prod after green
helm upgrade api ./chart -n prod --set image.tag=abc123
on:
push:
paths-ignore: ['**.md', 'docs/**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
  • 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.
  • 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”
.github/workflows/secure-build.yml
- 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 $IMG

Deep — matrix + concurrency + reusable workflow

Section titled “Deep — matrix + concurrency + reusable workflow”
name: ci
on:
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: inherit
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"
}
}
}]
}
@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/v1alpha1
kind: Rollout
metadata: { 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