Audience: Beginners, developers, junior DevOps engineers, QA engineers, and students
Level: Beginner to early-intermediate
Duration: About 120 minutes
Format: Learn, modify, run, break, and fix
Goal: By the end of this tutorial, you should be able to read an unfamiliar GitHub Actions workflow, create a basic CI workflow, use common Actions features safely, and troubleshoot common failures.
Table of Contents
- How to Use This Tutorial
- GitHub Actions in 10 Minutes
- Understanding Workflow YAML
- Events and Triggers
- Jobs, Steps, and Runners
- Using Actions
- Variables, Contexts, and Expressions
- Secrets and GITHUB_TOKEN
- Hands-On Lab: Build Your First CI Pipeline
- Matrix Builds
- Caching
- Artifacts
- From CI to CD and Environments
- GitHub Actions Security: 5 Rules
- Troubleshooting: The 7-Step Method
- Final Hands-On Challenge
- GitHub Actions Essential Cheat Sheet
- 15-Question Knowledge Check
- What to Learn Next
1. How to Use This Tutorial
This tutorial is intentionally small enough to complete in about two hours.
The objective is not to memorize every GitHub Actions feature. The objective is to build a strong mental model and become productive with the features that appear in most day-to-day CI workflows.
You will repeatedly modify one workflow instead of studying many unrelated examples.
The learning sequence is:
Understand
|
v
Write
|
v
Run
|
v
Inspect
|
v
Break
|
v
Fix
Code language: PHP (php)
1.1 Suggested timing
| Time | Module | Main outcome |
|---|---|---|
| 0-10 min | GitHub Actions mental model | Understand the platform |
| 10-25 min | Workflow YAML | Read and write workflow structure |
| 25-38 min | Triggers, jobs, and steps | Control when and how work runs |
| 38-48 min | Runners and Actions | Understand where work runs and how Actions are reused |
| 48-60 min | Variables, contexts, expressions, secrets | Use runtime data safely |
| 60-78 min | Main CI lab | Build a real CI pipeline |
| 78-90 min | Matrix, cache, artifacts | Add important CI capabilities |
| 90-101 min | CI to CD | Understand deployment flow |
| 101-110 min | Security | Learn the five rules that matter most |
| 110-117 min | Troubleshooting | Debug failures systematically |
| 117-120 min | Challenge | Apply what you learned |
1.2 Prerequisites
You should have:
- a GitHub account;
- access to a repository where you can create branches and commits;
- basic Git knowledge;
- basic command-line knowledge;
- optional Node.js knowledge for the hands-on lab.
You do not need prior GitHub Actions experience.
1.3 What this tutorial intentionally does not cover
The following topics are important, but they belong in a longer course:
- custom JavaScript actions;
- custom Docker actions;
- advanced reusable workflow architecture;
- OpenID Connect cloud federation implementation;
- Kubernetes deployment;
- Terraform and infrastructure-as-code workflows;
- Actions Runner Controller;
- enterprise governance;
- monorepo optimization;
- artifact attestations;
- advanced release engineering;
- organization-wide workflow policy.
2. GitHub Actions in 10 Minutes
2.1 What is GitHub Actions?
GitHub Actions is GitHub’s automation platform.
It listens for events such as:
- code being pushed;
- a pull request being opened;
- a pull request being updated;
- a release being created;
- a user manually starting a workflow;
- a schedule being reached.
When an event matches a workflow definition, GitHub starts a workflow run.
The most common use is CI/CD.
Typical CI tasks include:
- checkout source code;
- install dependencies;
- lint code;
- run unit tests;
- run integration tests;
- compile or build software;
- create packages;
- upload build artifacts.
Typical CD tasks include:
- publish a package;
- push a container image;
- deploy to staging;
- deploy to production;
- run smoke tests;
- roll back a deployment.
2.2 The core mental model
Remember this sequence:
Event
|
v
Workflow
|
v
Job
|
v
Runner
|
v
Steps
|
v
Actions / Commands
|
v
Result
That single flow explains most of GitHub Actions.
2.3 Core terms
| Term | Simple meaning |
|---|---|
| Event | Something happened in or around GitHub |
| Workflow | Automation defined in YAML |
| Workflow run | One execution of a workflow |
| Job | A unit of work scheduled to a runner |
| Runner | The machine that executes a job |
| Step | A task inside a job |
| Action | Reusable automation executed by a step |
| Command | A shell command executed by a step |
| Artifact | A file kept from a workflow run |
| Cache | Reusable data intended to make future runs faster |
| Secret | Protected sensitive configuration |
| Variable | Non-sensitive configuration |
| Context | Structured runtime information from GitHub |
| Expression | GitHub’s ${{ }} evaluation syntax |
| Environment | A named deployment target such as staging or production |
2.4 CI, continuous delivery, and continuous deployment
These terms are related but different.
| Practice | Question it answers | Example |
|---|---|---|
| Continuous Integration | Is this code safe to merge? | Build, lint, test |
| Continuous Delivery | Is this release ready to deploy? | Build, package, stage, wait for approval |
| Continuous Deployment | Can validated changes deploy automatically? | CI plus automatic production deployment |
A beginner should first learn CI. Deployment should come after you understand workflow execution and security.
2.5 Your first workflow
Create this file:
.github/workflows/hello.yml
Add:
name: Hello GitHub Actions
on:
push:
jobs:
hello:
runs-on: ubuntu-latest
steps:
- name: Say hello
run: echo "Hello from GitHub Actions"
Code language: PHP (php)
Commit and push it.
Then open:
Repository -> Actions -> Hello GitHub Actions
You should see a workflow run.
2.6 What happened?
You pushed a commit
|
v
GitHub detected push
|
v
hello.yml matched the event
|
v
Workflow run was created
|
v
Job "hello" was scheduled
|
v
Ubuntu runner started
|
v
Step executed echo command
|
v
Workflow completed
Code language: PHP (php)
That is GitHub Actions in its simplest useful form.
3. Understanding Workflow YAML
GitHub Actions workflows are YAML files stored under:
.github/workflows/
Common filenames:
ci.yml
pull-request-ci.yml
release.yml
deploy-production.yml
Code language: CSS (css)
Both .yml and .yaml work.
3.1 YAML indentation matters
Correct:
jobs:
test:
runs-on: ubuntu-latest
Incorrect:
jobs:
test:
runs-on: ubuntu-latest
YAML uses indentation to describe structure.
3.2 The basic workflow structure
Study this example carefully:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run a command
run: echo "CI is running"
Code language: PHP (php)
The important keywords are:
name
on
permissions
jobs
runs-on
steps
uses
run
with
Code language: JavaScript (javascript)
3.3 Workflow hierarchy
Workflow
|
+-- name
+-- on
+-- permissions
+-- jobs
|
+-- test
|
+-- runs-on
+-- steps
|
+-- checkout
+-- command
3.4 name
The workflow display name:
name: Pull Request CI
Code language: HTTP (http)
This appears in the Actions UI.
3.5 on
The trigger:
on:
push:
Or multiple triggers:
on:
push:
pull_request:
workflow_dispatch:
3.6 jobs
A workflow contains one or more jobs:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test
Here test is the job ID.
3.7 runs-on
This selects the runner:
runs-on: ubuntu-latest
Code language: HTTP (http)
3.8 steps
Steps run in order inside one job:
steps:
- run: echo "Step 1"
- run: echo "Step 2"
- run: echo "Step 3"
Code language: PHP (php)
3.9 uses
Use a reusable Action:
- uses: actions/checkout@v6
3.10 run
Run a shell command:
- run: npm test
Multiline commands:
- name: Build
run: |
npm ci
npm test
npm run build
3.11 with
Pass inputs to an Action:
- uses: actions/setup-node@v7
with:
node-version: "24"
Code language: JavaScript (javascript)
3.12 Micro-practice
Modify your first workflow:
name: My First CI Workflow
on:
push:
jobs:
hello:
runs-on: ubuntu-latest
steps:
- name: Print repository information
run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Commit: $GITHUB_SHA"
Code language: PHP (php)
Push the change and inspect the log.
4. Events and Triggers
A workflow does nothing until an event matches its on configuration.
For a two-hour course, focus on three triggers:
push
pull_request
workflow_dispatch
4.1 Push
Run whenever code is pushed:
on:
push:
Run only when main changes:
on:
push:
branches:
- main
Run for multiple branches:
on:
push:
branches:
- main
- develop
4.2 Pull request
Run when a pull request is opened or updated:
on:
pull_request:
This is one of the most important CI triggers.
Typical PR workflow:
Developer creates PR
|
v
GitHub starts CI
|
v
Lint
|
v
Test
|
v
Build
|
v
Status check passes or fails
4.3 Manual workflow
Use workflow_dispatch when a person should start the workflow manually:
on:
workflow_dispatch:
After pushing the workflow to the default branch, GitHub can show a “Run workflow” button in the Actions UI.
A simple manual input:
on:
workflow_dispatch:
inputs:
environment:
description: Deployment environment
required: true
type: choice
options:
- staging
- production
Code language: JavaScript (javascript)
Read the selected value with:
${{ inputs.environment }}
4.4 Multiple triggers
A common CI workflow:
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
Code language: CSS (css)
Meaning:
Push to main --------+
|
Pull request --------+--> Run workflow
|
Manual execution ----+
4.5 Path filters
If you only want CI when application code changes:
on:
pull_request:
paths:
- "src/**"
- "package.json"
- "package-lock.json"
Code language: JavaScript (javascript)
Use path filters carefully. A workflow skipped because of a path filter can interact with required checks in ways that need deliberate repository design.
4.6 Trigger practice
Change your workflow so that it runs:
- on pull requests;
- on pushes to
main; - manually.
Solution:
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
Code language: CSS (css)
5. Jobs, Steps, and Runners
This is the most important execution concept after triggers.
5.1 Jobs
A job is an independently scheduled unit of work.
Example:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "Linting"
test:
runs-on: ubuntu-latest
steps:
- run: echo "Testing"
Code language: PHP (php)
By default, these jobs can run in parallel.
+--> Job: lint --> Runner A
Workflow ----+
+--> Job: test --> Runner B
5.2 Steps
Steps inside a job run sequentially.
steps:
- run: echo "1"
- run: echo "2"
- run: echo "3"
Code language: PHP (php)
Execution:
Step 1
|
v
Step 2
|
v
Step 3
5.3 Jobs do not automatically share files
Each job normally has its own runner environment.
This is important:
Job A filesystem != Job B filesystem
If one job creates a file and another job needs it, use an artifact or another explicit data-transfer mechanism.
5.4 Sequential jobs with needs
Example:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Build"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploy"
Code language: PHP (php)
Flow:
Build
|
v
Deploy
Without needs, jobs may run in parallel.
5.5 Fan-out pattern
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Build"
unit:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Unit tests"
integration:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Integration tests"
Code language: PHP (php)
Flow:
+--> Unit
|
Build ------+
|
+--> Integration
5.6 Runners
A runner is the compute environment that executes a job.
Common options:
| Runner | Meaning |
|---|---|
ubuntu-latest | GitHub-hosted Linux runner |
windows-latest | GitHub-hosted Windows runner |
macos-latest | GitHub-hosted macOS runner |
self-hosted | Runner managed by you |
For this course, use:
runs-on: ubuntu-latest
Code language: HTTP (http)
5.7 GitHub-hosted runners
Advantages:
- no server maintenance;
- fresh environment per job;
- common tools preinstalled;
- easy scaling;
- simple for most CI use cases.
Important consequence:
The environment is temporary. Do not expect files to remain after the job ends unless you upload them somewhere.
5.8 Self-hosted runners
A self-hosted runner is a machine you operate.
Examples:
- EC2 instance;
- VM;
- bare-metal server;
- on-premises server;
- Kubernetes runner pod.
Use self-hosted runners when you need special networking, hardware, tooling, or internal access.
For beginners, the most important security lesson is:
Do not run untrusted code on a powerful persistent self-hosted runner that can access production systems.
5.9 Practice
Create two jobs:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo "Lint"
test:
runs-on: ubuntu-latest
steps:
- run: echo "Test"
Code language: PHP (php)
Run the workflow and inspect whether the jobs overlap in time.
Then add:
needs: lint
Code language: HTTP (http)
to the test job and run it again.
Observe the difference.
6. Using Actions
An Action is reusable automation used inside a step.
6.1 Action vs command
Action:
- uses: actions/checkout@v6
Command:
- run: npm test
The difference is simple:
uses = reuse an Action
run = execute a shell command
6.2 Checkout Action
Most workflows need the repository contents:
- name: Checkout
uses: actions/checkout@v6
Without checkout, your runner does not automatically have the repository files available for your commands.
6.3 Setup Actions
Example for Node.js:
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
Code language: JavaScript (javascript)
Example for Python:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
Code language: JavaScript (javascript)
6.4 Action inputs
Actions can accept inputs through with:
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
Code language: JavaScript (javascript)
6.5 Action versions
Examples commonly use a major release tag:
uses: actions/checkout@v6
Code language: HTTP (http)
Production security-sensitive third-party Actions should be reviewed carefully and often pinned to a full commit SHA according to your organization’s supply-chain policy.
The beginner rule is:
- prefer official or well-reviewed Actions;
- understand what permissions they receive;
- do not blindly copy random Marketplace Actions into privileged workflows.
7. Variables, Contexts, and Expressions
These concepts often confuse beginners because they look similar.
Use this table first:
| Type | Example | Purpose |
|---|---|---|
| Environment variable | $NODE_ENV | Runtime process configuration |
| Configuration variable | ${{ vars.API_URL }} | Non-secret GitHub configuration |
| Context | ${{ github.ref }} | Runtime information from GitHub |
| Expression | ${{ ... }} | GitHub-side evaluation and logic |
| Secret | ${{ secrets.API_TOKEN }} | Sensitive configuration |
7.1 Environment variables
Workflow-level:
env:
NODE_ENV: test
Job-level:
jobs:
test:
env:
MODE: ci
Step-level:
- name: Run test
env:
API_URL: https://example.test
run: npm test
Code language: JavaScript (javascript)
A more specific scope overrides a broader scope.
7.2 Default GitHub environment variables
GitHub automatically provides many useful values.
Examples:
GITHUB_REPOSITORY
GITHUB_SHA
GITHUB_REF
GITHUB_REF_NAME
GITHUB_RUN_ID
GITHUB_RUN_NUMBER
GITHUB_WORKFLOW
GITHUB_WORKSPACE
RUNNER_OS
RUNNER_ARCH
Use them in shell commands:
- name: Print metadata
run: |
echo "Repository: $GITHUB_REPOSITORY"
echo "Commit: $GITHUB_SHA"
echo "Ref: $GITHUB_REF"
echo "Runner: $RUNNER_OS/$RUNNER_ARCH"
Code language: PHP (php)
7.3 Contexts
Contexts are structured objects evaluated by GitHub.
Important contexts:
| Context | Example data |
|---|---|
github | repository, ref, SHA, actor, event |
env | workflow/job/step environment values |
vars | configuration variables |
secrets | available secrets |
steps | step outputs and outcomes |
needs | dependency job results and outputs |
matrix | current matrix values |
runner | runner details |
inputs | manual or reusable workflow inputs |
Example:
- run: |
echo "Repository: ${{ github.repository }}"
echo "Branch: ${{ github.ref_name }}"
echo "Commit: ${{ github.sha }}"
Code language: PHP (php)
7.4 Expressions
GitHub expression syntax:
${{ expression }}
Example:
if: ${{ github.ref == 'refs/heads/main' }}
Code language: JavaScript (javascript)
In many if: conditions, the outer ${{ }} is optional:
if: github.ref == 'refs/heads/main'
Code language: JavaScript (javascript)
7.5 Useful operators
==
!=
&&
||
!
Example:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Code language: JavaScript (javascript)
7.6 Useful functions
Examples:
if: startsWith(github.ref, 'refs/tags/v')
Code language: JavaScript (javascript)
if: contains(github.event.pull_request.labels.*.name, 'run-full-ci')
Code language: JavaScript (javascript)
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
Code language: JavaScript (javascript)
You do not need to memorize all expression functions. Learn how to recognize and look them up.
7.7 Conditions
A common conditional step:
- name: Main branch only
if: github.ref == 'refs/heads/main'
run: echo "Running on main"
Code language: PHP (php)
Common status functions:
if: success()
if: failure()
if: cancelled()
if: always()
Code language: HTTP (http)
Example cleanup:
- name: Collect diagnostics
if: failure()
run: echo "Tests failed - collecting diagnostics"
Code language: PHP (php)
8. Secrets and GITHUB_TOKEN
Secrets and permissions are where beginner workflows can accidentally become unsafe.
8.1 What is a secret?
A secret is sensitive information stored in GitHub instead of being written directly into the workflow file.
Examples:
- API tokens;
- passwords;
- package registry tokens;
- signing credentials;
- webhook tokens.
Do not do this:
env:
API_TOKEN: abc123-real-secret
Instead, create a GitHub secret and reference it:
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
8.2 Secret scopes
Secrets can be configured at different scopes, including:
- repository;
- organization;
- environment.
For beginners, repository secrets are the easiest to understand.
For deployments, environment secrets are often better because production and staging can use different credentials.
8.3 Using a secret safely
Prefer passing a secret as environment data:
- name: Call API
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./call-api.sh
Avoid printing secrets:
echo "$API_TOKEN"
Code language: PHP (php)
GitHub masks many known secret values in logs, but masking should not be treated as a complete data-loss prevention system.
8.4 Fork pull requests
Untrusted pull requests do not automatically receive powerful repository secrets.
That is intentional.
The safe mental model is:
Untrusted pull request
|
v
Low-privilege CI
|
v
Test / validate
|
v
Trusted workflow handles privileged work
Do not design ordinary PR testing so that arbitrary code requires production credentials.
8.5 What is GITHUB_TOKEN?
GitHub automatically creates a short-lived token for workflow jobs.
You usually do not need to manually create or rotate it.
A workflow can access it as:
${{ secrets.GITHUB_TOKEN }}
or:
${{ github.token }}
8.6 Permissions
Always start with the minimum permissions required.
A safe CI baseline:
permissions:
contents: read
A job that needs to create or modify repository content may need more:
permissions:
contents: write
Do not grant write permission to every job just because one job needs it.
8.7 Workflow-level vs job-level permissions
Example:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "Read-only CI"
publish:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- run: echo "Publishing needs additional permission"
Code language: PHP (php)
This keeps the test job less privileged.
8.8 Beginner security rule
Remember:
Secrets protect sensitive values.
Permissions control what the workflow identity can do.
Code language: JavaScript (javascript)
They solve different problems.
9. Hands-On Lab: Build Your First CI Pipeline
This is the main practical exercise.
You will build one CI workflow step by step.
The example assumes a Node.js project with commands similar to:
npm ci
npm run lint
npm test
npm run build
If your project uses different commands, adapt them.
9.1 Goal
By the end, the workflow will look like this:
Push / Pull Request
|
v
Checkout
|
v
Setup Node.js
|
v
Install dependencies
|
v
Lint
|
v
Test
|
v
Build
|
v
Upload build artifact
9.2 Step 1 – Create the workflow
Create:
.github/workflows/ci.yml
Start with:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Say hello
run: echo "Starting CI"
Code language: PHP (php)
Commit and push.
Confirm that the workflow starts.
9.3 Step 2 – Checkout the repository
Replace the hello-only workflow with:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: List repository files
run: ls -la
Code language: HTTP (http)
Run it.
The ls -la command should now show repository files.
9.4 Step 3 – Setup Node.js
Add:
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
Code language: JavaScript (javascript)
Then verify:
- name: Check Node version
run: node --version
Your steps now include:
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
- name: Check Node version
run: node --version
Code language: JavaScript (javascript)
9.5 Step 4 – Install dependencies
Add:
- name: Install dependencies
run: npm ci
Why npm ci instead of npm install in CI?
npm ci is designed for clean, repeatable installation based on the lock file.
9.6 Step 5 – Lint
Add:
- name: Lint
run: npm run lint
If linting fails, the job normally stops and the workflow fails.
That is desirable in CI when lint is a required quality gate.
9.7 Step 6 – Test
Add:
- name: Test
run: npm test
Now the pipeline validates behavior, not only syntax/style.
9.8 Step 7 – Build
Add:
- name: Build
run: npm run build
A successful run now proves:
Repository can be checked out
Dependencies can be installed
Code passes lint
Tests pass
Application can build
9.9 Step 8 – Add a timeout
Jobs should not run forever.
Add:
timeout-minutes: 15
Code language: HTTP (http)
Example:
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 15
9.10 Step 9 – Final baseline CI workflow
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
Code language: HTTP (http)
9.11 What happens when npm test fails?
Execution normally looks like:
Checkout ........ PASS
Setup Node ...... PASS
Install ......... PASS
Lint ............ PASS
Test ............ FAIL
Build ........... NOT RUN
Workflow ........ FAIL
That is standard fail-fast behavior for sequential steps.
9.12 Add diagnostics on failure
Add:
- name: Failure diagnostics
if: failure()
run: |
echo "A previous step failed"
echo "Commit: $GITHUB_SHA"
echo "Runner: $RUNNER_OS"
Code language: PHP (php)
This introduces conditional steps.
9.13 Add concurrency for pull request CI
When developers push several commits quickly, old CI runs can become wasteful.
Add at workflow level:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Code language: JavaScript (javascript)
Complete baseline:
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Failure diagnostics
if: failure()
run: |
echo "CI failed"
echo "Commit: $GITHUB_SHA"
Code language: PHP (php)
You now have a useful real-world CI workflow.
10. Matrix Builds
A matrix allows one job definition to run multiple combinations.
10.1 Why use a matrix?
Suppose your application supports Node.js 22 and 24.
Without a matrix, you could create two nearly identical jobs.
That duplicates YAML.
With a matrix:
strategy:
matrix:
node: [22, 24]
Code language: CSS (css)
GitHub expands the job.
Test job
|
+--> Node 22
|
+--> Node 24
10.2 Basic matrix workflow
name: Matrix CI
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Code language: HTTP (http)
GitHub creates separate jobs for the matrix values.
10.3 Multi-dimensional matrices
Example:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
runs-on: ${{ matrix.os }}
This expands to four combinations:
| OS | Node |
|---|---|
| Ubuntu | 22 |
| Ubuntu | 24 |
| Windows | 22 |
| Windows | 24 |
Do not create large matrices just because you can. Every combination costs time and compute.
10.4 fail-fast
If one matrix job fails, GitHub can cancel other in-progress matrix jobs according to strategy behavior.
If you want all compatibility results:
strategy:
fail-fast: false
matrix:
node: [22, 24]
Code language: JavaScript (javascript)
10.5 Beginner rule
Use a matrix when you are testing the same logic against multiple supported environments.
Do not use a matrix merely to make YAML look advanced.
11. Caching
Caching speeds up workflows by reusing data that is expensive to download or compute.
11.1 Mental model
Without cache:
Download dependencies -> run build
With cache hit:
Restore dependency cache -> run build
A cache is a performance optimization.
Your build should still be correct if the cache disappears.
11.2 Node.js dependency caching
The simplest approach uses setup-node:
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
Code language: JavaScript (javascript)
Then:
- run: npm ci
The cache reduces repeated dependency download work.
11.3 Cache keys
For manual caching, keys usually include information that determines whether cached content is valid.
Example concept:
OS + runtime + dependency lock hash
Example:
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
Code language: JavaScript (javascript)
11.4 Cache vs artifact
Do not confuse them.
| Cache | Artifact |
|---|---|
| Speeds up future work | Preserves workflow output |
| Usually dependencies/build cache | Usually packages/reports/build output |
| Correctness should not depend on it | Can be part of the delivery pipeline |
| Looked up by cache key | Uploaded/downloaded by artifact name or ID |
11.5 Cache security
Cached content can become a supply-chain risk if untrusted workflows can poison caches later consumed by privileged jobs.
Beginner rule:
Treat cache as untrusted performance data, not as proof that software is safe.
12. Artifacts
Artifacts are files produced by a workflow and stored by GitHub.
Examples:
- compiled application;
- coverage report;
- test report;
- generated documentation;
- packaged release candidate;
- diagnostic logs.
12.1 Upload an artifact
If your build creates dist/:
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: application
path: dist/
12.2 Add artifact upload to the lab
Update your CI workflow:
- name: Build
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: application-${{ github.sha }}
path: dist/
retention-days: 7
Now the flow becomes:
Checkout
|
Setup
|
Install
|
Lint
|
Test
|
Build
|
Upload artifact
12.3 Download in another job
A separate job can download the artifact:
deploy:
needs: ci
runs-on: ubuntu-latest
steps:
- name: Download build
uses: actions/download-artifact@v5
with:
name: application-${{ github.sha }}
path: dist/
- name: Inspect
run: ls -la dist/
12.4 Build once, deploy many
A mature CI/CD design often creates one immutable artifact and promotes that exact artifact.
Commit
|
v
Build once
|
v
Artifact
|
+--> Staging
|
+--> UAT
|
+--> Production
This is better than rebuilding different binaries independently for each environment when consistency matters.
13. From CI to CD and Environments
Once CI proves the code is acceptable, CD moves a release toward an environment.
13.1 Basic lifecycle
Developer
|
v
Push / Pull Request
|
v
CI
|
+--> Lint
+--> Test
+--> Build
|
v
Artifact
|
v
Staging
|
v
Approval / Protection
|
v
Production
13.2 GitHub environments
A GitHub environment can represent a deployment target such as:
development
staging
uat
production
Environments can be used with:
- environment secrets;
- environment variables;
- deployment approvals;
- branch restrictions;
- deployment protection rules;
- deployment history.
13.3 Reference an environment
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
13.4 A safer deployment shape
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: ./build.sh
- uses: actions/upload-artifact@v4
with:
name: release
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v5
with:
name: release
path: dist/
- run: ./deploy.sh dist/
The key design idea is:
Build first
Verify first
Then deploy
13.5 Approvals
A production environment can require reviewers before a deployment job receives protected environment access.
Conceptually:
Deployment requested
|
v
Environment gate
|
+--> Rejected --> Stop
|
+--> Approved --> Deploy
For a two-hour course, understand the concept. Detailed environment governance belongs in the advanced course.
14. GitHub Actions Security: 5 Rules
If students remember only five security rules, use these.
Rule 1 – Use least privilege
Start with:
permissions:
contents: read
Add write permissions only when a specific job needs them.
Bad beginner habit:
permissions: write-all
Code language: HTTP (http)
Avoid broad permissions unless there is a strong, reviewed reason.
Rule 2 – Never hard-code secrets
Bad:
env:
PASSWORD: my-password-123
Good:
env:
PASSWORD: ${{ secrets.PASSWORD }}
Rule 3 – Do not print secrets
Bad:
echo "$PASSWORD"
Code language: PHP (php)
Also avoid writing secrets into artifacts or caches.
Rule 4 – Treat Actions as code dependencies
An Action can execute code in your job.
That means an Action may be able to access:
- repository files;
- available secrets;
- job token permissions;
- the network;
- workflow outputs.
Prefer trusted Actions and review third-party Actions before allowing them into privileged workflows.
Rule 5 – Treat pull request input as untrusted
This can be dangerous:
- run: echo "${{ github.event.pull_request.title }}"
Code language: PHP (php)
Why?
A pull request title is user-controlled data. Interpolating untrusted values directly into shell syntax can create command-injection risks.
Safer:
- name: Print title safely
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE"
Code language: PHP (php)
The general rule is:
Untrusted input should be treated as data,
not as executable shell syntax.
Code language: JavaScript (javascript)
14.1 One more critical PR rule
pull_request and pull_request_target are not interchangeable.
For beginner CI that executes pull request code, prefer a low-privilege pull_request workflow.
Do not combine a privileged base-repository context with execution of untrusted pull request code unless you fully understand the security model.
15. Troubleshooting: The 7-Step Method
Do not debug GitHub Actions by randomly editing YAML.
Use layers.
15.1 Debugging flow
Problem
|
v
1. Did workflow start?
|
+-- No --> Trigger / branch / path / YAML
|
+-- Yes
|
v
2. Did job start?
|
+-- No --> needs / if / runner / environment
|
+-- Yes
|
v
3. Which step failed?
|
v
4. Read first useful error
|
v
5. Classify error
|
+-------+-------+-------+-------+
| | | | |
YAML Command Auth Network Runtime
15.2 Step 1 – Did the workflow start?
If no workflow run appears, check:
- Is the file under
.github/workflows/? - Is the YAML valid?
- Did the expected event happen?
- Does
onmatch the event? - Does the branch filter match?
- Does the path filter match?
- Is the workflow enabled?
15.3 Step 2 – Did the job start?
If the workflow exists but a job is skipped or queued, check:
ifcondition;needsdependency;- runner label;
- environment approval;
- runner availability.
15.4 Step 3 – Which step failed?
Open the failing job and locate the first failed step.
Avoid focusing only on the last red line. The useful error may appear earlier.
15.5 Step 4 – Is it YAML or application logic?
Example workflow error:
Invalid workflow file
Usually means syntax, indentation, expression, or unsupported configuration.
Example application error:
npm test exited with code 1
Code language: JavaScript (javascript)
That may mean the workflow works correctly but the test failed.
This distinction matters.
15.6 Step 5 – Check authentication and permissions
Useful mental model:
401 = authentication problem is likely
403 = identity exists but permission/policy may deny
404 = resource may not exist or may be hidden by authorization
Check:
permissions;- secret availability;
- token scope;
- environment approval;
- fork restrictions.
15.7 Step 6 – Check the runner environment
Print selected values:
- name: Runner diagnostics
run: |
echo "OS=$RUNNER_OS"
echo "ARCH=$RUNNER_ARCH"
echo "WORKSPACE=$GITHUB_WORKSPACE"
pwd
df -h
Code language: PHP (php)
For self-hosted runners also check:
- runner online status;
- labels;
- disk space;
- network access;
- service health;
- file permissions.
15.8 Step 7 – Reproduce the failing command
If this fails in Actions:
- run: npm test
try to reproduce the same command with the same runtime and dependencies locally or in a controlled container.
The workflow may simply be exposing a real application issue.
15.9 Common mistakes
| Symptom | Likely cause |
|---|---|
| Workflow does not run | Trigger/filter/path/YAML issue |
| Job is skipped | if or failed needs dependency |
| Job stays queued | No matching runner or capacity |
command not found | Tool missing or PATH issue |
| File missing in second job | Jobs do not share filesystem |
| Secret empty | Secret unavailable in that context/scope |
| API returns 403 | Token lacks permission |
| Cache always misses | Bad key or changing inputs |
| Artifact missing | Wrong path or build did not create file |
| Works locally, fails in CI | Environment/runtime/dependency difference |
16. Final Hands-On Challenge
Start with this incomplete workflow:
name: CI
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: echo "TODO"
Code language: PHP (php)
Complete as many tasks as possible without copying the final solution.
Challenge tasks
- Add the
pull_requesttrigger. - Make
pushrun only formain. - Add
workflow_dispatch. - Add
permissions: contents: read. - Add a 15-minute job timeout.
- Setup Node.js 24.
- Enable npm caching.
- Run
npm ci. - Run
npm run lint. - Run
npm test. - Run
npm run build. - Upload
dist/as an artifact. - Name the artifact using
github.sha. - Add a failure-diagnostics step.
- Convert Node.js versions 22 and 24 into a matrix.
One possible solution
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: [22, 24]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: application-node-${{ matrix.node }}-${{ github.sha }}
path: dist/
retention-days: 7
- name: Failure diagnostics
if: failure()
run: |
echo "Workflow: $GITHUB_WORKFLOW"
echo "Commit: $GITHUB_SHA"
echo "Runner: $RUNNER_OS/$RUNNER_ARCH"
Code language: PHP (php)
If you can explain every line in this workflow, you have learned the essential GitHub Actions model.
17. GitHub Actions Essential Cheat Sheet
17.1 Workflow location
.github/workflows/*.yml
17.2 Minimal workflow
name: CI
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "Hello"
Code language: PHP (php)
17.3 Common triggers
on:
push:
pull_request:
workflow_dispatch:
17.4 Push only to main
on:
push:
branches: [main]
Code language: CSS (css)
17.5 Checkout
- uses: actions/checkout@v6
17.6 Setup Node
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
Code language: JavaScript (javascript)
17.7 Run a command
- run: npm test
17.8 Multiline shell
- run: |
npm ci
npm test
npm run build
17.9 Job dependency
needs: build
Code language: HTTP (http)
17.10 Condition
if: github.ref == 'refs/heads/main'
Code language: JavaScript (javascript)
17.11 Secret
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
17.12 Read-only permissions
permissions:
contents: read
17.13 Context values
${{ github.repository }}
${{ github.sha }}
${{ github.ref_name }}
17.14 Matrix
strategy:
matrix:
node: [22, 24]
Code language: CSS (css)
Use:
${{ matrix.node }}
17.15 Upload artifact
- uses: actions/upload-artifact@v4
with:
name: application
path: dist/
17.16 Download artifact
- uses: actions/download-artifact@v5
with:
name: application
path: dist/
17.17 Cancel stale CI
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Code language: JavaScript (javascript)
17.18 Failure-only step
- if: failure()
run: echo "Something failed"
Code language: PHP (php)
17.19 Environment
environment: production
Code language: HTTP (http)
17.20 The five security rules
1. Least privilege.
2. Never hard-code secrets.
3. Never print secrets.
4. Treat Actions as executable dependencies.
5. Treat pull request input as untrusted.
Code language: PHP (php)
18. 15-Question Knowledge Check
Try to answer before reading the answer key.
Questions
1. Where must GitHub Actions workflow files normally be stored?
A. .github/actions/ B. .github/workflows/ C. .git/workflows/ D. workflows/
2. What does on define?
A. The runner B. Workflow permissions C. Workflow triggers D. Workflow artifacts
3. What is the difference between a job and a step?
4. Do two jobs automatically share the same filesystem?
5. What does runs-on select?
6. What is the difference between uses and run?
7. What does needs: build do?
8. Which should hold an API password?
A. vars B. secrets C. github D. runner
9. What does ${{ github.sha }} represent?
10. What problem does a matrix solve?
11. What is the main purpose of a cache?
12. What is the main purpose of an artifact?
13. Why should workflow permissions be minimal?
14. Why is direct interpolation of pull request titles into shell commands risky?
15. If a workflow does not start at all, what should you check first?
Answer key
1. B – .github/workflows/
That is the standard workflow directory.
2. C – Workflow triggers
on tells GitHub when the workflow is eligible to run.
3. Job vs step
A job is independently scheduled to a runner. A step is one ordered task inside a job.
4. No
Jobs normally run on separate runner environments. Use artifacts, outputs, or another explicit mechanism to transfer data.
5. The runner
Example:
runs-on: ubuntu-latest
Code language: HTTP (http)
6. uses vs run
uses invokes an Action. run executes a shell command.
7. It creates a dependency
The dependent job waits for build and normally only runs if the dependency succeeds.
8. B – secrets
Sensitive values should not be ordinary configuration variables.
9. The commit SHA
It identifies the commit associated with the workflow run.
10. Repeated compatibility combinations
A matrix expands one job definition across multiple values such as runtime versions or operating systems.
11. Performance
A cache reduces repeated download or computation work.
12. Retaining workflow output
Artifacts preserve files such as build output, reports, or packages.
13. Reduce blast radius
If a job or dependency is compromised, least privilege limits what the workflow identity can do.
14. Command injection risk
Pull request metadata is user-controlled. It should be passed as data rather than inserted directly into executable shell syntax.
15. Trigger and workflow definition
Check the workflow location, YAML validity, event, branch/path filters, and whether the workflow is enabled.
19. What to Learn Next
After completing this tutorial, you should be comfortable with:
- workflows;
- triggers;
- jobs;
- steps;
- runners;
- Actions;
- variables;
- contexts;
- expressions;
- secrets;
GITHUB_TOKEN;- permissions;
- matrix builds;
- caching;
- artifacts;
- basic CI/CD flow;
- environments;
- security basics;
- troubleshooting basics.
The next learning sequence should be:
GitHub Actions Essentials
|
v
Reusable Workflows
|
v
Advanced Security
|
v
OIDC / Cloud Authentication
|
v
Docker CI/CD
|
v
Cloud / Kubernetes Deployment
|
v
Infrastructure as Code
|
v
Self-hosted Runners / ARC
|
v
Organization and Enterprise Governance
Code language: PHP (php)
19.1 Recommended next topics
Reusable automation
Learn:
- reusable workflows;
- composite actions;
- workflow inputs;
- workflow outputs;
- centralized CI standards.
Advanced security
Learn:
pull_requestvspull_request_target;- third-party Action pinning;
- cache poisoning;
- supply-chain trust;
- branch protection;
- rulesets;
- environment protection.
Cloud authentication
Learn OpenID Connect so workflows can obtain short-lived cloud credentials instead of storing long-lived cloud access keys.
Containers
Learn:
- Docker build and push;
- container registries;
- image tags and digests;
- image scanning;
- SBOMs;
- provenance.
Kubernetes
Learn:
- secure cluster authentication;
- Helm or Kustomize;
- rollout verification;
- namespace and identity isolation;
- GitOps patterns.
Infrastructure as Code
Learn a controlled pipeline such as:
fmt
|
v
validate
|
v
security checks
|
v
plan
|
v
review / approval
|
v
apply
Runner architecture
Learn:
- self-hosted runners;
- ephemeral runners;
- runner groups;
- private networking;
- Actions Runner Controller;
- trust-zone separation.
Final Summary
If you remember only one model, remember this:
Event
|
v
Workflow
|
v
Jobs
|
v
Runners
|
v
Steps
|
v
Actions / Commands
And if you remember only one production pipeline shape, remember this:
Change
|
v
CI Trigger
|
v
Checkout
|
v
Install
|
v
Lint
|
v
Test
|
v
Build
|
v
Artifact
|
v
Protected Deployment
Code language: PHP (php)
And if you remember only one security principle:
Give every workflow, job, runner, Action, and credential only the access it actually needs.
That foundation is enough to start building useful GitHub Actions workflows safely and confidently.
I’m Rajesh Kumar, a DevOps, SRE, DevSecOps, Cloud, and Platform Engineering expert passionate about sharing practical knowledge, real-world experiences, and industry best practices. I have worked at Cotocus and regularly write about technology, travel, investing, health, product reviews, and digital marketing through my various platforms.
I publish technical articles at DevOps School, travel stories at Holiday Landmark, stock market insights at Stocks Mantra, health and fitness guidance at My Medic Plus, product reviews at TrueReviewNow, and SEO and digital marketing strategies at Wizbrand.
Find Trusted Cardiac Hospitals
Compare heart hospitals by city and services — all in one place.
Explore Hospitals