Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with git 2.54.0, Node.js 24.21.0, Python 3.14.8 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

The project's tests, run the way the workflow runs them

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

  1. One question What does GitHub read to decide whether the step python -m unittest -v passed?

    Choose one answer.

    Show the answer: One question

    Answer: The command's exit status, 0 for passed and anything else for failed

    Start from a command that exits with a non-zero status when a test fails. unittest does that, and a failing step fails its job and the job's check.

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:

ci-first.yml, the first version of .github/workflows/ci.yml YAML · workflows/ci-first.yml
# .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:

Interactive · message by message One run of the first workflow, from a push to a check Step through a push to a pull request's branch and watch where each part of ci.yml takes effect.
One run of the first workflow, from a push to a check, step 7 of 7. Messages so far: Your laptop to GitHub: git push; GitHub to A fresh runner: job test; GitHub to A fresh runner: the merge commit; A fresh runner to GitHub: check: passed.Your laptopgit pushGitHubthe repositoryA fresh runner5 tests: OK, exit status 0git pushjob testthe merge commitcheck: passed

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. actions/setup-python. The second step installs the Python version that its with asks for, here 3.14.
  6. 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.
  7. 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.

  • name is how the workflow appears in the repository’s Actions tab.
  • on lists the events. push with branches: [main] runs the tests on main after each merge; pull_request runs 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 on refs/pull/<number>/merge, so it checks your branch combined with the latest main, not your branch alone.
  • jobs holds one job, test. runs-on: ubuntu-latest asks for a fresh Linux runner. Each job gets a new machine, so jobs share no files unless you pass them on.
  • steps run in order on that machine. actions/checkout fetches the code, actions/setup-python installs the Python version given in with, 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

  1. Question 1 of 2 The workflow says on: { push: { branches: [main] }, pull_request: {} }. Why limit push to main?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Without it, every push to a pull request's branch starts two runs, one for the push and one for the pull request

    With the filter, each pull request is tested once, on pull_request, and main is tested again after each merge, on push.

  2. Question 2 of 2 For a run started by the pull_request event, which code does the workflow test?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The merge commit GitHub prepares on refs/pull/<number>/merge, the branch joined with the latest main

    GitHub sets the run's commit to the merge commit of the pull request's merge branch, so a pull request that would break main when merged fails its check before anyone merges it.

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

  1. Question 1 of 2 A job has strategy: { matrix: { python-version: ['3.13', '3.14'], os: [ubuntu-latest, windows-latest, macos-latest] } }. How many jobs does it start?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 6 jobs

    A matrix starts one job for every combination of its values: 2 Python versions times 3 operating systems makes 6 jobs, each with its own check.

  2. Question 2 of 2 To save a minute, a job caches a folder that also holds a deploy token. What is wrong with that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Anyone who can open a pull request can read what the caches of the base branch hold

    Never put credentials in a cached folder. Keep tokens in secrets, passed to the one step that needs them.

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

  1. Question 1 of 2 A workflow runs for a pull request from a fork. What does it get?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No secrets except GITHUB_TOKEN, and that token is read-only

    For a workflow triggered from a forked repository, GitHub passes no secrets to the runner apart from a read-only GITHUB_TOKEN. Work that needs a secret runs after the merge.

  2. Question 2 of 2 A workflow sets permissions: { contents: read } at the top. What may GITHUB_TOKEN do with issues?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing. Once any permission is set, every scope that is not listed gets none

    GitHub's workflow syntax sets every unlisted scope to none as soon as you give the access for any one of them. A job that needs more asks for it in its own 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:

Interactive · message by message A tag that moves, and a pin that does not Step through two runs of a workflow that names an action by its tag, then the same workflow with a SHA pin.
A tag that moves, and a pin that does not, step 6 of 6. Messages so far: The action's repository to The runner: the code at v7; The action's repository to The runner: different code; The action's repository to The runner: the pinned commit.Your workflowuses: …@3d3c42e5 # v7.0.1The action's repositoryv7 now at another commitThe runnerruns the actionthe code at v7different codethe pinned commit

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. A normal run. The runner downloads the commit the tag points at and runs it, with your job's token and secrets in reach.
  3. 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.
  4. The next run. Your next run downloads the new commit and runs it with the same token and secrets, although nobody changed your workflow.
  5. 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.
  6. 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.1
  • uses: 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

  1. Question 1 of 2 Which reference always runs exactly the same code of actions/checkout?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1

    Tags and branches are names that can be moved; a full commit SHA is the hash of one version. That is why GitHub's secure-use guidance recommends full-length SHA pins for workflows without a lockfile.

  2. Question 2 of 2 git ls-remote --tags prints two lines for one tag: refs/tags/v2.1.0 and refs/tags/v2.1.0^{}. Which SHA do you pin?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The one on the line ending in ^{}, the commit the tag points at

    Ask for the tag and its peeled name, refs/tags/v2.1.0 and refs/tags/v2.1.0^{}. A lightweight tag gives one line, whose SHA is the commit; an annotated tag gives two, and the commit is the SHA on the line that ends in ^{}.

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:

Interactive · message by message A pull request title pasted into a script, and the fix Step through one title on its way into a run step, first pasted into the script, then passed as data.
A pull request title pasted into a script, and the fix, step 5 of 5. Messages so far: Pull request author to GitHub: a title with a quote; GitHub to The runner's shell: the script; GitHub to The runner's shell: TITLE, then the script.Pull request authorchooses the titleGitHubTITLE holds the titleThe runner's shellruns the scripta title with a quotethe scriptTITLE, then the script

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 Which version of a step prints a pull request's title safely?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: env: TITLE set to ${{ github.event.pull_request.title }}, and run: echo "Testing $TITLE"

    An expression in run is filled in before the shell starts, so its value becomes code. Set it to an intermediate environment variable instead, and the shell only ever sees it as data.

  2. Question 2 of 2 A workflow must add a label to pull requests from forks, which needs a token that can write. Which design is safe?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A pull_request_target workflow that adds the label and never checks out or runs the pull request's code

    pull_request_target runs in the base repository's context with its secrets. Use it only for work that never touches the pull request's code; actions/checkout v7 refuses to check that code out there unless you opt in.

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:

An event starts the workflow, a matrix runs the tests on two Python versions, a summary job reports one check, and a ruleset requires it.An eventpush to main · pull requestschedule · Run workflow.github/workflows/ci.ymlpermissions: contents: readJob test, a matrixone job per Python version, side by side:Tests on Python 3.13 · Tests on Python 3.14each: checkout · setup-python · unittestAll tests passedneeds: test · if: always()Ruleset on mainrequired check: All tests passedThe pull request can be mergedboth resultsone checkonly when it passed

The lesson's workflow, from an event to the merge button

Text description of the diagram

The diagram reads from top to bottom.

  1. An event: a push to main, a pull request, the weekly schedule or a click on Run workflow.
  2. The workflow file .github/workflows/ci.yml starts, with permissions set to read the repository's contents.
  3. 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.
  4. 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.
  5. Its result reaches the ruleset on main as a status check, and the ruleset requires All tests passed.
  6. 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

  1. Question 1 of 2 The test workflow is a required check, and its trigger now has paths-ignore for docs. A pull request changes only docs. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The check stays pending and the pull request cannot be merged

    A workflow skipped by a path or branch filter never reports its checks, so they stay pending and block the merge. A job skipped by an if condition does report, as a success, which is why the decision belongs inside the workflow.

  2. Question 2 of 2 Why does the summary job All tests passed run with if: always()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Without it, a failing test job would skip the summary job, and a skipped required check may let the pull request merge

    With always() the summary job runs after every outcome of the test job, and its one step fails unless the result is success. Requiring only that job also keeps the ruleset stable when the matrix changes.

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.

Five schedules, in UTC and in India Standard Time Python · schedule_ist.py
"""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.

Cron Expression Generator & Explainer Build a cron expression from plain choices and read back when it runs, before you put it in a schedule.

Checkpoint

Check: Schedules and time zones

  1. One question A workflow has schedule: - cron: '0 9 * * 1' and no timezone key. When does it run in India?

    Choose one answer.

    Show the answer: One question

    Answer: Mondays at 14:30 IST

    GitHub reads cron fields in UTC unless the schedule has a timezone key. For 09:00 IST write '30 3 * * 1', or keep '0 9 * * 1' and add timezone with Asia/Kolkata.

Practise: harden a workflow

With every change of this lesson, the workflow looks like this:

ci.yml, the final .github/workflows/ci.yml YAML · workflows/ci.yml
# .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, compared with git diff --no-index JavaScript · compare_workflows.mjs
// 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.

GitHub Actions Linter (Workflow Security and Expression Checks) Paste a workflow to check its expressions, jobs, matrices, schedules and security settings, each finding linked to GitHub's documentation.

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:

  1. Triggers. It runs only on push. Make it run on pushes to main and on every pull request.
  2. Token. It sets no permissions, so GITHUB_TOKEN gets the repository's default. Give it read access to the repository contents and nothing else, and set persist-credentials to false in the with of actions/checkout, because these tests never run an authenticated git command.
  3. Pins. Pin every action to a full 40-character commit SHA; the lesson's workflow lists the SHAs of actions/checkout v7.0.1 and actions/setup-python v7.0.0.
  4. 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 through env, and use the variable in run.
  5. 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 env too, and test Python 3.13 and 3.14 with a matrix on python-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.

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.

Text Diff Compare your fixed workflow with the final one above, line by line, in the browser.

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.

  1. Question 1 of 20 What does python -m unittest -v run when it is given no test name?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Every test file whose name matches test*.py that it discovers from the folder it runs in

    That is why the workflow's last step can be one line: python -m unittest -v finds test_inr.py, runs its 5 tests and exits with 0 only when all of them pass.

  2. Question 2 of 20 One of the 5 tests fails in the workflow. What happens to the step, the job and the check?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: unittest exits with a non-zero status, so the step fails, the job stops and its check fails

    One failing test makes the command exit with a non-zero status, which fails the step, the job and the job's check, and a ruleset that requires the check then blocks the merge.

  3. Question 3 of 20 A job lint writes report.txt, and a job test runs after it. Can test read report.txt?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: Not unless lint passes it on, because each job runs on a new machine

    Steps of one job share a machine and its files; separate jobs do not, so anything a later job needs must be passed on explicitly.

  4. Question 4 of 20 Someone edits the title of an open pull request and pushes nothing. Does the lesson's first workflow run?

    Choose one answer.

    Show the answer: Question 4 of 20

    Answer: No, because pull_request starts runs only for opened, synchronize and reopened unless types says otherwise

    By default a pull_request workflow runs when a pull request is opened, gets new commits (synchronize) or is reopened. Add types to on.pull_request to react to other activity, such as edited.

  5. Question 5 of 20 What does a matrix do by default when one of its jobs fails?

    Choose one answer.

    Show the answer: Question 5 of 20

    Answer: It cancels the matrix jobs that are still running or queued

    With fail-fast false you learn whether a failure happens on every version or only on one, which is the reason to test several versions in the first place.

  6. Question 6 of 20 A matrix lists python-version: [3.9, 3.10] without quotes. Which versions does setup-python get asked for?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: 3.9 and 3.1, because YAML reads 3.10 as the number 3.1

    Quote every version in a matrix, ['3.9', '3.10'], so each stays the string you wrote.

  7. Question 7 of 20 A run for a pull request from feature into main looks for a dependency cache. Which caches can it restore?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: Caches of feature, of the default branch, and of main as the pull request's base branch

    The same rule is why credentials must never be cached: anyone who can open a pull request can read what the caches of the base branch hold.

  8. Question 8 of 20 How does a step get the repository secret DASHBOARD_TOKEN into its script?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: Through env, as TOKEN: ${{ secrets.DASHBOARD_TOKEN }}, with "$TOKEN" in the script

    Store the value under the repository's Actions secrets, or with gh secret set, and refer to it by name in the step that needs it, never anywhere else.

  9. Question 9 of 20 Why does GitHub's guidance say not to keep four keys in one JSON secret?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: Structured data can make the redaction of secrets in logs fail, so each value should be a secret of its own

    Create one secret for each sensitive value, and register any value you derive from a secret, such as its Base64 form, as a secret too, so that both stay hidden in logs.

  10. Question 10 of 20 The workflow sets permissions: { contents: read } at the top, and one job must also comment on issues. What do you change?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: Give that job its own permissions: { contents: read, issues: write }

    Least privilege per job: the top-level block gives every job read access to the contents, and the one job that needs more asks for exactly that in its own permissions.

  11. Question 11 of 20 Why is uses: actions/checkout@v7 a risk?

    Choose one answer.

    Show the answer: Question 11 of 20

    Answer: The tag can be moved to other code, which the next run executes with your job's token and secrets

    A pin to a full commit SHA closes the risk: the workflow names one exact version, and any update arrives as a reviewed change of the SHA.

  12. Question 12 of 20 Why write # v7.0.1 after the SHA on the same line?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: So people can read the version, and Dependabot updates the comment with the SHA

    A 40-character SHA says nothing to a reader. The version comment does, and keeping it on the same line lets Dependabot propose an update of both together.

  13. Question 13 of 20 What does GitHub's workflow dependency lockfile cover that SHA pins in your own uses lines do not?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: The exact commits of the actions that your actions use in turn, checked against the repository they came from

    A SHA pin fixes the action you name; an action you trust may still use other actions by tag. The lockfile, .github/workflows/actions.lock, records those too. It is in public preview, so the lesson pins by SHA.

  14. Question 14 of 20 Which of these values can someone outside your team choose, so that pasting them into run is dangerous? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 14 of 20

    Answer:

    • The name of the pull request's head branch
    • The message of a commit in the pull request
    • github.event.pull_request.title

    Titles, branch names, commit messages and issue bodies come from whoever opened or pushed them. Pass any such value to a script through an environment variable, never inside run.

  15. Question 15 of 20 In what context does a workflow triggered by pull_request_target run?

    Choose one answer.

    Show the answer: Question 15 of 20

    Answer: The base repository's default branch, with its secrets and a token that can write unless permissions limit it

    Because pull_request_target runs with the base repository's privileges, it must never check out or run the pull request's code; actions/checkout v7 refuses to check out a fork's code there unless you set allow-unsafe-pr-checkout.

  16. Question 16 of 20 Which check of the final workflow should the ruleset on main require?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: All tests passed, the summary job

    Require one stable name: the summary job needs the test job, runs with if: always() and fails unless needs.test.result is success.

  17. Question 17 of 20 You want to add a brand-new workflow's check to the ruleset, but it is not offered. Why?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: A required check must have completed successfully in the repository within the past seven days

    GitHub's troubleshooting guide says a required status check must have completed successfully in the chosen repository during the past seven days, so a workflow is run before its check is required.

  18. Question 18 of 20 You want documentation-only pull requests to skip the 20-minute test job without blocking their merge. What do you do?

    Choose one answer.

    Show the answer: Question 18 of 20

    Answer: Keep the workflow's trigger unfiltered and skip the job with a job-level if, because a job skipped by a condition reports success

    Decide inside the workflow, not in the trigger: a job skipped by its own condition reports success, while a workflow that never starts reports nothing.

  19. Question 19 of 20 Without a timezone key, which cron line runs every Monday at 09:00 IST?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: '30 3 * * 1'

    Convert the local time to UTC, minute and hour, or keep the local time and add timezone with Asia/Kolkata next to the cron line.

  20. Question 20 of 20 Without a timezone key, which cron line runs at 03:00 IST on Mondays?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: '30 21 * * 0'

    An early morning in India is still the previous day in UTC, so converting the hour also moves the day of the week back by one.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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: on names the events, each job runs its steps on a fresh runner, and the command’s exit status decides the check.
  • Use push with branches: [main] plus pull_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, and permissions: { 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 inside run.
  • Require one summary job that needs the tests and runs with if: always(), and keep path filters out of required workflows.
  • Schedules are read in UTC unless a timezone key 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.