You will write a GitHub Actions workflow that lints and tests your code on every push. By the end, broken code is caught automatically before it can be merged into main.

Continuous integration (CI) is the habit of validating every change the moment it lands. Instead of discovering a broken test suite the night before a demo, the repository itself rejects bad code within minutes of a push. GitHub Actions is the CI engine built into every GitHub repo: you describe the checks you want in a YAML file, commit that file, and GitHub runs it on a fresh virtual machine every time the trigger conditions are met.

This lab builds the smallest CI setup that is still genuinely useful in real projects: a workflow with two jobs (lint and test), dependency caching so runs stay fast, and a branch protection rule that makes the workflow a required status check on main. Every command is included, along with the output you should expect and the mistakes that most often bite first-time users.

Learning Objectives

  • Define triggers, jobs, and steps in workflow YAML.
  • Cache dependencies to speed up runs.
  • Make the workflow a required status check.
  • Read run logs from the terminal with gh run view and find failing steps fast.
  • Prove the protection works by pushing a deliberately failing change and watching the merge get blocked.
  • Time: ~3 hours · Difficulty: Intermediate · Prereqs: a GitHub repo with tests.

Architecture Overview

Everything in Actions flows from an event. A git push (or a pull request) fires the event; GitHub matches it against the on: triggers declared in your workflow file; and each matching workflow is queued. Every job in the workflow gets its own fresh virtual machine called a runner, here, ubuntu-latest. Jobs run in parallel by default, which is why the lint and test jobs below finish in roughly the time of the slower one. Inside a job, steps run sequentially: they either reuse a published action (uses:) or execute a shell command (run:).

When all jobs finish, GitHub rolls their results up into a single status check attached to the commit. Branch protection watches that check: if it is red, the merge button stays disabled. That last link in the chain is what turns "a script that runs tests" into actual protection for main.

Two properties of this design are worth remembering. First, runners are ephemeral, nothing you install or download survives the run, which is exactly why caching (step 01) matters for speed. Second, the workflow file itself is versioned code: it is reviewed in pull requests like everything else, and a broken workflow file fails loudly in the Actions tab rather than silently skipping your checks.

How It Works

How It Works

GitHub Actions is event-driven automation: a push, PR, schedule, or manual dispatch triggers a workflow run on a fresh runner VM. Jobs run in parallel by default, steps within a job run sequentially on the same runner, which is why artifacts and outputs bridge jobs.

Caching is the main speed lever: dependencies download once and restore in seconds on later runs. The cache key should include a lockfile hash so dependency changes invalidate it automatically instead of serving stale packages.

Required status checks close the loop with branch protection: the merge button stays disabled until the named checks pass. That turns 'CI is informational' into 'CI is the gate', which is the difference between automation that advises and automation that protects.

Environment Setup

Prerequisites

  • A GitHub repository with a test command (pytest or npm test)
  • Workflows live in .github/workflows/; any .yml there on the default branch activates
  • The gh CLI for reading run logs from the terminal (optional)
  • Permission to edit branch protection rules for the required-check step
  • Assumed skill: YAML basics and git push

You will need: a GitHub repo with a test command (for example pytest or npm test).

Before you begin: workflows live in .github/workflows/.

Any file with a .yml or .yaml extension placed in that directory and pushed to the default branch becomes a workflow, there is nothing to enable in the UI. The examples below use a small Python project, but every concept maps one-to-one onto Node, Go, or anything else with a test command.

EXAMPLE REPO LAYOUT USED IN THIS LAB

my-project/
├── .github/
│   └── workflows/
│       └── ci.yml        <-- the workflow you are about to write
├── src/
│   └── app.py
├── tests/
│   └── test_app.py
└── requirements.txt

Install the GitHub CLI (gh) if you have not already, it lets you watch runs, read logs, and configure branch protection without leaving the terminal. Verify it is authenticated against the account that owns the repo:

VERIFY THE GH CLI IS AUTHENTICATED

gh auth status
$ gh auth status
github.com ✓ Logged in to github.com account TYFSADIK (keyring) - Active account: true - Git operations protocol: https - Token scopes: 'gist', 'read:org', 'repo', 'workflow'

Two things to check in that output: the account matches the repo owner, and the token includes the workflow scope, without it, pushing a file under .github/workflows/ is rejected with an opaque "refusing to allow an OAuth App to create or update workflow" error. If the scope is missing, re-authenticate with gh auth login --scopes workflow.

Finally, confirm the test command actually passes locally before automating it. CI should encode a known-good check, not debug one:

SANITY-CHECK THE TEST SUITE LOCALLY

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest -q
$ pytest -q
.... [100%] 4 passed in 0.12s

Common mistake: writing the workflow first and discovering in CI that the tests never passed locally in the first place. A green local run is your baseline; every red run afterwards is a real regression.

Step-by-Step Execution

01
Write the workflow

Create the workflow directory and the file itself. The name is arbitrary, ci.yml is the usual convention, but the directory path is not: GitHub only discovers workflows under .github/workflows/ at the repository root.

CREATE THE WORKFLOW FILE

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Start with the minimal version below. It already does the essential job: on every push and every pull request, check out the code, install Python 3.11 with pip caching enabled, install dependencies, and run the test suite.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.11', cache: 'pip' }
      - run: pip install -r requirements.txt
      - run: pytest -q

Walk through what each line means, because every workflow you will ever write is a variation on this shape:

  • name: CI, the display name in the Actions tab and, importantly, the first half of the status check name that branch protection will reference in step 03.
  • on: [push, pull_request], the triggers. push covers direct commits to any branch; pull_request covers proposed merges so the check runs on the merged result before anyone clicks the merge button.
  • runs-on: ubuntu-latest, the runner image. GitHub-hosted runners are free for public repos and include generous monthly minutes for private ones.
  • actions/checkout@v4, clones your repo onto the runner. Without this step the runner starts with an empty directory; forgetting it is a classic beginner bug that produces "No such file or directory" on the very next step.
  • actions/setup-python@v5 with cache: 'pip', installs the requested Python version and caches the pip download cache keyed on a hash of requirements.txt. The first run populates the cache; later runs skip re-downloading every package.
  • The two run: steps are plain shell. Any command you would type locally works here.

Once the minimal version is green, grow it into the two-job shape from the architecture diagram: lint and test run in parallel, and a failing linter stops bad style from reaching main just as surely as a failing test. The full file also pins the cache key explicitly so you can see how caching works under the hood.

FULL WORKFLOW, .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Cancel superseded runs on the same branch to save runner minutes.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      - run: pip install ruff
      - run: ruff check src tests

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      - run: pip install -r requirements.txt
      - run: pytest -q --cov=src --cov-report=term-missing

Notes on the additions: concurrency cancels the previous in-flight run when you push again to the same branch, handy when you spot a typo seconds after pushing. The push.branches filter keeps pushes to throwaway feature branches from burning minutes, while pull_request still validates every proposed merge into main. The two jobs have no needs: relationship, so GitHub schedules them in parallel.

How to verify: the file is just YAML, so a syntax error will break the whole workflow before it ever runs. Lint it locally before pushing:

VALIDATE THE YAML BEFORE COMMITTING

python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml'))" && echo "YAML OK"
# or, better, use the purpose-built linter:
actionlint .github/workflows/ci.yml

actionlint goes beyond YAML syntax, it catches misspelled action inputs, unknown contexts like ${{ github.branhc }}, and shellcheck issues inside run: blocks. It is the single highest-value tool for workflow debugging.

Common mistake: YAML is whitespace-sensitive, and mixing tabs with spaces produces errors like mapping values are not allowed here. Configure your editor to insert two spaces for .yml files. A subtler gotcha: some YAML 1.1 parsers (including Python's yaml.safe_load) read the bare key on: as the boolean True, GitHub parses it correctly, but do not be alarmed if a generic YAML tool shows the key as true.

What this teaches: Workflow YAML declares triggers, jobs, and steps; pushing the file to the default branch is what activates it.

02
Push and watch the run

Commit the workflow file and push it. Because the trigger list includes push, this very push is also the workflow's first run, a satisfying feedback loop.

COMMIT AND PUSH THE WORKFLOW

git add .github/workflows/ci.yml
git commit -m "Add CI workflow: lint + test on push and PR"
git push origin main

Then watch the run live from the terminal. gh run watch attaches to the most recent run and streams each job's status until it completes; --exit-status makes the command itself fail if the run fails, which is useful in scripts.

$ git push && gh run watch
CI · test (push) completed success

A full two-job run looks like this in the terminal:

$ gh run watch --exit-status
✓ main CI · 14473922110 Triggered via push about 1 minute ago JOBS ✓ lint in 24s (ID 40567891234) ✓ test in 41s (ID 40567891887) ✓ Run CI (14473922110) completed with 'success'

Notice the run time: the first run is the slow one because it populates the pip cache. Push a trivial change (edit a comment, amend, push again) and compare, the setup-python step should now log a cache hit:

$ gh run view --log | grep -i "cache"
test Set up Python Cache restored from key: setup-python-Linux-22.04-Ubuntu-python-3.11.9-pip-9f2c1e... test Set up Python Successfully restored from cache

When a run goes red, do not click around the web UI hunting for the failure, pull the failing logs straight into your terminal:

READ ONLY THE FAILED STEPS OF A RUN

gh run list --limit 5
gh run view --log-failed          # logs from failed steps of the latest run
gh run view --job 40567891887 --log  # full log of a specific job (job IDs need --job)

How to verify: after the push, the commit on GitHub should carry a green check (or yellow dot while running). Clicking it shows both jobs. In the Actions tab, the run appears under the workflow name "CI" with the commit message as its title.

Common mistake: committing the file but forgetting git add .github/workflows/ci.yml (the directory is easy to overlook because it is hidden), or pushing to a feature branch while push.branches filters to [main], in both cases the Actions tab stays empty and it looks like Actions is broken. Check git status and your trigger filters first.

What this teaches: Every push triggers a run; reading logs with gh run view is the core debugging loop.

03
Require the check on main

A workflow that only reports failures is advisory; developers can ignore it and merge anyway. Branch protection turns the status check into a gate: until the checks named in the rule pass on the exact commit being merged, GitHub disables the merge button. You can configure this in the UI under Settings › Branches › Add branch ruleset, but doing it with the API is repeatable and reviewable.

Set the workflow as a required status check in branch protection so failing code cannot merge.

Note: the protection API accepts only the complete required_status_checks object in one PUT, so the reliable form is the full JSON payload below. A partial update that sets only strict without the contexts array fails validation.

The :owner and :repo placeholders are filled in automatically by gh from your git remote. strict=true means the PR branch must also be up to date with main before merging, the strictest and safest mode, since the check then ran against exactly what will land on main.

Strict mode alone does not name which checks are required. Name them explicitly so the gate waits for your two jobs. The check names follow the pattern workflow name / job name, here, lint and test as jobs inside the CI workflow:

REQUIRE BOTH JOBS AS NAMED STATUS CHECKS

gh api repos/:owner/:repo/branches/main/protection -X PUT \
  --input - <<'EOF'
{
  "required_status_checks": {
    "strict": true,
    "contexts": ["lint", "test"]
  },
  "enforce_admins": true,
  "required_pull_request_reviews": null,
  "restrictions": null
}
EOF
$ gh api repos/:owner/:repo/branches/main/protection/required_status_checks
{ "url": "https://api.github.com/repos/TYFSADIK/my-project/branches/main/protection/required_status_checks", "strict": true, "contexts": [ "lint", "test" ] }

That GET call is your verification: it confirms the rule stuck and lists exactly which contexts GitHub will wait for. enforce_admins: true is deliberate, without it, repository admins can merge over a red check, which is how "temporary" exceptions become permanent holes.

Common mistake: a context name that does not match any real check. If you require CI / test but the job is named test, the PR waits forever on a check that will never report. The context must match the job name (or workflow / job for reusable workflows). The check must also have run at least once before GitHub will offer it in the branch-protection search box, one more reason step 02 comes first.

What this teaches: Required status checks make CI a gate: broken code cannot merge, not just should not.

04
Expand the triggers: schedules and path filters

The minimal on: [push, pull_request] covers active development, but two more trigger shapes earn their keep quickly: a schedule that runs CI even when nobody is pushing (this catches "dependency rot", a passing suite that starts failing because a transitive dependency or a GitHub runner image changed), and path filters that skip CI for changes that cannot break code, like edits to documentation.

Replace the on: block of ci.yml with this expanded version:

TRIGGERS, PUSH, PR, SCHEDULE, MANUAL, WITH PATH FILTERS

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    branches: [main]
  schedule:
    # 03:17 UTC every Monday, cron is always UTC on GitHub.
    - cron: '17 3 * * 1'
  workflow_dispatch: {}   # adds a "Run workflow" button in the Actions tab

Each key does one thing: paths-ignore skips the run when every changed file matches the ignore list; schedule takes standard five-field cron expressions (minute, hour, day-of-month, month, day-of-week) in UTC; workflow_dispatch enables manual runs from the web UI or gh workflow run ci.yml, which is invaluable when debugging the workflow itself.

How to verify: after pushing, run gh workflow list to confirm the workflow is active, then trigger a manual run and confirm it appears:

LIST WORKFLOWS AND TRIGGER ONE MANUALLY

gh workflow list
gh workflow run ci.yml
gh run list --workflow=ci.yml --limit 3
$ gh workflow list
CI active 14473922110 .github/workflows/ci.yml

For the schedule, you do not need to wait until Monday: confirm the cron expression parses the way you think with a tool like crontab.guru, and check that the workflow file containing it is on the default branch, schedules are read from the default branch only.

Common mistake: editing the schedule trigger on a feature branch and wondering why nothing runs at the appointed time. Scheduled runs only use the workflow as it exists on the default branch. Two related gotchas: GitHub delays scheduled runs during high-load periods (a 03:17 cron may start at 03:40), and GitHub disables schedules entirely on repos with no activity for 60 days.

What this teaches: Schedules and path filters run CI only when relevant, saving minutes and money.

05
Test across versions with a matrix

Code that passes on Python 3.11 can still break on 3.10 (a typing feature) or 3.12 (a removed stdlib API). A matrix turns one job definition into N parallel jobs, one per combination of the variables you list. Replace the test job with this matrix version:

MATRIX BUILD, OS × PYTHON VERSION

  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false   # let every combination finish even if one fails
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      - run: pip install -r requirements.txt
      - run: pytest -q

This single job block produces 3 × 2 = 6 parallel jobs. fail-fast: false matters: the default is true, which cancels the other five jobs the moment one fails, convenient for saving minutes, but it hides whether a failure is version-specific or universal, which is exactly what you want to know from a matrix.

How to verify: push and watch the run. You should see six jobs, each named from the matrix values:

$ gh run watch
JOBS ✓ lint in 22s ✓ test (3.10, ubuntu-latest) in 38s ✓ test (3.11, ubuntu-latest) in 35s ✓ test (3.12, ubuntu-latest) in 36s ✓ test (3.10, windows-latest) in 71s ✓ test (3.11, windows-latest) in 68s ✗ test (3.12, windows-latest) fail in 64s

That sample output also shows the matrix doing its job: a failure isolated to one OS-and-version cell points at a platform-specific bug, not a general regression.

Common mistake: the matrix changes the status check names, they become test (3.10, ubuntu-latest) and so on. The branch protection rule from step 03 that requires test will now wait forever, because no check named exactly test exists anymore. Fix it by updating the required contexts to each matrix combination, or by adding a single test-result job with needs: test and if: always() that fails unless needs.test.result == 'success', and requiring only that one aggregator check. The aggregator pattern scales much better as the matrix grows.

What this teaches: A matrix multiplies one job definition across versions, catching version-specific breakage automatically.

06
Add secrets and a protected environment

Build and deploy steps need credentials, an API token, a registry password, an SSH key. Secrets are stored encrypted in the repo (or organization), injected into the workflow at runtime, and masked in logs. Create one with the CLI so the value never appears in your shell history as plain text:

CREATE A REPOSITORY SECRET

gh secret set DEPLOY_TOKEN
# (paste the token at the prompt, press Enter)
gh secret list
$ gh secret list
DEPLOY_TOKEN Updated 2026-09-21

Note what the listing shows: the name and the update date, never the value. There is no read-back API, if you lose the value, you rotate it, which is the correct security posture.

Next, create an environment. Environments are named deployment targets (staging, production) that can carry their own secrets and, crucially, protection rules such as required reviewers, a human must approve before a job touching production starts. Create it in the UI under Settings › Environments › New environment, name it production, and optionally add yourself as a required reviewer. Reference both in the workflow:

USING A SECRET AND AN ENVIRONMENT IN A JOB

  deploy:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./scripts/deploy.sh

Two details worth reading twice: the secret is passed through env: rather than written inline in the run: script (inline values can leak via process listing and error messages), and the if: condition restricts deploys to pushes on main, without it, every pull request would attempt a production deploy.

Common mistake: assuming secrets are available in every context. Workflows triggered by pull requests from forks receive no secrets at all (a deliberate defense against a malicious PR exfiltrating them), so a deploy step on a forked PR fails with an empty credential. Also note that log masking is literal string matching: if your token is very short or its value is a common substring, masking becomes unreliable, prefer long, randomly generated tokens.

What this teaches: Secrets stay out of logs and code; environments add manual approval gates for sensitive targets.

07
Build once, ship the artifact

Remember that every job gets a fresh, empty runner, files created in the test job do not exist in a later deploy job. The bridge between jobs is an artifact: a named bundle of files uploaded at the end of one job and downloaded at the start of another. Add a build job that packages the application and hands the package downstream:

BUILD JOB WITH ARTIFACT UPLOAD

  build:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Build the package
        run: |
          pip install build
          python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist-package
          path: dist/
          retention-days: 7

needs: [lint, test] is the sequencing mechanism: build waits until both upstream jobs succeed, and is skipped entirely if either fails, no point packaging broken code. retention-days: 7 keeps storage costs down; the default is much longer.

The deploy job from step 06 then pulls exactly the bits that were built and tested, instead of rebuilding them:

DOWNLOAD THE ARTIFACT IN THE DEPLOY JOB

      - uses: actions/download-artifact@v4
        with:
          name: dist-package
          path: dist/
      - run: ls -lh dist/    # the exact package from the build job is here

How to verify: open the run summary in the Actions tab, a "Artifacts" section lists dist-package with its size and expiry date. From the terminal, gh run view shows the same list, and you can pull the artifact down locally to inspect what would have been deployed:

DOWNLOAD AN ARTIFACT LOCALLY FOR INSPECTION

gh run download --name dist-package --dir ./artifact-check
ls ./artifact-check

Common mistake: treating artifacts as shared storage between runs or workflows. An artifact belongs to the run that created it; another workflow, or a re-run a week later after retention expired, cannot fetch it by name alone. If you need durable outputs, publish to a real package registry or a release, and keep artifacts for short-lived hand-offs inside one run.

What this teaches: Uploading build artifacts shares outputs between jobs instead of rebuilding from scratch.

08
Wire deploy after build and add a status badge

The final pipeline shape chains everything: lint and test in parallel, then build, then deploy, each stage gated by the previous one through needs:. Update the deploy job so it runs after build (not just after lint and test) and downloads the artifact as shown in step 07:

FINAL JOB CHAIN, LINT + TEST → BUILD → DEPLOY

  deploy:
    needs: [build]   # build already needs [lint, test], so this transitively gates on all three
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist-package, path: dist/ }
      - name: Deploy
        env: { DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} }
        run: ./scripts/deploy.sh dist/

Because needs is transitive, requiring build is enough: build itself required lint and test, so deploy can never start on unverified code. In the Actions UI the run renders as a left-to-right graph of these dependencies.

Last, make the pipeline's health visible without opening the Actions tab: every workflow exposes a badge SVG. Add it to the top of your README.md:

STATUS BADGE FOR README.md

![CI](https://github.com/OWNER/REPO/actions/workflows/ci.yml/badge.svg?branch=main)

Replace OWNER/REPO with your repository slug. The ?branch=main query keeps the badge pinned to the default branch so a red feature-branch experiment does not make the project look broken.

How to verify: paste the badge URL into a browser, it should return an SVG reading "CI | passing" once the latest run on main is green. For the deploy gating, push to a feature branch and confirm in the Actions tab that the deploy job is skipped, then merge to main and confirm it runs (and pauses for approval if you configured required reviewers on the production environment).

Common mistake: putting the badge in a private repo's README and getting a broken image, badge SVGs for private repos require authentication. Also common: a deploy step that "succeeds" while doing nothing because deploy.sh exits 0 on every path. Make the script fail loudly (set -euo pipefail in bash) and verify a real post-deploy signal, a health-check endpoint, a version endpoint, a log line on the server, rather than trusting the green checkmark alone.

What this teaches: Chaining deploy after build with needs:, plus a status badge, makes the pipeline visible and trustworthy.

Progress So Far

Testing & Validation

Never trust a gate you have not tried to walk through. Validation has two halves: confirm the happy path is green, then deliberately break something and confirm the gate actually closes.

Part 1, happy path. The latest run should be green:

gh run list --limit 1 && gh run view --log-failed 2>/dev/null | head

The latest run should show success. Push a deliberately failing test and confirm the workflow goes red and blocks the merge.

Part 2, break it on purpose. Open a feature branch, sabotage one test, and open a pull request:

CREATE A DELIBERATELY FAILING PULL REQUEST

git switch -c demo/failing-test
# change an expected value in tests/test_app.py so one assertion fails
git commit -am "Demo: break a test to prove CI blocks it"
git push -u origin demo/failing-test
gh pr create --title "Demo: failing test" --body "Should be blocked by CI."
$ gh pr checks
Some checks were not successful 1 failing, 1 successful, 0 skipped, and 0 pending checks ✗ test fail 43s https://github.com/TYFSADIK/my-project/actions/runs/14473924501 ✓ lint pass 22s https://github.com/TYFSADIK/my-project/actions/runs/14473924501

On the pull request page the merge button is now disabled with the message "Merging is blocked: required status check 'test' is failing." That disabled button is the entire point of the lab, the repository, not human discipline, enforces quality. Confirm the failure is also visible from the API, which is what the merge box itself consults:

CONFIRM THE CHECK STATE FROM THE API

gh api repos/:owner/:repo/commits/HEAD/check-runs \
  --jq '.check_runs[] | "\(.name): \(.conclusion)"'
$ gh api repos/:owner/:repo/commits/HEAD/check-runs --jq '.check_runs[] | "\(.name): \(.conclusion)"'
lint: success test: failure

Clean up by fixing the assertion, pushing the fix to the same branch, and watching gh pr checks flip green, the merge button unlocks automatically. Close the demo PR unmerged if you prefer; either way, delete the branch afterwards. You have now observed the full red-to-green lifecycle that every future contributor to this repo will experience.

Troubleshooting
  • Workflow not triggering: the file must be in .github/workflows/ on the default branch.
  • Cache misses: ensure the cache key matches the lockfile path.
  • Secrets needed: add them under repo Settings, reference via ${{ secrets.NAME }}.
  • "Resource not accessible by integration" when a step comments on a PR or pushes: the default GITHUB_TOKEN is read-only on many repos. Grant what the job needs explicitly at the top of the workflow, least privilege, named per permission:
    permissions:
      contents: read
      pull-requests: write
    Also check Settings › Actions › General › Workflow permissions is not set to read-only if a job genuinely needs to write.
  • Push rejected with "refusing to allow an OAuth App to create or update workflow": your gh token is missing the workflow scope. Run gh auth login --scopes workflow (or gh auth refresh -s workflow) and push again. This only affects commits that touch files under .github/workflows/.
  • Required check listed as "Expected, waiting for status to be reported" forever: the context name in branch protection does not match the job name, or the workflow's triggers do not cover this event (for example push.branches: [main] only, but the PR targets develop). Compare the protection contexts from gh api repos/:owner/:repo/branches/main/protection/required_status_checks against the exact job names in ci.yml.
  • Runs randomly killed or steps hang: long-running steps without output can hit the 6-hour job limit or appear frozen. Add a timeout to keep failures fast and cheap: timeout-minutes: 15 on the job, and check concurrency.cancel-in-progress is not cancelling runs you actually wanted (each branch needs its own group key).

Troubleshooting

SymptomLikely causeFix
workflow never triggersthe YAML is not on the default branch or the trigger event is wrongmerge the workflow to main first; check the Actions tab for the event filter
job fails on actions/setup-pythonversion pin or runner image issuepin the action to a full SHA and check the setup log lines
cache never hitsthe key changes every run (e.g. includes a timestamp)key on lockfile hashes: hashFiles('**/requirements.txt')
secret is empty in the jobtypo in the secret name or wrong environment scopenames are case-sensitive; environment secrets need the environment named on the job
required check never reportsthe check name in branch protection does not match the job namecopy the exact check name from a completed run into the protection rule

Extension Ideas

  • Add build + deploy stages in Full CI/CD Pipeline, chain jobs with needs: so deploy only runs after lint and test pass.
  • Scan images (see Docker Images) in CI.
  • Deploy to Azure with a service principal from Azure CLI Setup.
  • Add a matrix to test on multiple Python versions at once: strategy.matrix.python-version: ['3.10', '3.11', '3.12'].
  • Run the workflow on a schedule (on: schedule: cron: '17 3 * * 1') to catch dependency rot even when nobody is pushing.

Practice Challenges

Challenge 1: Reusable workflow

Goal: Extract the test job into a reusable workflow called with workflow_call from a second repo.

Hint: Inputs and secrets pass explicitly; the caller declares them under with: and secrets:.

Challenge 2: Concurrency guard

Goal: Add a concurrency group so pushes to a PR cancel superseded runs instead of queueing.

Hint: concurrency: group: ${{ github.workflow }}-${{ github.ref }} with cancel-in-progress: true.

Key Results

  • Ran lint and tests automatically on every push and PR.
  • Sped up runs with dependency caching.
  • Made CI a required check protecting main.
  • Confirmed failing code is blocked from merge.

Key Takeaways

  • A workflow is versioned YAML: events (on:) trigger jobs, jobs run on ephemeral runners, steps are either uses: (an action) or run: (shell).
  • Always actions/checkout first, the runner starts empty.
  • Caching is keyed on the lockfile hash; when the lockfile changes, the cache rebuilds itself. Verify with "Cache restored from key" in the logs.
  • A green workflow is only half the protection, branch protection with named, strict required checks is what actually blocks bad merges.
  • Prove the gate works: a deliberately failing PR that gets blocked is the only trustworthy evidence the setup is correct.
  • gh run watch, gh run view --log-failed, and actionlint cover nearly all day-to-day workflow debugging from the terminal.