Stage 5 · Platform
CI/CD Foundations
Core Delivery Principles
Trunk-based development, small batches, immutable artifacts, and separation of build from deploy.
Trunk-Based Development
Trunk-based development means all developers commit to a single branch (main) or use very short-lived branches. Long-lived feature branches create merge conflicts, delay feedback, and make releases unpredictable. Trunk-based development keeps the codebase in a constantly releasable state.
The key discipline is that main is always deployable. Every commit goes through the full pipeline. If the pipeline passes, the change is safe to deploy. This eliminates the distinction between development and release branches.
name: Trunk CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
deploy-preview:
needs: validate
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- run: ./deploy-preview.sh
deploy-production:
needs: validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy-production.shThis pipeline validates every commit to main and every pull request. Production deploys happen automatically on merge. Preview deploys happen on pull requests for manual verification.
When you cannot ship a feature immediately, use feature flags instead of long-lived branches. The code merges to main behind a flag and is activated when ready. This keeps the codebase integrated and avoids branch divergence.
Small Batches
Small batches reduce risk. A 10-line change that breaks something is easy to debug and revert. A 1000-line change that breaks something could take hours to diagnose. The DORA research confirms that teams deploying smaller changes more frequently have lower failure rates and faster recovery.
- Keep pull requests under 400 lines of changed code — larger reviews miss defects.
- Deploy at least once per day per developer — if you cannot, your pipeline is too slow.
- Use feature flags to decouple deployment from release — ship code small, enable gradually.
- Break monolithic changes into sequential, independently deployable pieces.
Immutable Artifacts
An immutable artifact is built once and deployed everywhere. You never rebuild the artifact for different environments. Instead, you configure the same artifact with environment-specific settings. This guarantees that what you tested is what you deploy.
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
push: true
tags: |
ghcr.io/myorg/myapp:${{ github.sha }}
ghcr.io/myorg/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/myapp myapp=${{ needs.build.outputs.image }}
--namespace=staging
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- run: kubectl set image deployment/myapp myapp=${{ needs.build.outputs.image }}
--namespace=productionThe same image tag (git SHA) is deployed to staging and production. No rebuild occurs. If staging tests fail, the same image is never promoted. If production needs rollback, the previous SHA is redeployed.
Separation of Build from Deploy
Build and deploy should be separate pipeline stages with different access scopes. The build job needs read access to source code and write access to the artifact registry. The deploy job needs write access to the production environment but should not have access to source code. This limits the blast radius of compromised credentials.
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: docker build -t ghcr.io/myorg/myapp:${{ github.sha }} .
- run: docker push ghcr.io/myorg/myapp:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
environment: production
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az containerapp update --name myapp --resource-group rg-prod --image ghcr.io/myorg/myapp:${{ github.sha }}The build job has container registry access. The deploy job has cloud IAM access via OIDC. Neither job has the other's credentials. If the build job is compromised, it cannot modify production directly.
Fast-Forward Only Merges
Merge commits create non-linear history that makes bisection and rollback harder. Fast-forward only merges keep the history linear, making it easy to identify exactly which commit introduced a change. GitHub offers this as a branch protection setting.
Everything as Code
Infrastructure, configuration, policies, and pipeline definitions should all live in version control. This makes changes auditable, reviewable, and reversible. Manual changes to production are the enemy of reliability.
DORA defines four key metrics for software delivery: deployment frequency, lead time for changes, time to restore service, and change failure rate. These metrics measure the outcome of applying these principles, not the principles themselves. Focus on the practices, and the metrics will follow.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.