This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole
What Is CI/CD?
- Continuous Integration (CI): automate build, lint, test, and packaging on every change.
- Continuous Delivery/Deployment (CD): automate promotion to environments (staging → production), often with approvals, feature flags, and rollbacks.
- Benefits: faster feedback, fewer regressions, reproducible releases, higher confidence.
CI/CD Tools at a Glance (Quick Compare)
- Jenkins: highly extensible, self‑hosted; you manage controllers/agents and plugins.
- GitLab CI: tightly integrated with GitLab; YAML pipelines, built‑in container registry.
- Azure Pipelines: great for Microsoft stacks; Windows/macOS/Linux hosted pools.
- CircleCI: cloud‑hosted, fast parallelism, orbs ecosystem.
- Bitbucket Pipelines: simple pipelines for Bitbucket repos.
- Buildkite: hybrid model; run builders on your infra.
- Tekton: Kubernetes‑native pipelines (CRDs).
- Argo CD / Flux: GitOps CD for Kubernetes (pull‑based).
- Spinnaker / Harness: powerful multi‑cloud CD, canary/blue‑green baked in.
Why GitHub Actions? Native to GitHub, enormous marketplace, generous hosted runners, great DX, reusable workflows, and first‑class security integrations (OIDC, environments, approvals).
GitHub Actions Core Concepts
- Workflow: YAML in
.github/workflows/*.yml
- Trigger (Event):
push
,pull_request
,workflow_dispatch
,schedule
,release
, etc. - Job: runs on a runner (
runs-on: ubuntu-latest
,windows-latest
, etc.). Jobs can depend on others vianeeds
. - Step: individual shell command or “action” (like
actions/checkout
). - Runners: GitHub‑hosted or self‑hosted (ephemeral or static).
- Artifacts & Caching: persist build outputs; speed up installs.
- Environments:
dev
,staging
,prod
with protection rules, approvals, and secrets. - Secrets & Variables: org/repo/environment scope; injected at runtime.
- Permissions: least‑privilege via
permissions:
(and OIDC viaid-token: write
).
A Minimal CI Workflow (Node example)
Create .github/workflows/ci.yml
:
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install
run: npm ci
- name: Lint
run: npm run lint --if-present
- name: Test
run: npm test -- --ci --reporters=default --reporters=jest-junit
Notes:
- Runs on PRs and on pushes to
main
. - Caches
node_modules
automatically viasetup-node@v4
+cache: npm
.
Python & Java Variants
Python (.github/workflows/python-ci.yml
):
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.10, 3.11, 3.12]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q --maxfail=1 --disable-warnings --junitxml=report.xml
- uses: actions/upload-artifact@v4
with:
name: pytest-report
path: report.xml
Java (Gradle):
name: Java CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: gradle
- run: ./gradlew build --stacktrace
- uses: actions/upload-artifact@v4
with:
name: app-jar
path: build/libs/*.jar
Service Containers for Integration Tests
Example: test against Postgres and Redis
name: Integration Tests
on: [push, pull_request]
jobs:
itest:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: password
POSTGRES_DB: appdb
ports: ["5432:5432"]
options: >-
--health-cmd="pg_isready -U app -d appdb"
--health-interval=10s --health-timeout=5s --health-retries=5
redis:
image: redis:7
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: 3.11 }
- run: pip install -r requirements.txt
- env:
DATABASE_URL: postgresql://app:password@localhost:5432/appdb
REDIS_URL: redis://localhost:6379
run: pytest tests/integration -q
Caching & Artifacts (Speed and Traceability)
- Use setup actions’ built‑in caching (
cache: npm/pip/gradle
). - For custom caches:
- name: Cache build
uses: actions/cache@v4
with:
path: .m2/repository
key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
restore-keys: |
maven-${{ runner.os }}-
- Upload build outputs for later jobs or downloads:
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/**
if-no-files-found: error
CD Basics: Environments, Approvals, and Secrets
- Create environments in GitHub:
dev
,staging
,prod
. - Add environment secrets/variables (e.g.,
PROD_DB_URL
). - Configure protection rules (approvers, wait timers).
Sample CD job gated by an environment:
jobs:
deploy_prod:
needs: build
runs-on: ubuntu-latest
environment:
name: prod
url: https://app.example.com
permissions:
contents: read
id-token: write # for OIDC to cloud providers
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh
Approvers receive a prompt in the PR/Actions UI before the job runs.
Multi‑Stage CI/CD (Build → Test → Deploy)
name: Webapp CI/CD
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: app-${{ github.ref }}
cancel-in-progress: true
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/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=ref,event=branch
type=sha
type=semver,pattern={{version}}
- name: Build & Push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy_staging:
runs-on: ubuntu-latest
needs: [test]
environment: staging
steps:
- name: Deploy to Staging
run: ./scripts/deploy_staging.sh ${{ needs.build.outputs.image }}
deploy_prod:
runs-on: ubuntu-latest
needs: [deploy_staging]
environment: prod
steps:
- name: Deploy to Production
run: ./scripts/deploy_prod.sh ${{ needs.build.outputs.image }}
Cloud Deployments via OIDC (No Long‑Lived Secrets)
AWS (ECS/EKS/Lambda/etc.)
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GHActionsDeployRole
aws-region: eu-west-1
- name: Deploy infra with Terraform
run: |
terraform init
terraform apply -auto-approve
- name: Update ECS service
run: aws ecs update-service --cluster app --service web --force-new-deployment
Prereq: set IAM role with a trust policy allowing GitHub’s OIDC provider and your repo/environment.
Azure (Web Apps/AKS)
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: my-webapp
images: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
Use Azure Federated Credentials for your repo/environment to avoid client secret rotation.
GCP (Cloud Run/GKE)
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/github
service_account: gha-deployer@myproj.iam.gserviceaccount.com
- name: Deploy to Cloud Run
run: |
gcloud run deploy web --image=ghcr.io/${{ github.repository }}:${{ github.sha }} --region=europe-west1
Terraform in Actions (Infra as Code)
name: Terraform
on:
pull_request:
paths: ["infra/**.tf", ".github/workflows/terraform.yml"]
push:
branches: [main]
paths: ["infra/**.tf"]
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=infra init
- run: terraform -chdir=infra plan -out=plan.out
- uses: actions/upload-artifact@v4
with:
name: tf-plan
path: infra/plan.out
apply:
if: github.ref == 'refs/heads/main'
needs: plan
environment: prod
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with: { name: tf-plan, path: infra }
- run: terraform -chdir=infra apply -auto-approve plan.out
Secure Supply Chain (SCA, SAST, Code Scanning)
- Dependency Review on PRs:
name: Dependency Review
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
dep-review:
runs-on: ubuntu-latest
steps:
- uses: actions/dependency-review-action@v4
- CodeQL (static analysis):
name: CodeQL
on:
push: { branches: [main] }
pull_request:
schedule: [{ cron: "35 1 * * 1" }] # weekly
permissions:
contents: read
security-events: write
jobs:
analyze:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { language: [javascript, python, java] }
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with: { languages: ${{ matrix.language }} }
- uses: github/codeql-action/analyze@v3
- Optional: container provenance (SLSA‑style):
- name: Attest build provenance
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.meta.outputs.digest }}
Reusable Workflows & Composite Actions
Reusable workflow (publisher):
.github/workflows/reusable-test.yml
name: Reusable Test
on:
workflow_call:
inputs:
node-version: { required: true, type: string }
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: ${{ inputs.node-version }}, cache: npm }
- run: npm ci && npm test
Caller:
jobs:
tests:
uses: your-org/your-repo/.github/workflows/reusable-test.yml@main
with:
node-version: "20"
Composite action (encapsulate repeatable steps):
.github/actions/setup-app/action.yml
name: "Setup App"
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
shell: bash
Use it:
- uses: ./.github/actions/setup-app
Monorepos & Path Filters
Run pipelines only when relevant code changes:
on:
pull_request:
paths:
- "services/api/**"
- ".github/workflows/api-*.yml"
jobs:
api-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make -C services/api test
Matrix by service:
strategy:
matrix:
service: [api, web, worker]
steps:
- run: make -C services/${{ matrix.service }} test
Branching & Release Strategies
- Trunk‑based (recommended): PRs →
main
; feature flags; short‑lived branches. - GitFlow:
develop
,release/*
,hotfix/*
; more ceremony. - Semver tags trigger releases:
on:
push:
tags: ["v*.*.*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Changelog
run: npx conventional-changelog -p angular -i CHANGELOG.md -s
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/**
Advanced Controls & Guardrails
-
concurrency:
to prevent overlapping deploys. -
environment:
with required reviewers and wait timers. -
timeout-minutes:
per job. -
if:
conditions (e.g., only deploy on tags). -
needs:
to enforce stage order. -
permissions:
minimal scopes; addid-token: write
only when needed. - Pin actions to major versions or SHAs (for maximum supply‑chain safety).
- Secret scanning & push protection: keep secrets out of code.
Self‑Hosted Runners (When and How)
Why: custom tools, private networks, GPUs, cost control, long builds.
Basic setup steps:
- Provision VM/container with network access to targets.
- Create runner in repo/org (Settings → Actions → Runners).
- Label runners (e.g.,
self-hosted
,gpu
,arm64
). - Prefer ephemeral/auto‑scaled runners (clean state per job).
Use in workflow:
jobs:
build:
runs-on: [self-hosted, linux, x64, docker]
Security tips:
- Lock runners to specific repos.
- Rotate tokens; auto‑update runner.
- Isolate with VM snapshots or ephemeral images.
Slide 18 — Observability, Test Reports, Coverage, and Artifacts
- Upload test reports & coverage to keep PR feedback rich.
- Publish HTML reports as artifacts or Pages in non‑prod.
- Example (Jest coverage):
- run: npm run test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/**
- Annotate PRs via problem matchers or actions (eslint, flake8, etc.).
Scheduling, Manual Runs, and Branch Protections
- Schedules use UTC (not repository timezone):
on:
schedule:
- cron: "0 5 * * 1-5" # 05:00 UTC on weekdays
workflow_dispatch:
inputs:
target:
description: "Env to deploy"
required: true
default: "staging"
- Combine with branch protection rules (required checks before merge).
End‑to‑End Example: Dockerized API → Staging/Prod (ECS)
.github/workflows/api-cicd.yml
:
name: API CI/CD
on:
pull_request:
paths: ["api/**", ".github/workflows/api-cicd.yml"]
push:
branches: [main]
paths: ["api/**", ".github/workflows/api-cicd.yml"]
permissions:
contents: read
id-token: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}/api
jobs:
ci:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.version }}
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: ${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long
type=ref,event=branch
- name: Build & Push
uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: ${{ steps.meta.outputs.tags }}
- uses: actions/upload-artifact@v4
with:
name: image-tags
path: |
# emit a simple file with the final tag(s)
/dev/stdout
if: always()
deploy_staging:
needs: ci
runs-on: ubuntu-latest
environment: staging
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gh-ecs-deployer
aws-region: eu-west-1
- name: Update ECS service (staging)
run: |
aws ecs update-service \
--cluster app-staging \
--service api \
--force-new-deployment
deploy_prod:
if: startsWith(github.ref, 'refs/tags/v')
needs: deploy_staging
runs-on: ubuntu-latest
environment: prod
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gh-ecs-deployer
aws-region: eu-west-1
- name: Update ECS service (prod)
run: |
aws ecs update-service \
--cluster app-prod \
--service api \
--force-new-deployment
Flow:
- PR → CI only.
- Push to
main
→ build/push image + deploy tostaging
. - Create tag
vX.Y.Z
→ deploy toprod
(after staging job completes and environment approval, if configured).
Common Pitfalls & Troubleshooting
- “Permission denied” on checkout/push: set
permissions: contents: write
when publishing tags or creating releases. - OIDC failing: verify correct audience/repo/environment in cloud role trust policy.
- Slow builds: add caches, narrow
paths
, enable parallel matrix, use bigger runners (e.g.,ubuntu-latest
vsubuntu-24.04
as available). - Flaky tests: add service health checks and retries; separate unit vs integration; use
timeout-minutes
. - Secrets not found: confirm secret scope (environment vs repo vs org) and name casing.
Security Best Practices Checklist
- Least‑privilege
permissions:
per workflow/job. - Use environments for prod with required reviewers.
- Prefer OIDC to cloud over static keys.
- Pin actions to major versions or commit SHAs.
- Keep runners ephemeral or routinely cleaned.
- Enable branch protections + required status checks.
- Turn on secret scanning, Dependabot alerts & updates.
- Store sensitive config as environment secrets, not repo secrets if env‑specific.
Suggested Project Structure
.
├─ api/ # your app(s)
├─ infra/ # terraform/helm
├─ scripts/ # deploy scripts
├─ .github/
│ ├─ actions/
│ │ └─ setup-app/ # composite action
│ └─ workflows/
│ ├─ ci.yml
│ ├─ codeql.yml
│ ├─ terraform.yml
│ └─ api-cicd.yml
└─ Dockerfile
Quick Start Checklist
- Add
.github/workflows/ci.yml
and run a PR. - Add environments and secrets for
staging
andprod
. - Add OIDC role/federated credentials in your cloud.
- Implement build → test → deploy workflow with approvals.
- Add CodeQL + Dependency Review.
- Add path filters and caches.
- Monitor, iterate, and keep pipelines as code.
Copy‑Paste Starters (Grab‑Bag)
Manual Deploy with Inputs
on:
workflow_dispatch:
inputs:
env:
type: choice
options: [staging, prod]
default: staging
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.env }}
steps:
- uses: actions/checkout@v4
- run: ./scripts/deploy_${{ inputs.env }}.sh
Blue‑Green (Feature Flag‑Style) Toggle
- name: Flip production traffic
run: ./scripts/switch_traffic.sh --to blue
Conditional Job (Only on PRs from internal repo)
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
What to Implement Next
- Tracing CI duration and break‑down per step; set goals for speed.
- Parallelize test suites via matrix/shards.
- Add canary/percentage rollouts (ECS, Cloud Run, AKS/GKE).
- GitOps for Kubernetes (Argo CD/Flux) and make Actions only push manifests/images.
- DORA metrics (deployment frequency, lead time, MTTR, change‑fail rate) from Actions runs.
This content originally appeared on DEV Community and was authored by Goodluck Ekeoma Adiole

Goodluck Ekeoma Adiole | Sciencx (2025-08-16T15:49:26+00:00) CI/CD With GitHub Actions — A Practical, End‑to‑End Tutorial. Retrieved from https://www.scien.cx/2025/08/16/ci-cd-with-github-actions-a-practical-end%e2%80%91to%e2%80%91end-tutorial/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.