GitHub Actions Tutorial in 2026 (CI/CD for Real Projects)

GitHub Actions Tutorial in 2026 (CI/CD for Real Projects)
A practical onboarding guide — workflows, triggers, runners, secrets, marketplace, and real examples you can ship today.
TL;DR
- GitHub Actions is YAML-based CI/CD that lives in
.github/workflows/right next to your code — no separate platform to manage. - You define triggers (push, pull_request, schedule, workflow_dispatch), jobs (parallel by default), and steps (sequential shell commands or marketplace actions).
- GitHub-hosted runners are free for public repos and metered for private ones; self-hosted runners give you more power and access to internal networks.
- Secrets and variables are scoped (repo, environment, organization), and the marketplace has 20,000+ pre-built actions for nearly every CI task.
- The biggest wins in 2026: cache aggressively, use matrix builds for cross-version testing, gate deploys with environments, and fail fast on linting before running expensive tests.
Why GitHub Actions still wins in 2026
If you opened your first repository in the last few years, GitHub Actions probably feels invisible — it's just there, running checks on your pull requests, deploying things on merge, and quietly turning red when something breaks. That invisibility is the point. Unlike Jenkins, CircleCI, or Travis, Actions doesn't ask you to maintain a separate service or learn a separate identity model. Your CI lives in the same repo as your code, runs under the same permissions, and ships in the same pull request.
Five years in, the platform has matured into something genuinely production-grade. Teams ship Kubernetes deploys, mobile builds, ML training pipelines, and infrastructure-as-code rollouts on Actions. The 2026 reality is that almost every modern open-source project — and most private SaaS teams under a few thousand engineers — runs CI/CD here by default. The friction of switching to a third-party platform isn't worth the marginal feature gain.
This tutorial walks you from "I've never written a workflow" to "I can ship a real CI/CD pipeline with caching, secrets, matrix builds, and deploys." We'll cover the YAML model, every trigger type, runner choices, secret management, the marketplace, and the workflows you'll actually copy-paste into real projects. By the end, you'll know enough to read most workflow files in the wild and write your own without fighting the docs.
The mental model: workflows, jobs, steps
Before any YAML, internalize three nouns. A workflow is a single YAML file in .github/workflows/ that describes one automated process — for example, "run tests on every push." A workflow contains one or more jobs, which run in parallel by default on separate virtual machines. Each job contains steps, which run sequentially inside that job's VM and share its filesystem.
So if you have a workflow with two jobs, GitHub spins up two fresh Ubuntu (or whatever you choose) machines, runs the steps in each, and reports back. If you want a job to wait for another to finish — typical for "build, then deploy" — you use needs:. That's the entire mental model. Everything else is configuration on top.
Workflow YAML basics
Here's the smallest workflow that does something useful. Drop this in .github/workflows/ci.yml and push:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
That's a complete CI pipeline. Six elements matter:
name— what shows up in the GitHub UI. Optional but worth setting.on— the triggers. Here we run on every push and every pull request to any branch.jobs— the map of jobs. We have one calledtest.runs-on— which runner image to use.ubuntu-latestis the cheapest and most common.steps— the ordered list.uses:pulls in a marketplace action;run:executes a shell command.- Versions — pin to major versions like
@v4. Don't pin to@latest— it doesn't exist as a tag, and unpinned references break reproducibility.
YAML is whitespace-sensitive. Two-space indents, no tabs, and watch out for strings that start with reserved characters (:, {, *) — wrap them in quotes when in doubt. The single most common new-user mistake is mismatched indentation between steps: children, which produces cryptic parser errors.
Triggers: push, PR, schedule, manual, and more
The on: key is where most of your control lives. Real projects use a mix of triggers depending on what each workflow is for.
Push. Run on every push to any branch, or filter to specific branches and paths. This is your default for fast feedback.
on:
push:
branches: [main, dev]
paths:
- 'src/**'
- 'package.json'
Pull request. Run on PR open, sync, or reopen. Crucial for required checks. Use pull_request_target only if you understand the security model — it runs with the base branch's secrets, which is dangerous for forks.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
Schedule. Cron-style scheduled runs. Use this for nightly builds, dependency audits, link checks, or scheduled deploys. The cron is in UTC.
on:
schedule:
- cron: '0 6 * * *' # 6:00 UTC daily
Manual (workflow_dispatch). Adds a "Run workflow" button to the Actions tab. Perfect for releases, hotfix deploys, and one-off scripts. You can declare typed inputs.
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
default: staging
Other triggers worth knowing: release (publish-on-release), issues and issue_comment (bots that triage), repository_dispatch (trigger from external API), and workflow_call (reusable workflows that other workflows invoke).
Jobs and steps in detail
Jobs run on separate runners, so they don't share files by default. If you build artifacts in one job and want to deploy them in another, you upload them with actions/upload-artifact and download in the second job. Inside a single job, all steps share the same filesystem and environment.
Use needs: to express dependencies. Use if: on a job or step to conditionally run it. Use strategy.matrix to fan out into multiple parallel runs of the same job — for example, testing on Node 18, 20, and 22 simultaneously.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This pipeline lints first (cheap, fast), and only runs the matrix of tests if linting passes. If the matrix has a single failing version, by default it cancels the others — set fail-fast: false if you want full visibility.
Runners: GitHub-hosted vs self-hosted
GitHub-hosted runners are fresh VMs that spin up per job, run your steps, and disappear. They come pre-loaded with common tools (Node, Python, Docker, AWS CLI, etc.). They're the default and the right choice for 95% of teams.
| Aspect | GitHub-hosted | Self-hosted |
|---|---|---|
| Setup | Zero — works out of the box | You install and maintain the runner agent |
| Cost | Free for public repos; metered minutes for private | Free runtime, but you pay for the hardware |
| Performance | Standard sizes (2-core, 4-core, larger SKUs available) | As powerful as the box you provision |
| Security | Ephemeral, isolated | You're responsible for hardening, especially for public repos |
| Network | Public internet only | Can reach internal networks, VPNs, private services |
| Best for | Most CI workloads, OSS, web apps, mobile | GPU workloads, large monorepos, internal infrastructure |
Pick a self-hosted runner when you need GPUs for ML, when CI on hosted runners takes 30+ minutes for a build, or when your tests need to hit private infra. Otherwise, stay on hosted — the operational savings dwarf any minute cost.
Secrets and variables
Never commit credentials to your repo. GitHub gives you three layers of secret storage: organization-level, repository-level, and environment-level. They're encrypted at rest and only exposed to workflows as environment variables.
Add them in Settings → Secrets and variables → Actions, then reference with ${{ secrets.NAME }}:
steps:
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: ./deploy.sh
Variables (the non-secret cousin) work the same but are visible in logs. Use them for things like build flags, region names, and feature toggles — anything that's environment-specific but not sensitive.
Environments deserve special mention. Define an environment like production in repo settings, attach secrets and protection rules to it (required reviewers, wait timers, branch restrictions), and reference it from a job:
jobs:
deploy:
environment: production
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
Now production deploys can require manual approval from a specific reviewer before the job actually runs. This is the cleanest pattern for adding human gates to CD.
The marketplace: don't reinvent
The GitHub Actions marketplace has tens of thousands of pre-built actions. Most things you'd want to do — checkout code, set up a language runtime, cache dependencies, deploy to AWS, post to Slack, lint Markdown — already have a battle-tested action. Reach for the marketplace before writing custom shell.
The actions you'll use in nearly every workflow:
actions/checkout@v4— clones your repo into the runner. Almost every workflow's first step.actions/setup-node@v4,actions/setup-python@v5,actions/setup-go@v5— install language runtimes with optional caching built in.actions/cache@v4— cache anything (dependencies, build outputs) across runs.actions/upload-artifact@v4andactions/download-artifact@v4— pass files between jobs.docker/build-push-action@v6— build and push Docker images, with BuildKit caching.aws-actions/configure-aws-credentials@v4,google-github-actions/auth@v2,azure/login@v2— OIDC-based cloud auth.
Pin third-party actions to a SHA, not a tag. A tag like @v1 can be moved to point at malicious code. A commit SHA cannot. For first-party GitHub-owned actions, major-version tags are fine — they have stronger guarantees.
Common workflows you'll actually use
Here are the patterns that cover most real-world needs. Copy, adapt, ship.
1. Test and lint on every PR
name: CI
on:
pull_request:
push:
branches: [main]
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 run lint
- run: npm test -- --coverage
2. Build and deploy on merge to main
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: us-east-1
- run: npm ci && npm run build
- run: aws s3 sync ./dist s3://my-bucket --delete
3. Tag-triggered release
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- uses: softprops/action-gh-release@v2
with:
files: dist/*
4. Scheduled dependency audit
name: Audit
on:
schedule:
- cron: '0 9 * * 1' # Mondays 9 UTC
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=high
5. Matrix build across versions and OSes
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
That single block runs nine parallel jobs (3 OSes × 3 Node versions). Hugely valuable for libraries; overkill for application code.
Caching: the single biggest speedup
Out-of-the-box, every workflow run starts fresh. npm ci downloads everything, Docker builds without layer cache, Maven re-resolves every dependency. On a real project this is the difference between a 90-second CI and a 12-minute one.
The fix: cache. The setup-* actions have built-in caching — pass cache: 'npm' to setup-node and you're done. For anything else, use actions/cache@v4 directly:
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
.venv
key: ${{ runner.os }}-py-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-py-
The cache key should change when the inputs change (here, when requirements.txt changes). The restore-keys are fallbacks — if the exact key misses, GitHub looks for the most recent partial match.
For Docker, use docker/build-push-action with cache-from and cache-to set to type=gha. That stores BuildKit layers in the Actions cache, often cutting image builds by 80% or more.
Cost considerations
For public repositories on GitHub-hosted runners, Actions is free. Period. For private repos, GitHub bundles a generous monthly minute allowance with paid plans, then charges per minute beyond that. Linux is the cheapest, Windows is double, macOS is ten times. So:
- Default to Ubuntu unless your code is platform-specific.
- Use matrix builds judiciously — every cell is a billable run.
- Fail fast: lint before tests, fast tests before slow integration tests, so failures cost less.
- Cache aggressively — fewer minutes per run.
- Cancel in-progress runs when new commits arrive on the same PR with
concurrency:.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
That single block can cut your minute usage by 30-50% on active branches because old runs get killed the moment a new commit lands.
Common mistakes (and how to avoid them)
Habits that save you
- Pin third-party actions to a commit SHA.
- Use
concurrencyto cancel superseded runs. - Cache language dependencies and Docker layers.
- Lint before testing, test before deploying — fail fast.
- Use environments with required reviewers for production.
- Prefer OIDC over long-lived cloud credentials.
- Set
permissions:at the workflow or job level — don't rely on the default token scope.
Mistakes that bite
- Using
pull_request_targetfor forks without understanding the security implications. - Hardcoding secrets in workflow files (they'll be redacted in logs but still committed in git).
- Running every workflow on every push without path filters.
- No timeout on jobs — a hung step can burn an hour of minutes.
- Pinning to
@latestor floating tags from third parties. - Not setting
fail-fast: falsewhen you need full matrix visibility. - Skipping caching because "it's just one project" — you'll regret it at scale.
Add timeout-minutes: to every job. The default is 360 minutes (six hours). A runaway test loop or a network hang can chew through your monthly budget while you sleep. Ten or fifteen minutes is usually plenty.
FAQ
How is GitHub Actions different from CircleCI or Jenkins?
Actions lives inside GitHub — same auth, same UI, same repo. CircleCI and Jenkins are separate platforms you connect to GitHub via webhooks. Functionally they're similar; operationally Actions has way less overhead because there's no second service to maintain. Jenkins still wins for teams that need deep customization on private infrastructure or have legacy pipelines too expensive to migrate.
Are GitHub Actions free?
For public repositories, yes — unlimited minutes on standard runners. For private repos, every paid GitHub plan includes a monthly minute allowance (2,000 to 50,000+ depending on tier), and you pay for additional minutes. macOS and Windows minutes cost more than Linux.
Can I run Actions on my own hardware?
Yes — install the self-hosted runner agent on any Linux, macOS, or Windows machine. The runner polls GitHub for jobs and executes them locally. Useful for GPU workloads, internal network access, or saving on minute costs at high volume.
How do I share secrets with my workflow safely?
Add them in repo or organization settings, reference them as ${{ secrets.NAME }}, and they'll be injected as environment variables at runtime. Logs automatically redact them. For deploys, prefer OIDC federation over storing long-lived credentials.
What's the difference between a workflow, a job, and a step?
A workflow is one YAML file describing one automated process. A workflow has one or more jobs, each running on its own runner. Each job has steps that run sequentially on that runner. Jobs run in parallel by default; steps inside a job run in order.
Why is my workflow not running?
The most common reasons: the file isn't in .github/workflows/, the YAML has a parse error (check the Actions tab for the exact line), the trigger doesn't match (e.g., your branches: filter excludes the branch you pushed), or the workflow is disabled because the repo has been inactive for over 60 days on a free plan.
How do I trigger one workflow from another?
Use workflow_call in the called workflow and reference it in the caller's uses: field. This makes the called workflow reusable across repos in your org. For event-style triggers, use repository_dispatch with a custom event type.
Can I skip CI for trivial commits?
Yes — include [skip ci], [ci skip], or [skip actions] anywhere in your commit message. GitHub will skip workflow runs triggered by that commit. Useful for docs-only or comment-only changes.
Bottom Line
GitHub Actions in 2026 isn't a side feature — it's the default CI/CD substrate for most software projects shipping on GitHub. The platform is stable, the marketplace is deep, and the YAML model is simple enough to write by memory after a few weeks. The real difference between teams that love their CI and teams that hate it isn't the platform — it's the discipline. Cache. Pin SHAs. Fail fast. Use environments. Set timeouts. Cancel superseded runs. Do those six things and your pipelines stay fast, cheap, and trustworthy.
Start with the basic test workflow above. Add deploy when you're confident. Layer on matrix and caching when you feel the pain. Don't over-engineer on day one — Actions rewards iteration, not architecture astronautics.
Key Takeaways
- Workflows live in
.github/workflows/*.yml— one file per pipeline, YAML-defined, version-controlled with your code. - Triggers cover everything: push, PR, schedule, manual dispatch, releases, issues, external API calls, and reusable workflow calls.
- Jobs run in parallel on separate runners; steps run sequentially inside one. Use
needs:to chain,matrix:to fan out. - GitHub-hosted runners cover 95% of use cases. Reach for self-hosted only when you need GPUs, internal network access, or are scaling past the minute economics.
- Use the marketplace before writing custom shell, but pin third-party actions to commit SHAs for security.
- Cache aggressively and set concurrency cancellation — these two habits cut CI time and cost more than anything else.
- Gate production deploys with environments, required reviewers, and OIDC-based cloud credentials.
- Always add
timeout-minutes:andpermissions:at the workflow or job level. Defaults are too permissive.
Create Your Free Link-in-Bio Page
Join thousands of creators using UniLink. 40+ blocks, analytics, e-commerce, and AI tools — all free.
Get Started Free