CI/CD built into GitHub, hands-on. You'll write your first workflow, understand events and jobs, cache dependencies, run matrices, use secrets and environments, and build a secure deploy pipeline.
| You need | Why |
|---|---|
| A GitHub repository you can push to | Workflows live in .github/workflows/ and run on GitHub's runners |
| Basic Git + YAML familiarity | Workflows are YAML, triggered by Git events |
| (Optional) a deploy target + secrets | For the deploy pipeline in Parts 6–9 |
Nothing to install locally — Actions runs on GitHub-hosted runners. You just need push access to a repo.
GitHub Actions runs workflows — YAML files in .github/workflows/ — in response to events (a push, a pull request, a schedule, a manual click). Each workflow has jobs; each job runs on a fresh runner (VM) and contains steps (shell commands or reusable actions).
event -> workflow -> job(s) on runners -> step(s)
Jobs run in parallel by default and get a clean environment each time — so builds are reproducible.
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
Commit it. On the next push or PR, GitHub runs the job and shows a green check or red X on the commit. actions/checkout pulls your code; actions/setup-node installs Node and — with cache: npm — caches ~/.npm between runs.
on: decides when a workflow runs. The common ones:
on:
push:
branches: [main]
paths: ['src/**'] # only when source changes
pull_request:
schedule:
- cron: '0 6 * * *' # daily at 06:00 UTC
workflow_dispatch: # a manual "Run workflow" button
Use paths: filters to avoid running heavy jobs on docs-only changes, and workflow_dispatch for on-demand deploys.
Run the same job across versions or OSes in parallel:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}' }
- run: npm ci && npm test
That's 6 parallel jobs. fail-fast: false lets the others finish even if one combination fails, so you see the full picture.
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
A downstream job restores it with download-artifact.
Store credentials in Settings → Secrets and variables → Actions, then reference them — never hard-code:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
deploy:
needs: build # runs only after build succeeds
runs-on: ubuntu-latest
environment: production # gate + protection rules
steps:
- run: ./deploy.sh
env:
API_TOKEN: ${{ secrets.PROD_API_TOKEN }}
needs: creates a dependency, turning parallel jobs into a pipeline.environment: unlocks required reviewers, wait timers, and environment-scoped secrets — so a prod deploy can require manual approval.CI has access to your code and secrets — treat it as production:
@v4 at minimum; for third-party actions, pin to a full commit SHA to prevent a hijacked tag from running in your pipeline.GITHUB_TOKEN to read-only and grant per-job:permissions:
contents: read
permissions:
id-token: write
contents: read
echo secrets or interpolate untrusted input (like a PR title) into a run: shell — that's a script-injection path. Pass it via env: instead.uses: ./.github/workflows/deploy.yml and pass inputs/secrets. Define your deploy once, call it per environment.action.yml and share across repos.concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t ghcr.io/acme/app:${{ github.ref_name }} .
- run: docker push ghcr.io/acme/app:${{ github.ref_name }}
deploy:
needs: build
environment: production
runs-on: ubuntu-latest
steps:
- run: kubectl set image deploy/app app=ghcr.io/acme/app:${{ github.ref_name }}
Tag a release (git tag v1.2.0 && git push --tags) and the image builds, pushes, and — after the production environment's approval — deploys.
run:. A PR title like $(rm -rf /) becomes a script-injection path. Pass it via env:, never inline into the shell.GITHUB_TOKEN is broad by default. Set permissions: contents: read and grant more per-job only when needed.@v3 tag runs in your pipeline with your secrets. Pin third-party actions to a full commit SHA.id-token: write) to exchange a short-lived token with AWS/GCP/Azure.echoing secrets. Even masked, it's a leak risk. Never print them.concurrency group with cancel-in-progress.ci.yml from Part 2 to a repo, push, and watch the check appear on the commit. Break a test and confirm it goes red.[ubuntu-latest, macos-latest] × [18, 20, 22] with fail-fast: false. How many jobs run in parallel?deploy job with needs: build and environment: production, and configure a required reviewer so it waits for approval.permissions: contents: read, pin one third-party action to a SHA, and add a concurrency block that cancels superseded runs.Self-check:
run: dangerous, and what's the fix?needs: change about how jobs execute?environment: give you that a plain job doesn't?kubectl in CI.You can now build multi-job, cached, matrixed pipelines with least-privilege secrets and gated deploys — production-grade GitHub Actions.
Learned the concepts? Test yourself with GitHub Actions interview questions.
Go to the question bank →