Git & GitHub Module 12 – Hooks and CI
Run tests on every push with GitHub Actions
Write a GitHub Actions workflow that tests every push and pull request on two Python versions, with a read-only token, pinned actions and a required check.
What you will learn
- Write a workflow that runs your tests on pushes to main, pull requests and a weekly schedule
- Test several Python versions with a matrix, and decide what a dependency cache may hold
- Pass credentials as secrets through env, and limit GITHUB_TOKEN with permissions
- Secure a workflow by pinning actions to full commit SHAs and passing untrusted text to scripts as data
- Configure a ruleset that requires one summary check before anything merges into main
Before you start
What you’ll learn · Before you start
What you will learn
- Write a workflow that runs your tests on pushes to main, pull requests and a weekly schedule
- Test several Python versions with a matrix, and decide what a dependency cache may hold
- Pass credentials as secrets through env, and limit GITHUB_TOKEN with permissions
- Secure a workflow by pinning actions to full commit SHAs and passing untrusted text to scripts as data
- Configure a ruleset that requires one summary check before anything merges into main
Before you start
On this page
The fest budget project of this lesson has 5 tests. A Git hook can run them before each commit, but anyone can skip it
with --no-verify, and a fresh clone does not even have it. GitHub Actions runs them on GitHub’s machines instead,
whenever something happens in the repository. You describe the run in a workflow (GitHub Actions): A YAML file in a repository's .github/workflows folder that names the events that start it, such as a push or a pull request, and the jobs it runs., a YAML
file in the repository’s .github/workflows folder that names the events that start it, such as push and
pull_request, and the jobs to run. Each job runs its steps, shell commands or reusable actions, on a
fresh runner (GitHub Actions): The machine that runs one job of a workflow. A GitHub-hosted runner is a fresh virtual machine for every job, so jobs share no files unless they pass them on., and reports its result as a check that a rule on main can require.
The workflow below starts from the project’s own test command and grows one change at a time: two Python versions, a read-only token, actions pinned to commit SHAs, secrets kept out of the file, untrusted text kept out of scripts, a required check and a weekly schedule.
A workflow runs the command that fails when a test fails
A workflow does not know how to test your project; it runs the command you give it. So start with a command that works on your computer, prints what it checked, and exits with a non-zero status when a test fails. This project formats amounts for a college fest’s budget page.
The project: rupee amounts with Indian digit grouping
format_inr writes an amount in paise as rupees, with the last three digits in one group and the digits before them in
groups of two: one lakh is ₹1,00,000.00, ten lakh is ₹10,00,000.00 and 1,23,45,678.90 rupees is a little over one
crore. The tests check the sizes where the grouping changes, a refund (a negative amount) and amounts under ₹1,000.
fest-money/test_inr.py
"""The tests that the workflow runs on every push and pull request: python -m unittest -v"""
import sys
import unittest
from inr import format_inr
class FormatInrTest(unittest.TestCase):
def test_under_a_thousand(self):
self.assertEqual(format_inr(0), "₹0.00")
self.assertEqual(format_inr(5), "₹0.05")
self.assertEqual(format_inr(99999), "₹999.99")
def test_thousands(self):
self.assertEqual(format_inr(100000), "₹1,000.00")
self.assertEqual(format_inr(9999999), "₹99,999.99")
def test_one_lakh_and_ten_lakh(self):
self.assertEqual(format_inr(10000000), "₹1,00,000.00")
self.assertEqual(format_inr(100000000), "₹10,00,000.00")
def test_crore(self):
self.assertEqual(format_inr(1234567890), "₹1,23,45,678.90")
def test_refund_is_negative(self):
self.assertEqual(format_inr(-250000), "-₹2,500.00")
if __name__ == "__main__":
# The same tests that python -m unittest -v runs, with the report on standard output.
unittest.main(testRunner=unittest.TextTestRunner(stream=sys.stdout, verbosity=2)) Output
test_crore (__main__.FormatInrTest.test_crore) ... ok test_one_lakh_and_ten_lakh (__main__.FormatInrTest.test_one_lakh_and_ten_lakh) ... ok test_refund_is_negative (__main__.FormatInrTest.test_refund_is_negative) ... ok test_thousands (__main__.FormatInrTest.test_thousands) ... ok test_under_a_thousand (__main__.FormatInrTest.test_under_a_thousand) ... ok ---------------------------------------------------------------------- Ran 5 tests in 0.000s OK
This output changes from run to run: the time unittest reports depends on the computer and on what else it is doing; the tests and their results do not
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_inr.py
fest-money/inr.py
"""Rupee amounts for the fest budget page, written with Indian digit grouping: 12,34,567."""
def format_inr(paise: int) -> str:
"""An amount in paise as rupees: 123456789 -> '₹12,34,567.89', -250000 -> '-₹2,500.00'."""
sign = "-" if paise < 0 else ""
rupees, rest = divmod(abs(paise), 100)
digits = str(rupees)
head, tail = digits[:-3], digits[-3:]
groups = []
while head: # in front of the last three digits, the groups have two digits each
groups.insert(0, head[-2:])
head = head[:-2]
return f"{sign}₹{','.join(groups + [tail])}.{rest:02d}" All five tests pass. In the workflow the command will be python -m unittest -v: without a test name, Python’s
unittest discovers every test*.py file in the folder and runs it, and -v lists each test as above. The exit
status is what GitHub reads: 0 marks the step as passed, and anything else fails the step, the job and its check.
Checkpoint
Check: The test command and its exit status
Events start a workflow, and each job gets a fresh runner
This file, saved as .github/workflows/ci.yml, is a complete workflow. GitHub reads every .yml or .yaml file in
that folder:
# .github/workflows/ci.yml, first version: run the tests on every push to main and on every pull request.
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Run the tests
run: python -m unittest -v Step through what happens when you push new commits to a branch that has an open pull request:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A push. You push new commits to a branch that has an open pull request. For GitHub that is a pull_request event of the activity type synchronize.
- The workflow matches. GitHub reads .github/workflows/ci.yml from the commit. Its on lists pull_request, so the job test is queued; push is limited to main, so this push starts no second run.
- A fresh machine. runs-on ubuntu-latest gets the job a new virtual machine with nothing of yours on it. Jobs share no files unless you pass them on.
- actions/checkout. The first step fetches the code under test. For a pull request that is the merge commit GitHub prepares on refs/pull/<number>/merge, your branch joined with the latest main.
- actions/setup-python. The second step installs the Python version that its with asks for, here 3.14.
- The tests. python -m unittest -v runs the 5 tests and exits with status 0, so the step passes, and with it the job. Any other status would fail both.
- The check. The job's result goes back to GitHub as a check on the commit and on the pull request, with a link to the log of every step.
nameis how the workflow appears in the repository’s Actions tab.onlists the events.pushwithbranches: [main]runs the tests onmainafter each merge;pull_requestruns them whenever a pull request is opened, gets new commits or is reopened, its three default activity types. Without the branch filter, every push to a branch with an open pull request would start two runs, one for each event. For a pull request, the code under test is the merge commit that GitHub prepares onrefs/pull/<number>/merge, so it checks your branch combined with the latestmain, not your branch alone.jobsholds one job,test.runs-on: ubuntu-latestasks for a fresh Linux runner. Each job gets a new machine, so jobs share no files unless you pass them on.stepsrun in order on that machine.actions/checkoutfetches the code,actions/setup-pythoninstalls the Python version given inwith, and the last step runs the tests. A step that fails stops the job.
Push the file on a branch and open a pull request, and the checks appear at the bottom of the pull request’s page, with a link to every step’s log.
Checkpoint
Check: Events, jobs, steps and runners
A matrix runs one job for each version
A matrix (GitHub Actions): A job strategy that runs one job for each combination of the values it lists: two Python versions on three operating systems make six jobs. turns one job into several, one for each combination of values. Under
strategy.matrix, the list python-version: ['3.13', '3.14'] makes two jobs that are the same except for
${{ matrix.python-version }}, which the setup step reads. Two lists multiply: two Python versions on three operating
systems are six jobs, and a matrix can make at most 256 jobs in one workflow run. Give the job a name that includes
the matrix value, such as Tests on Python ${{ matrix.python-version }}, so each run is easy to tell apart in the
checks.
Quote version numbers, because YAML reads an unquoted number as a number:
| In the matrix | YAML reads | setup-python is asked for |
|---|---|---|
[3.9, 3.10] |
the numbers 3.9 and 3.1 | Python 3.9 and Python 3.1 |
['3.9', '3.10'] |
the strings “3.9” and “3.10” | Python 3.9 and Python 3.10 |
By default a matrix is fail-fast: when one of its jobs fails, GitHub cancels the jobs still running or queued. For
tests, fail-fast: false is often better, because you learn whether the failure happens on every version or on one.
timeout-minutes stops a hung job early instead of after the default 360 minutes.
A matrix also multiplies the minutes. For public repositories, GitHub Actions is free on GitHub’s standard runners. For private repositories, each account gets a monthly quota: on GitHub Free, 2,000 minutes, 500 MB of artifact storage and, separately, 10 GB of cache for each repository; GitHub Pro raises the minutes to 3,000. Jobs on Windows and macOS runners cost more per minute than on Linux, and once the quota is used up, an account without a payment method cannot run more until the next month. Each run of this lesson’s final workflow starts three Linux jobs: the two test jobs and a summary job.
Caching dependencies
This project needs only the standard library, so it has nothing to cache. A project that installs packages saves time
with a cache: add cache: 'pip' to the with of actions/setup-python, and the action keys a cache on a hash of
requirements.txt or pyproject.toml, restores it on the next run and saves it when the file changes; caching is off
until you ask for it, and cache-dependency-path names other files to hash. A run can restore caches from its own
branch and from the default branch, and a pull request also from its base branch, but never from a sibling branch.
GitHub removes caches that nobody used for 7 days and keeps 10 GB per repository by default. Never put credentials in
a cached folder: anyone who can open a pull request can read what the caches of the base branch hold.
Checkpoint
Check: Matrices and caches
Secrets reach a step through env, and the token gets only what it needs
Some steps need a credential: a token for a code-coverage service, a key for a test environment. Store it as a
repository secret under Settings → Secrets and variables → Actions → New repository secret, or with
gh secret set NAME, and refer to it by name: a step gets it through env, as TOKEN: ${{ secrets.NAME }}, and its
script reads "$TOKEN". Never write a credential into the workflow file itself: the OWASP CI/CD Security Cheat Sheet
rules out hard-coding secrets anywhere in a repository or in a pipeline’s configuration. A committed value stays in the
history of every clone, readable by anyone who can read the repository.
GitHub hides a secret’s value in the logs, but only where the value itself appears, so register any value you derive
from a secret, such as its Base64 form, as a secret too. GitHub’s secure-use guidance also says never to use structured
data, a blob of JSON or YAML, as a secret: create one secret for each sensitive value. A secret cannot be read in an
if: condition directly, and a secret that is not set reads as an empty string.
Which runs get the secrets depends on who wrote the code under test:
| The run | Secrets | GITHUB_TOKEN |
|---|---|---|
push to main, or pull_request from a branch of the same repository |
Yes | The repository’s default, limited by permissions |
pull_request from a fork |
None | Read-only |
Pull requests from forks get no secrets because the pull request’s code runs in that job, and anything that code can read, it could send anywhere. Steps that need a secret, such as a deployment, belong in workflows that run after the merge.
The GITHUB_TOKEN: A token that GitHub creates at the start of every job of a workflow, limited to the workflow's repository and expiring when the job finishes. The permissions key decides what it may do. is a token that GitHub creates at the start of every job and that
expires when the job finishes; its access is limited to the repository that holds the workflow. What it may do depends
on the repository’s default, which in a new repository of a personal account is read access to contents and packages
and in an organisation’s repository is set by the organisation, and on the workflow’s permissions key. The rule
that makes the key useful: once you set the access for any scope, every scope you did not list gets none. So
permissions: { contents: read } at the top of the file lets every job read the code and nothing else, and a job that
needs more, such as issues: write to comment on an issue, asks for it in its own permissions. That is least
privilege, which the OWASP cheat sheet asks of every identity a pipeline uses.
The checkout step needs one more setting. actions/checkout keeps the token configured for later git commands by
default (persist-credentials), which a job that only tests never needs; persist-credentials: false leaves it out.
Checkpoint
Check: Secrets and GITHUB_TOKEN permissions
Pin each action to a commit SHA, not a tag
uses: actions/checkout@v7 names a tag, and GitHub’s secure-use guidance warns that anyone who gets into the
repository holding an action can move its tags or delete them. Step through what a moved tag does to your next run,
and what a pin changes:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A tag names a commit. The workflow names the action by its tag, v7. Today the tag points at one commit of the action's code.
- A normal run. The runner downloads the commit the tag points at and runs it, with your job's token and secrets in reach.
- The tag is moved. Whoever controls the action's repository, its maintainers or someone who has broken in, points v7 at another commit. Nothing in your repository changes.
- The next run. Your next run downloads the new commit and runs it with the same token and secrets, although nobody changed your workflow.
- Pinned to a SHA. Pinned as actions/checkout@3d3c42e5…, the workflow names one commit by its hash. Pointing that name at other code would take a SHA-1 collision, so every run gets the code you reviewed.
- Updates on purpose. A new version arrives only when a commit changes the SHA and its comment, written by you or proposed by Dependabot, so every update passes through review.
A full-length commit SHA is the hash of one exact version of the action’s code, so pointing it at other code would take a SHA-1 collision. Write the 40-character SHA, and put the version in a comment on the same line:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
To find the SHA behind a tag yourself, ask the action’s own repository, not a fork, for the tag and for what it points
at: git ls-remote https://github.com/actions/checkout refs/tags/v7.0.1 'refs/tags/v7.0.1^{}'. For v7.0.1 it prints
one line, because that tag names the commit directly. An annotated tag is an object of its own, so Git prints a second
line ending in ^{}, and that line’s SHA is the commit to pin; asked for the tag name alone, Git prints only the tag
object’s SHA, which is not a commit. Dependabot can keep SHA pins up to date, and it updates the version comment when
the comment is on the same line. A repository or organisation owner can go further and turn on Require actions to
be pinned to a full-length commit SHA in the Actions settings, after which only pinned actions can be used.
GitHub’s guidance now ranks one measure above SHA pins: a workflow dependency lockfile, .github/workflows/actions.lock,
made by the GitHub CLI extension gh actions-lock, records the exact commit of every action a workflow uses, including
the actions those actions use. It is in public preview, so this lesson pins by SHA, which works everywhere today; a
locked dependency also satisfies the setting above.
Checkpoint
Check: Pinning actions to commit SHAs
Untrusted text reaches a script only as data
An expression in run is filled in before the shell starts. In
run: echo "Testing ${{ github.event.pull_request.title }}", the pull request’s title becomes part of the script, and
anyone who can open a pull request chooses the title. Step through what happens to a title that closes the quoted
string early and adds a command of its own, then through the fix:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A title. Anyone who can open a pull request chooses its title. This one closes the quoted string early and adds a command of its own.
- Filled in before the shell. The step says run with echo "Testing ${{ github.event.pull_request.title }}". GitHub replaces the expression with the title while it builds the script, before any shell starts.
- Two commands. The shell reads the script and finds two commands, the echo and the one the title added. It runs both, inside a job that has a token.
- The fix. The fixed step puts the expression in its env, as TITLE, and runs echo "Testing $TITLE". GitHub now places the title in a variable, not in the script.
- Only data. The shell expands $TITLE inside double quotes as text. Whatever the title says, it is printed, never run.
This weakness is called script injection: A weakness in which untrusted text, such as a pull request title, is pasted into a script before the script runs, so the text can add commands of its own. Passing the text in an environment variable prevents it., and branch names, commit messages and
issue bodies are just as untrusted as titles. GitHub’s guidance is to put the expression’s value in an intermediate
environment variable, env: { TITLE: ${{ github.event.pull_request.title }} } with echo "Testing $TITLE" in the
script, so the shell receives it as the value of a variable and never as code.
The pull_request_target event exists for the opposite case: it runs in the context of the base repository’s default
branch, with its secrets and a token that can write unless permissions limits it, so that a workflow can, say, label a
pull request from a fork. GitHub’s guidance is to avoid it, and workflow_run, with untrusted pull requests, and never
to check out untrusted code in workflows that use them. actions/checkout v7 enforces part of this: under
pull_request_target and workflow_run it refuses to check out a fork’s pull-request code unless you set
allow-unsafe-pr-checkout: true. For running tests, pull_request is the right event.
An expression pasted into run
run: echo "Testing ${{ github.event.pull_request.title }}" lets the title add commands to the script. Write
env: { TITLE: ${{ github.event.pull_request.title }} } on the step and run: echo "Testing $TITLE" instead, so the
title is printed whatever it says.
Checkpoint
Check: Untrusted input in scripts
A required check needs one job that always reports
A check that fails but blocks nothing is a suggestion. To turn it into a rule, open Settings → Rules → Rulesets,
create a branch ruleset that targets main, turn on Require status checks to pass before merging and add the check
by name. Many teams also turn on Require a pull request before merging, so that every change to main arrives
through a pull request. GitHub’s troubleshooting guide notes that a required check must have completed successfully in
the repository within the past seven days, so run the workflow once before you require it. Require branches to be up
to date before merging is the strict setting: a pull request must contain the latest main before it merges, which
costs more runs but tests what will really land.
Which check should you require? Each job of the matrix reports its own check, named after the job, and those names
change whenever the list of versions changes. That is why the lesson’s final workflow ends with a summary job,
All tests passed, which needs the test job; require only that one:
The lesson's workflow, from an event to the merge button
Text description of the diagram
The diagram reads from top to bottom.
- An event: a push to main, a pull request, the weekly schedule or a click on Run workflow.
- The workflow file .github/workflows/ci.yml starts, with permissions set to read the repository's contents.
- The job named test is a matrix, so it runs twice, side by side: Tests on Python 3.13 and Tests on Python 3.14. Each one checks out the code, sets up its Python version and runs the unit tests.
- Both lead to the job All tests passed, which needs the test job and runs even when a test job failed (if: always()), so that it always reports a result.
- Its result reaches the ruleset on main as a status check, and the ruleset requires All tests passed.
- Only when that check passed can the pull request be merged.
The summary job runs with if: always(), because of the way GitHub treats skipped work:
| What happened | What the required check sees |
|---|---|
| The workflow was skipped by a path or branch filter, or by a commit message | Pending: it never starts, and the merge stays blocked |
A job was skipped by its own if: condition |
Success |
A job was skipped because a job it needs failed |
Skipped, which may not block the merge |
The summary job, with if: always() |
It runs, and its step fails unless needs.test.result is success |
The third row is the trap the summary job closes: without always(), a failing test would skip the summary job, and a
skipped required check may let the pull request merge. The first row is the other trap: if you add
paths-ignore: ['docs/**'] to save minutes, documentation-only pull requests wait forever for a check that never
starts. Keep such filters out of required workflows, and skip expensive work inside them with a job-level if
instead, because a job skipped by a condition reports success.
Key idea
Require one job that always runs and fails unless every needed job succeeded: a check that never starts blocks every merge, and a skipped one may block none.
Checkpoint
Check: Required checks that always report
Schedules run in UTC unless you name a time zone
A weekly run catches what changed outside your code: a new release of a dependency, or of the runner image. The
schedule event takes five cron fields, minute, hour, day of the month, month and day of the week, and reads them in
UTC unless the schedule has a timezone key with an IANA zone name. The script below tries every minute of one week
against five schedules, with the same five-field rules, and prints when each one fires.
Weekly runs at 09:00 IST
India Standard Time (IST) is 5 hours 30 minutes ahead of UTC, as the results below show. That half hour is why
'30 3 * * 1' and not '0 4 * * 1' gives Monday 09:00 in India, and why a time early on an Indian morning falls on the
previous day in UTC.
"""When does a GitHub Actions schedule fire, in India? A small cron matcher for the five fields GitHub uses
(minute, hour, day of month, month, day of week; numbers only), tried on every minute of one week, then shown in UTC
and in India Standard Time (IST)."""
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
IST = ZoneInfo("Asia/Kolkata")
DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
def values(field: str, low: int, high: int) -> set[int]:
"""The numbers one cron field allows: *, n, a-b, lists with commas and steps such as */15."""
allowed = set()
for part in field.split(","):
spec, _, step = part.partition("/")
if spec == "*":
start, end = low, high
elif "-" in spec:
start, end = map(int, spec.split("-"))
else:
start = end = int(spec)
allowed.update(range(start, end + 1, int(step) if step else 1))
return allowed
def runs(cron: str, zone) -> list[datetime]:
"""The times in one week (in UTC) at which the schedule fires, read in the time zone `zone`."""
minute, hour, dom, month, dow = cron.split()
m, h, d, mo = values(minute, 0, 59), values(hour, 0, 23), values(dom, 1, 31), values(month, 1, 12)
w = {x % 7 for x in values(dow, 0, 7)} # 0 and 7 are both Sunday
start = datetime.fromtimestamp(3 * 24 * 3600, timezone.utc) # the first Sunday of Unix time
found = []
for i in range(7 * 24 * 60):
utc = start + timedelta(minutes=i)
t = utc.astimezone(zone)
by_date, by_weekday = t.day in d, (t.weekday() + 1) % 7 in w
# As in cron, when both day fields are restricted, a day that matches either of them counts.
day = (by_date or by_weekday) if dom != "*" and dow != "*" else (by_date and by_weekday)
if t.minute in m and t.hour in h and t.month in mo and day:
found.append(utc)
return found
def show(label: str, cron: str, zone) -> None:
for utc in runs(cron, zone):
ist = utc.astimezone(IST)
print(f"{label:<44} {DAYS[(utc.weekday() + 1) % 7]} {utc:%H:%M} UTC = {DAYS[(ist.weekday() + 1) % 7]} {ist:%H:%M} IST")
show("cron '30 3 * * 1'", "30 3 * * 1", timezone.utc)
show("cron '0 9 * * 1', no timezone key", "0 9 * * 1", timezone.utc)
show("cron '0 9 * * 1', timezone: Asia/Kolkata", "0 9 * * 1", IST)
show("cron '30 21 * * 0' (Monday 03:00 in India)", "30 21 * * 0", timezone.utc)
show("cron '30 21 * * 1' (meant for Monday)", "30 21 * * 1", timezone.utc) Output
cron '30 3 * * 1' Monday 03:30 UTC = Monday 09:00 IST cron '0 9 * * 1', no timezone key Monday 09:00 UTC = Monday 14:30 IST cron '0 9 * * 1', timezone: Asia/Kolkata Monday 03:30 UTC = Monday 09:00 IST cron '30 21 * * 0' (Monday 03:00 in India) Sunday 21:30 UTC = Monday 03:00 IST cron '30 21 * * 1' (meant for Monday) Monday 21:30 UTC = Tuesday 03:00 IST
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 schedule_ist.py
The first and third lines are the two right ways to write Monday 09:00 in India: the UTC time '30 3 * * 1', as in
the lesson’s workflow, or '0 9 * * 1' with timezone: "Asia/Kolkata" next to the cron line. The second line is the
common mistake, the local time without a time zone, which runs at 14:30 in India. The last two lines show the day
trap: a schedule for 03:00 on a Monday in India needs Sunday (0) in UTC, and a schedule written with Monday (1)
fires early on Tuesday instead.
Three limits come with schedules. The shortest interval is once every five minutes. Runs can start late when GitHub is
busy, and the start of every hour is a busy time, so 30 3 is a better minute than 0 3. And in a public repository,
scheduled workflows are switched off automatically after 60 days without activity in the repository. Scheduled runs
always use the latest commit of the default branch.
Checkpoint
Check: Schedules and time zones
Practise: harden a workflow
With every change of this lesson, the workflow looks like this:
# .github/workflows/ci.yml: the tests on every push to main, every pull request and every Monday morning, on two
# Python versions, with a read-only token and every action pinned to a full commit SHA.
name: Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '30 3 * * 1' # 03:30 UTC on Mondays, which is 09:00 in India
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Tests on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ['3.13', '3.14']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Run the tests
run: python -m unittest -v
tests-passed:
name: All tests passed
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- name: Fail unless every test job succeeded
env:
RESULT: ${{ needs.test.result }}
run: test "$RESULT" = success Git itself can show what changed, even for two files outside any repository: git diff --no-index compares them.
// What hardening changed: the first workflow against the final one, compared by Git itself. git diff --no-index
// compares two files outside any repository; its exit status is 1 because the files differ.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
// On macOS, /usr/bin/git is a small launcher: run the git that `git --exec-path` names (anywhere else, the same git).
const own = spawnSync('git', ['--exec-path'], { encoding: 'utf8' }).stdout?.trim() ?? '';
const git = [join(own, 'git'), join(own, 'git.exe')].find((f) => own && existsSync(f)) ?? 'git';
const args = ['diff', '--no-index', '--no-color', 'workflows/ci-first.yml', 'workflows/ci.yml'];
const r = spawnSync(git, args, { encoding: 'utf8', env: { PATH: process.env.PATH ?? '', GIT_CONFIG_NOSYSTEM: '1', HOME: process.cwd() } });
console.log(`$ git ${args.join(' ')}`);
process.stdout.write(r.stdout);
console.log(`(exit status ${r.status})`); Output
$ git diff --no-index --no-color workflows/ci-first.yml workflows/ci.yml
diff --git a/workflows/ci-first.yml b/workflows/ci.yml
index 7c70938..a1a4824 100644
--- a/workflows/ci-first.yml
+++ b/workflows/ci.yml
@@ -1,18 +1,44 @@
-# .github/workflows/ci.yml, first version: run the tests on every push to main and on every pull request.
+# .github/workflows/ci.yml: the tests on every push to main, every pull request and every Monday morning, on two
+# Python versions, with a read-only token and every action pinned to a full commit SHA.
name: Tests
on:
push:
branches: [main]
pull_request:
+ schedule:
+ - cron: '30 3 * * 1' # 03:30 UTC on Mondays, which is 09:00 in India
+ workflow_dispatch:
+
+permissions:
+ contents: read
jobs:
test:
+ name: Tests on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.13', '3.14']
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-python@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: '3.14'
+ python-version: ${{ matrix.python-version }}
- name: Run the tests
run: python -m unittest -v
+
+ tests-passed:
+ name: All tests passed
+ if: always()
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Fail unless every test job succeeded
+ env:
+ RESULT: ${{ needs.test.result }}
+ run: test "$RESULT" = success
(exit status 1)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compare_workflows.mjs
Read the diff from the top: a schedule and a manual Run workflow button (workflow_dispatch); a permissions
block for the whole file; a job name with the matrix value, a time limit and the matrix itself; both actions pinned with
their version in a comment, and the checkout without stored credentials; and the summary job, tests-passed. Before
pushing a workflow, a static checker such as actionlint catches unknown keys, wrong expressions and many of the
security mistakes above, and the browser tool below does the same kinds of checks.
Now the fest website’s own workflow. It runs, and it has five problems: fix the triggers, the token, the pins, a secret written into a command and an expression pasted into a script, and test two Python versions.
Exercise · Medium · JavaScript
Harden a GitHub Actions workflow
The fest website's workflow runs, but it has five problems. workflow.mjs holds it as the JavaScript object that its YAML file parses to: each YAML mapping is an object, each list an array, and every value is a string, so ${{ secrets.NAME }} is written as an ordinary string. Fix the object so that the sample tests pass:
- Triggers. It runs only on push. Make it run on pushes to
mainand on every pull request. - Token. It sets no
permissions, soGITHUB_TOKENgets the repository's default. Give it read access to the repository contents and nothing else, and setpersist-credentialstofalsein thewithofactions/checkout, because these tests never run an authenticatedgitcommand. - Pins. Pin every action to a full 40-character commit SHA; the lesson's workflow lists the SHAs of
actions/checkoutv7.0.1 andactions/setup-pythonv7.0.0. - Secret. The dashboard report has its key written into the command. Store it as a repository secret named
FEST_DASHBOARD_TOKEN, pass it to the step throughenv, and use the variable inrun. - Untrusted input and versions. One step pastes the pull request's title into its script, and only one Python version is tested. Pass the title through
envtoo, and test Python 3.13 and 3.14 with a matrix onpython-version.
Starter code · workflow.mjs
// The fest website's workflow, .github/workflows/ci.yml, as the object its YAML parses to. Fix it.
export const workflow = {
name: 'Tests',
on: {
push: {},
},
jobs: {
test: {
'runs-on': 'ubuntu-latest',
steps: [
{ uses: 'actions/checkout@v7' },
{ uses: 'actions/setup-python@v7', with: { 'python-version': '3.14' } },
{ name: 'Show what is tested', run: 'echo "Testing: ${{ github.event.pull_request.title }}"' },
{ name: 'Run the tests', run: 'python -m unittest -v' },
{
name: 'Report to the fest dashboard',
run: 'curl -fsS -H "Authorization: Bearer fest-dashboard-key-0000-not-real" https://dashboard.example.com/report',
},
],
},
},
}; The sample tests · workflow.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { workflow } from './workflow.mjs';
const jobs = () => Object.entries(workflow?.jobs ?? {});
const steps = () => jobs().flatMap(([, job]) => job.steps ?? []);
const SHA = /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[0-9a-f]{40}$/;
test('1. it runs on pushes to main and on pull requests', () => {
const on = workflow.on ?? {};
assert.ok(on.pull_request !== undefined, 'add pull_request to on, so that every pull request is tested');
assert.ok(on.push !== undefined, 'keep push in on, so that main is tested after each merge');
const branches = on.push?.branches;
assert.ok(Array.isArray(branches) && branches.length === 1 && branches[0] === 'main', "limit push to branches: ['main'], or every pull request runs twice");
});
test('2. GITHUB_TOKEN can only read the contents, and checkout does not keep it', () => {
for (const [id, job] of jobs()) {
const permissions = job.permissions ?? workflow.permissions;
assert.ok(permissions && typeof permissions === 'object', `${id}: set permissions to an object such as { contents: 'read' }, at the top or in the job`);
assert.equal(permissions.contents, 'read', `${id}: contents is 'read'`);
for (const [scope, level] of Object.entries(permissions)) {
assert.ok(scope === 'contents' || level === 'none', `${id}: the job needs no ${scope} permission; remove it`);
}
}
const checkout = steps().find((s) => typeof s.uses === 'string' && s.uses.startsWith('actions/checkout@'));
assert.ok(checkout, 'keep the actions/checkout step');
assert.equal(checkout.with?.['persist-credentials'], false, "give actions/checkout with: { 'persist-credentials': false }, so the token is not kept for later git commands");
});
test('3. every action is pinned to a full commit SHA', () => {
for (const step of steps()) {
if (step.uses === undefined) continue;
assert.match(step.uses, SHA, `${step.uses}: pin it to a 40-character commit SHA, like actions/checkout@<sha>`);
}
});
test('4. the dashboard key is a secret, passed through env', () => {
const text = JSON.stringify(workflow);
assert.ok(!text.includes('fest-dashboard-key'), 'take the written-in key out of the workflow; it is in the history of every clone now');
const report = steps().find((s) => /dashboard/i.test(`${s.name ?? ''} ${s.run ?? ''}`));
assert.ok(report, 'keep the step that reports to the dashboard');
const fromSecret = Object.entries(report.env ?? {}).find(([, value]) => /^\$\{\{\s*secrets\.FEST_DASHBOARD_TOKEN\s*\}\}$/.test(String(value)));
assert.ok(fromSecret, "give the step env: { FEST_DASHBOARD_TOKEN: '${{ secrets.FEST_DASHBOARD_TOKEN }}' }");
assert.ok(String(report.run).includes(`$${fromSecret[0]}`), `use "$${fromSecret[0]}" in the step's run`);
});
test('5. no expression is pasted into a script, and two Python versions are tested', () => {
for (const step of steps()) {
if (typeof step.run === 'string') assert.ok(!step.run.includes('${{'), `"${step.name ?? step.run}": pass expressions through env, never inside run`);
}
const versions = workflow.jobs?.test?.strategy?.matrix?.['python-version'];
assert.ok(Array.isArray(versions), "add strategy: { matrix: { 'python-version': ['3.13', '3.14'] } } to the test job");
assert.deepEqual([...versions].map(String).sort(), ['3.13', '3.14'], "test '3.13' and '3.14', written as strings");
const setup = steps().find((s) => typeof s.uses === 'string' && s.uses.startsWith('actions/setup-python@'));
assert.ok(setup, 'keep the actions/setup-python step');
assert.match(String(setup.with?.['python-version']), /^\$\{\{\s*matrix\.python-version\s*\}\}$/, "set python-version to '${{ matrix.python-version }}'");
}); A hint
Start with the top level: on becomes { push: { branches: ['main'] }, pull_request: {} }, and a new key permissions: { contents: 'read' } sits next to it. For a value from an expression, the step gets env: { TITLE: '${{ github.event.pull_request.title }}' } and its script uses "$TITLE", so the text is never pasted into the script.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To run a workflow on your own computer before pushing it, act, an open-source tool, runs the jobs in Docker
containers; the Run workflow button that workflow_dispatch adds runs it on GitHub without a new commit. The
self-check below goes from the exit status to the time zone, and keeps returning to whichever of the eight ideas your
answers show is weakest:
Practice that adapts to you
Practise this lesson
20 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): In GitHub Actions, what are a workflow, a job and a step?
A workflow is a YAML file in the repository’s .github/workflows folder that says which events start it, such as a
push or a pull request, and which jobs to run. A job is a set of steps that runs on one runner, a fresh virtual machine
or container; jobs run in parallel unless one needs another, and a matrix turns one job into several, one per
combination of values. A step is one unit of work inside a job: a shell command (run) or an action (uses), a
reusable piece such as actions/checkout. Steps of a job run in order on the same machine and share its files. Each
job reports its result as a check on the commit and the pull request.
Warm-up (fresher to 2–5 years): Why pin a third-party action to a full commit SHA instead of a tag such as v7?
A tag is a movable name. Whoever controls the action’s repository, including an attacker who has broken into it, can
point v7 at different code, and your next run executes that code with your workflow’s token and secrets. A full
40-character commit SHA is the hash of one exact version, so pointing it at other code would take a SHA-1 collision,
which is why GitHub’s secure-use guidance recommends it for workflows without a lockfile. Write the version in a comment
on the same line, for example actions/checkout@<sha> # v7.0.1, so people can read it and Dependabot can propose
updates. Repository and organisation owners can even require SHA pins for every action.
Two harder questions build on this lesson: tests that need a secret on pull requests from forks, and a required check that waits forever.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Integration tests call a payment provider's sandbox with an API key stored as a repository secret. They pass on your team's branches but fail on every pull request from a fork. Explain why, and design a way to test fork contributions without exposing the key.
-
Mid
Your test workflow is a required status check on main. To save minutes you added paths-ignore for docs/**, and now pull requests that change only documentation wait forever for the check and cannot be merged. Why, and how do you fix it without dropping the requirement?
Not affiliated
MySmartCoPilot is not affiliated with GitHub, the Git project, actionlint or act. GitHub Actions is described from GitHub’s public documentation, linked under References, and its features, limits and quotas can change.
Key takeaways
- A workflow is a YAML file in
.github/workflows:onnames the events, each job runs its steps on a fresh runner, and the command’s exit status decides the check. - Use
pushwithbranches: [main]pluspull_request, so each pull request is tested once, on its merge commit, and a matrix runs one job per version, with the versions quoted. - Secrets live in the repository’s settings and reach a step through
env; pull requests from forks get none, andpermissions: { contents: read }gives every unlisted scope none. - Pin every action to a 40-character commit SHA with its version in a comment, and pass untrusted event text to scripts
through
env, never insiderun. - Require one summary job that
needsthe tests and runs withif: always(), and keep path filters out of required workflows. - Schedules are read in UTC unless a
timezonekey names an IANA zone, so convert local times and check that the day of the week still matches.
Terms in this lesson
- GITHUB_TOKEN
- A token that GitHub creates at the start of every job of a workflow, limited to the workflow's repository and expiring when the job finishes. The permissions key decides what it may do.
- matrix (GitHub Actions)
- A job strategy that runs one job for each combination of the values it lists: two Python versions on three operating systems make six jobs.
- runner (GitHub Actions)
- The machine that runs one job of a workflow. A GitHub-hosted runner is a fresh virtual machine for every job, so jobs share no files unless they pass them on.
- script injection
- A weakness in which untrusted text, such as a pull request title, is pasted into a script before the script runs, so the text can add commands of its own. Passing the text in an environment variable prevents it.
- workflow (GitHub Actions)
- A YAML file in a repository's .github/workflows folder that names the events that start it, such as a push or a pull request, and the jobs it runs.
References
- GitHub Actions documentation (GitHub)
- Workflow syntax for GitHub Actions (GitHub)
- Events that trigger workflows (GitHub)
- Dependency caching reference (GitHub)
- Using secrets in GitHub Actions (GitHub)
- GITHUB_TOKEN (GitHub)
- Managing GitHub Actions settings for a repository (GitHub)
- Secure use reference (GitHub)
- Workflow dependency lockfiles (GitHub)
- Script injections (GitHub)
- Keeping your GitHub Actions and workflows secure, part 1: preventing pwn requests (pull_request_target) (GitHub Security Lab)
- Available rules for rulesets (GitHub)
- Troubleshooting required status checks (GitHub)
- GitHub Actions billing (GitHub)
- actions/checkout (README of version 7.0.1) (GitHub)
- actions/setup-python (README of version 7.0.0) (GitHub)
- unittest: unit testing framework (command-line interface and test discovery) (Python Software Foundation)
- crontab: schedule periodic background work (POSIX.1-2024) (The Open Group)
- YAML Ain't Markup Language, version 1.2.2 (core schema) (YAML Language Development Team)
- OWASP CI/CD Security Cheat Sheet (OWASP)
- git-diff documentation (--no-index) (Git)
- git-ls-remote documentation (Git)
- actionlint, a static checker for GitHub Actions workflow files (actionlint)
- act, run your GitHub Actions locally (nektos)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress