Workflow Files, YAML Structure, Jobs, Steps, and Actions: Guided Hands-On Workflow
This lesson builds a workflow incrementally so every line has an observable consequence. You will start with one shell step, add repository checkout and a pinned Python setup action, then split work into two jobs and prove that the second job receives a fresh runner. The exercise uses a disposable repository, standard-library Python tests, read-only repository permission, and no secret or external deployment.
Learning objectives
- Construct a workflow from an empty file and predict the effect of each structural addition before running it.
-
Use
runfor explicit shell logic and immutableusesreferences for checkout/tool setup, with action releases recorded alongside full SHAs. - Prove that source is absent before checkout and that each hosted job needs its own checkout when it requires repository content.
- Use two jobs to observe independent runner state, stable job IDs, ordered steps, explicit shell choice, and read-only token permission.
- Preserve run/job/step evidence for both a successful workflow and an intentionally invalid intermediate version.
1. Disposable repository preflight
Create a throwaway repository or a disposable branch in a repository you are authorized to modify. The lab needs only Actions enabled and permission to commit workflow/source files. It requires no repository secret, no cloud account, no package registry, no environment, and no self-hosted runner.
Create this tiny Python fixture:
# src/mathbox.py
def add(a, b):
return a + b
# tests/test_mathbox.py
import unittest
from src.mathbox import add
class MathBoxTests(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
if __name__ == "__main__":
unittest.main()
Before adding automation, record the commit SHA that contains these files. The run later must be mapped back to that exact source revision.
2. Start from the smallest executable workflow
Create .github/workflows/ch02-structure.yml:
name: Chapter 02 structure lab
on:
workflow_dispatch:
permissions: {}
jobs:
inspect:
runs-on: ubuntu-24.04
steps:
- name: Identify the runner
shell: bash
run: |
printf 'run=%s attempt=%s job=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_JOB"
printf 'sha=%s workspace=%s\n' "$GITHUB_SHA" "$GITHUB_WORKSPACE"
Predict before dispatch: one run, one job ID inspect,
one Ubuntu runner, one step, and no need for repository contents.
Because this version does not checkout code or call the API,
permissions: {} is sufficient.
3. Add checkout as an executable dependency, not magic
The runner workspace is not equivalent to “the repository has been cloned.” Add a before/after check and the pinned checkout action:
- name: Prove source is absent before checkout
shell: bash
run: test ! -f src/mathbox.py
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prove source is now present
shell: bash
run: |
test -f src/mathbox.py
git rev-parse HEAD
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
Checkout needs repository read access, so change the workflow permission to:
permissions:
contents: read
persist-credentials: false is deliberate because the
lab does not perform authenticated Git mutations after checkout. The
action reference is the full commit SHA for upstream checkout
v7.0.1, verified at guide generation time.
4. Add an explicit toolchain action
Do not depend silently on whichever Python happens to be preinstalled on the runner image. Add the pinned setup action, then verify the result:
- name: Set up Python 3.13
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Verify source and interpreter
shell: bash
run: |
python --version
python -m compileall -q src tests
python -m unittest discover -s tests -v
This illustrates the difference between image and toolchain
identity. The job pins the operating-system label to
ubuntu-24.04, then separately asks setup-python for
Python 3.13. The run log should preserve both the runner image
metadata and the selected Python version.
5. One-job version: inspect the scope you have built
name: Chapter 02 structure lab
on:
workflow_dispatch:
push:
paths:
- 'src/**'
- 'tests/**'
- '.github/workflows/ch02-structure.yml'
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
inspect:
name: Inspect and test on one runner
runs-on: ubuntu-24.04
steps:
- name: Workspace before checkout
run: test ! -f src/mathbox.py
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python 3.13
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Verify exact revision
run: |
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
python --version
- name: Test
run: python -m unittest discover -s tests -v
Notice the scopes. permissions applies to the workflow.
defaults.run.shell applies to shell steps, not to the
internal implementation of uses actions. All steps
share the same job runner, so checkout performed by one step makes
source visible to later steps.
6. Split into two jobs and prove the fresh-runner boundary
Now separate source inspection and tests. The second job declares
needs: inspect for ordering, but still requires its own
checkout and tool setup:
jobs:
inspect:
name: Inspect source identity
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Record identity
run: |
git rev-parse HEAD
echo "producer-runner=$RUNNER_NAME"
echo "created-by-inspect" > transient-marker.txt
test:
name: Test on an independent runner
needs: inspect
runs-on: ubuntu-24.04
steps:
- name: Prove producer workspace did not transfer
run: test ! -e transient-marker.txt
- name: Checkout again
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Run tests
run: python -m unittest discover -s tests -v
The second job's first step is a positive proof of isolation: the
transient file from inspect should not exist. If the
second job needs repository source, it explicitly checks out that
source again.
7. Evidence packet: what to record from the successful run
Workflow path, workflow name, source SHA, event, run ID, run attempt.
Job IDs inspect/test, dependency,
start order, conclusions.
ubuntu-24.04, runner image metadata, Python 3.13
selected by setup-python.
Checkout SHA and setup-python SHA plus human-readable upstream releases.
contents: read; no write permission, secret, OIDC,
or deployment credential.
Exact checked-out SHA and passing standard-library tests; no external deployment claim.
8. Intentionally invalid structure: distinguish “no valid workflow” from “failed job”
On a disposable branch or temporary copy, place
steps at the wrong structural level:
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- run: echo "wrong level"
This is YAML-shaped text, but it is not a valid GitHub Actions jobs map. Do not rely on one fixed diagnostic string because parser/schema wording can change. Preserve the invalid commit and the GitHub editor/UI validation evidence. The key observation is that there may be no normal job execution to inspect because the failure occurs before runner assignment.
9. Challenge: choose the correct boundary
You need a second job to run the same tests after an
inspect job. Which changes are required?
- Create a second job with its own
runs-on. -
Add
needs: inspectonly if ordering is required. - Checkout source again because the second hosted job has a fresh workspace.
- Set up the required Python version again because tool state is job-local.
- Do not “fix” the boundary by placing mutable files on a persistent runner or granting extra token permissions.
Knowledge check
Why does the workflow change from
permissions: {} to
contents: read after checkout is added?
Checkout of the repository uses the GitHub token by default to read repository content. The workflow grants only that read capability rather than broad write access.
Why is checkout repeated in the test job?
Each standard hosted job receives a fresh runner. Source checked
out in inspect is not automatically present in
test.
What does persist-credentials: false change in
this lab?
It avoids leaving the checkout token configured in local Git settings after checkout because subsequent steps do not need authenticated Git operations.
If the invalid steps placement creates no normal
job run, where should you diagnose first?
At workflow parsing/schema validation and the exact committed workflow revision, not at runner capacity or test commands.
Why record both an action SHA and an upstream release such as v7.0.1?
The SHA supplies immutability; the release label supplies human-maintainable context for updates and review.
Official references and version notes
- Understanding GitHub Actions — current definitions and execution relationships for workflows, jobs, steps, actions, and runners.
- Workflow syntax for GitHub Actions — authoritative workflow structure, jobs, steps, permissions, defaults, runners, and shell behavior.
-
Setting default shell and working directory
— current precedence and restrictions for workflow/job
defaults.run. - Using GitHub-hosted runners — job-to-runner isolation and filesystem sharing within a job.
- Secure use reference — current guidance to pin action dependencies to full-length commit SHAs and minimize privileges.
- actions/checkout repository — official action source and current metadata.
- actions/setup-python repository — official action source, supported inputs, and release history.
Version-sensitive behavior was rechecked against primary GitHub
documentation and GitHub-maintained action repositories on
2026-09-09. Executable examples use
ubuntu-24.04,
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
(upstream release v7.0.1), and where Python setup is needed
actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
(upstream release v7.0.0) with Python 3.13. At verification time
both actions declare a Node 24 runtime. Runner images, action
releases/runtimes, workflow keys, parser diagnostics, and
plan-dependent behavior can change; re-resolve current immutable
SHAs before copying these examples into long-lived production
workflows. The lab intentionally uses standard-library Python
only, so dependency caching, package publication, artifacts,
secrets, and deployment are outside this lesson.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.