Chapter 02Lesson 02~140 minutes

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.

Hands-onCheckoutSetup PythonTwo jobsImmutable actions

Learning objectives

  • Construct a workflow from an empty file and predict the effect of each structural addition before running it.
  • Use run for explicit shell logic and immutable uses references 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 identity

Workflow path, workflow name, source SHA, event, run ID, run attempt.

Graph

Job IDs inspect/test, dependency, start order, conclusions.

Runner/toolchain

ubuntu-24.04, runner image metadata, Python 3.13 selected by setup-python.

Dependencies

Checkout SHA and setup-python SHA plus human-readable upstream releases.

Permissions

contents: read; no write permission, secret, OIDC, or deployment credential.

Outcome

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?

  1. Create a second job with its own runs-on.
  2. Add needs: inspect only if ordering is required.
  3. Checkout source again because the second hosted job has a fresh workspace.
  4. Set up the required Python version again because tool state is job-local.
  5. Do not “fix” the boundary by placing mutable files on a persistent runner or granting extra token permissions.
Next lesson

Structure is design, not just syntax

Lesson 3 compares one job versus multiple jobs, scripts versus actions, global versus local configuration, and abstraction versus inspectability.

Knowledge check

Why does the workflow change from permissions: {} to contents: read after checkout is added?

Why is checkout repeated in the test job?

What does persist-credentials: false change in this lab?

If the invalid steps placement creates no normal job run, where should you diagnose first?

Why record both an action SHA and an upstream release such as v7.0.1?

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.