Module 05 · GitHub Actions & CI/CD
- Explain the structure of a GitHub Actions workflow file — triggers, jobs, steps, and runners
- Read and explain every job in the A2A project's ci.yml
- Describe the difference between unit tests and integration tests and why they run on different schedules
- Trigger a workflow manually using workflow_dispatch
- Add a new job to the CI pipeline via a Pull Request
- Explain the CI Gate pattern and why it simplifies branch protection configuration
Background
Every module so far has had a human in the loop — you wrote the code, you ran the tests, you reviewed the PR. That works when you’re the only contributor. It doesn’t scale.
Continuous Integration (CI) is the practice of running automated checks on every proposed change before it merges. A CI pipeline catches broken tests, linting failures, and schema mismatches automatically — so reviewers can focus on logic and design rather than mechanics.
GitHub Actions is GitHub’s built-in CI/CD platform. Workflows are defined
as YAML files in .github/workflows/ and run on GitHub-hosted virtual machines
called runners. The A2A project already has a fully-built, heavily-commented
CI pipeline waiting for you. This module is about reading it, understanding it,
running it, and extending it — not building it from scratch.
The A2A project has two workflow files. ci.yml runs on every PR and push
to main — it lints content, tests both the Python and Node.js starter
project variants, and validates the A2A message schema. nightly.yml runs
the full integration test suite every night, starting all services as
background processes and making real HTTP calls through the Orchestrator.
In this module you’ll trigger both, read every line, and add a new job
to ci.yml via a PR.
Concepts
Anatomy of a Workflow File
A GitHub Actions workflow is a YAML file with four top-level sections:
name: CI # 1. Name — shown in the Actions tab
on: # 2. Triggers — WHEN does this run? push: branches: [main] pull_request: branches: [main] workflow_dispatch: # Manual trigger via the UI
jobs: # 3. Jobs — WHAT runs? my-job: runs-on: ubuntu-latest # 4. Runner — WHERE does it run? steps: - name: Say hello run: echo "Hello from CI"Each of these sections is covered below.
Triggers (on)
The on key controls when the workflow fires. The most common triggers:
| Trigger | When it runs |
|---|---|
push | A commit is pushed to the specified branch |
pull_request | A PR is opened, updated, or reopened |
schedule | On a cron schedule (e.g. nightly at 02:00 UTC) |
workflow_dispatch | Manually via the Actions UI or gh workflow run |
workflow_call | Called by another workflow (reusable workflows) |
You can stack multiple triggers — the A2A ci.yml uses push, pull_request,
and workflow_dispatch together. The workflow runs whenever any of the
conditions is met.
Path filtering narrows a trigger to only fire when specific files change:
on: push: paths: - "starter-project/**" # Only run when starter project changes - ".github/workflows/**" # Or when workflow files changeThe nightly.yml uses this — it runs on push to main only when files
under starter-project/ change, avoiding unnecessary nightly re-runs for
documentation-only commits.
Jobs and Parallelism
Jobs are the top-level units of work. By default they run in parallel — all jobs start at the same time on separate runners. This is why the A2A CI pipeline is fast despite having seven jobs: linting, testing, and schema validation all run simultaneously.
The needs keyword creates dependencies between jobs:
jobs: test: runs-on: ubuntu-latest steps: [...]
ci-gate: # This job waits for `test` to finish needs: [test] runs-on: ubuntu-latest steps: [...]The ci-gate job at the bottom of ci.yml uses needs to wait for every
other job. It’s the one job configured as a required status check in branch
protection — more on this in Part 3.
Steps
Steps are the individual commands within a job. Each step either:
- Runs a shell command with
run: - Uses a pre-built action with
uses:
steps: - name: Checkout code # uses a Marketplace action uses: actions/checkout@v4
- name: Run tests # runs a shell command run: pytest tests/ -v
- name: Upload results # uses an action with inputs uses: actions/upload-artifact@v4 with: name: test-results path: coverage.xmlRunners
A runner is the virtual machine that executes a job. GitHub provides:
| Runner | OS | Use for |
|---|---|---|
ubuntu-latest | Ubuntu Linux | Most CI tasks — fastest and cheapest |
windows-latest | Windows Server | Windows-specific testing |
macos-latest | macOS | macOS-specific testing |
The A2A project uses ubuntu-latest throughout. GitHub also supports
self-hosted runners — your own machines registered with GitHub — for
cases where you need specific hardware or private network access.
Expressions and Contexts
Workflows use ${{ }} syntax to access dynamic values:
# Context variables${{ github.actor }} # Username who triggered the run${{ github.ref }} # Branch or tag ref (e.g. refs/heads/main)${{ github.event_name }} # What triggered the run (push, pull_request...)${{ github.sha }} # Full commit SHA
# Secrets${{ secrets.MY_SECRET }} # Repository or org secret
# Previous step outputs${{ steps.my-step.outputs.result }}
# Matrix values${{ matrix.python-version }}Matrix Builds
A matrix runs the same job multiple times with different values. The A2A
test-python job uses a matrix to test against Python 3.11 and 3.12
simultaneously:
strategy: matrix: python-version: ["3.11", "3.12"] fail-fast: false # Don't cancel 3.12 if 3.11 fails — see both resultsThis creates two parallel job instances: one for each Python version.
fail-fast: false means both complete even if one fails — you see all
failures rather than just the first.
Actions from the Marketplace
The GitHub Marketplace has thousands of pre-built actions. Instead of writing a 20-line shell script to set up Python, you use:
- uses: actions/setup-python@v5 with: python-version: "3.12" cache: pipPinning to commit SHAs is a security practice the A2A workflow uses
throughout. Notice the ci.yml format:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2That long hash is the exact commit SHA of the action — it cannot be changed
or redirected. A version tag like @v4 can be moved by the action author
to point at a different (potentially malicious) commit. Pinning to a SHA
is immutable. The comment # v4.2.2 keeps it human-readable. Dependabot
automatically opens PRs to update these SHAs when new versions are released.
The CI Gate Pattern
Configure branch protection to require one job — CI Gate — rather than
every individual job. The CI Gate job uses needs to depend on all other jobs
and if: always() to run even when dependencies fail:
ci-gate: needs: [lint-markdown, lint-yaml, test-python, test-nodejs, validate-schema] if: always() steps: - name: Evaluate results run: | results="${{ join(needs.*.result, ' ') }}" if echo "$results" | grep -qE '(failure|cancelled)'; then exit 1 fiWhy this matters: without the gate, you must list every job individually
in the branch protection required status checks UI. Each time you add a new
job to the pipeline, you must remember to also add it to branch protection.
With the gate, you add it to the needs list in the workflow and branch
protection configuration never changes.
Exercise
Part 1 — Read ci.yml from Top to Bottom
Before writing a single line, read the existing workflow. Open it alongside this page:
cat .github/workflows/ci.ymlOr open it in the Codespace editor:
code .github/workflows/ci.yml-
Find the triggers. What three events cause this workflow to run? Why does
workflow_dispatchexist? -
Find the
concurrencyblock. What doescancel-in-progress: truedo? When would two CI runs for the same branch overlap? -
Find the
permissionsblock. Why iscontents: readthe only permission listed at the top level? What could go wrong withcontents: writeas the default? -
Count the jobs. There are seven. List them and describe what each one does in one sentence. Don’t look at the comments — read the step names and commands.
-
Find the matrix. Which jobs use a matrix? What are the two Python versions being tested? What does
fail-fast: falsedo? -
Find every
uses:step. Notice that every action is pinned to a commit SHA with a version comment. Find the comment foractions/checkout. What version is it pinned to? -
Find the
ci-gatejob. What is theneedsarray? What doesif: always()do and why is it necessary here?
Part 2 — Trigger the Workflow Manually
-
Navigate to your repository on GitHub → Actions tab.
-
In the left sidebar under Workflows, click CI.
-
Click the Run workflow button (top-right of the workflow list). Select
mainas the branch and click Run workflow. -
Refresh the page. A new run appears at the top of the list with a spinning yellow circle — it’s running.
-
Click into the run. You’ll see the workflow visualisation — each job as a box, with arrows showing the
needsdependencies. Jobs running in parallel show side by side. -
Click into the Test Python Starter Project job. Expand the Run unit tests with coverage step. You’re watching live log output from a virtual machine running your tests on GitHub’s infrastructure.
-
Wait for all jobs to complete. Examine the results:
- Green ✅ — job passed
- Red ❌ — job failed (click to see why)
- Grey ⊘ — job was skipped (e.g.
check-linksonly runs onpush)
-
Click the CI Gate job. Note the
Evaluate CI resultsstep output — it lists the result of every dependency job and exits 0 (success) or 1 (failure) accordingly.
Part 3 — Trigger via the CLI
-
In your Codespace terminal, trigger a manual run with the GitHub CLI:
Terminal window gh workflow run ci.yml --ref main -
Watch the run start:
Terminal window gh run list --workflow=ci.yml --limit 3 -
Stream the live logs of the most recent run:
Terminal window gh run watchPress
Ctrl+Cwhen you’ve seen enough — the run continues in the background. -
Once finished, view the summary:
Terminal window gh run view --log-failed--log-failedonly shows output from failed steps — useful for quickly finding what broke without scrolling through all the passing steps.
Part 4 — Read nightly.yml
The nightly workflow is a different kind of pipeline — it tests the running system, not the code in isolation.
code .github/workflows/nightly.yml-
Find the
scheduletrigger. The cron expression is0 2 * * *. What does that mean? Use crontab.guru to verify your reading. -
Find the
workflow_dispatchinputs. What is thetarget_agentinput used for? Find where it’s referenced in the run step. -
Find the
timeout-minutessetting. Why does the integration test job have a timeout but the unit test job inci.ymldoesn’t? -
Find how services are started. The Python integration test job starts the Orchestrator and both agents as background processes using
&. Find the health check loop — why does it poll instead of using a fixedsleep? -
Find the
if: failure()step. What does it do? When does it run? Why isif: failure()better than always dumping logs? -
Find the
notify-on-failurejob. It usesgithub-script— a special action that lets you write JavaScript to call the GitHub API directly from a workflow. What API call does it make? What conditions must be true for this job to run?
Part 5 — Add a New Job to CI
Now you’ll extend the pipeline. You’ll add a job that validates the A2A
.env.example files — checking that they contain all required variable
names, and that none of them contains a real API key pattern.
-
Create a feature branch:
Terminal window git switch maingit pull origin maingit switch -c feat/ci-validate-env-examples -
Open
ci.yml:Terminal window code .github/workflows/ci.yml -
Add this new job before the
ci-gatejob. Find the line# JOB 7: CI Gateand insert the new job above it:# ──────────────────────────────────────────────────────────# JOB 7: Validate .env.example Files# Checks that .env.example files contain required variable# names and don't accidentally include real credentials.# ──────────────────────────────────────────────────────────validate-env-examples:name: Validate .env.example filesruns-on: ubuntu-lateststeps:- name: Checkout repositoryuses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2- name: Check .env.example filesrun: |echo "Checking .env.example files..."FAILED=0for f in $(find . -name ".env.example" -not -path "*/node_modules/*"); doecho "Checking $f"# Fail if any line looks like a real secret value# (non-placeholder values beside known key names)if grep -qE "(API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*[a-zA-Z0-9]{20,}" "$f"; thenecho " ❌ FAIL: $f appears to contain a real secret value"FAILED=1elseecho " ✅ PASS: No real secret patterns detected"fi# Warn if the file is emptyif [ ! -s "$f" ]; thenecho " ⚠️ WARN: $f is empty"fidoneif [ $FAILED -eq 1 ]; thenecho ""echo "One or more .env.example files may contain real credentials."echo "Replace any real values with placeholders like 'your-api-key-here'."exit 1fiecho ""echo "All .env.example files look clean." -
Now update the
ci-gatejob’sneedslist to include the new job. Find theneeds:block inci-gateand addvalidate-env-examples:ci-gate:needs:- lint-markdown- lint-yaml- test-python- test-nodejs- validate-schema- validate-env-examples # ← add this line -
Commit and push:
Terminal window git add .github/workflows/ci.ymlgit commit -m "feat(ci): add .env.example validation jobChecks that .env.example files don't contain real credentialvalues — catches accidental secret commits before they reachmain. Added to ci-gate so branch protection picks it upautomatically."git push origin feat/ci-validate-env-examples -
Open a PR. In the description, use the PR template’s Checklist section and note:
- This is a
chore/ CI change, not a lesson content change - The new job is included in the
ci-gateneedslist
- This is a
-
Navigate to the Actions tab. Watch the CI run triggered by your PR. Find your new
validate-env-examplesjob and confirm it passes. -
Merge the PR once CI is green.
Part 6 — Inspect an Artifact
The test-python job uploads a coverage report as an artifact. Let’s
download and inspect it.
-
Go to Actions → click the most recent CI run.
-
Scroll to the bottom of the run summary page. Under Artifacts, you’ll see
python-coverage-3.11andpython-coverage-3.12. -
Click to download one. Unzip it — you’ll find a
coverage.xmlfile. -
Open it in your editor. It’s an XML report of which lines in the Python source files were executed during the tests. This is what a coverage reporting tool like Codecov or Coveralls reads to display a coverage percentage badge on your README.
-
Via the CLI:
Terminal window # List artifacts for the most recent rungh run list --workflow=ci.yml --limit 1 --json databaseId \--jq '.[0].databaseId' | \xargs -I {} gh run download {}
Workflow Patterns Reference
# Only run on push to main (not PRs)- name: Deploy if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: ./deploy.sh
# Only run if a previous step failed- name: Notify on failure if: failure() run: echo "Something broke"
# Always run (even if previous steps failed)- name: Upload logs if: always() uses: actions/upload-artifact@v4 with: name: logs path: "*.log"
# Skip on draft PRs- name: Run expensive check if: github.event.pull_request.draft == false run: ./expensive-check.sh# Cache pip dependencies- uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip cache-dependency-path: requirements.txt
# Cache npm dependencies- uses: actions/setup-node@v4 with: node-version: "20" cache: npm cache-dependency-path: package-lock.json
# Manual cache (for anything else)- uses: actions/cache@v4 with: path: ~/.cache/myapp key: myapp-${{ hashFiles('**/lockfile') }} restore-keys: myapp-steps: - name: Get version id: version # Give the step an ID to reference run: | VERSION=$(cat VERSION) echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Use version run: echo "Building version ${{ steps.version.outputs.version }}"on: schedule: # Every night at 02:00 UTC - cron: "0 2 * * *"
# Every Monday at 09:00 UTC - cron: "0 9 * * 1"
# Every 6 hours - cron: "0 */6 * * *"
# cron syntax: minute hour day month weekday# Use https://crontab.guru to verify expressionsSummary
In this module you:
- Learned the structure of a workflow file — triggers, jobs, steps, runners,
and the
${{ }}expression syntax - Read every line of
ci.ymland understood the purpose of all seven jobs: Markdown linting, YAML linting, link checking, Python tests, Node.js tests, schema validation, and the CI Gate - Used matrix builds to run the same test job across multiple Python and Node.js versions in parallel
- Understood why SHA-pinned actions are a security requirement, not just a style preference
- Triggered a workflow manually from the GitHub UI and the
ghCLI, and streamed live logs - Read
nightly.ymland understood the difference between unit tests (every PR) and integration tests (nightly scheduled run) - Added a new
validate-env-examplesjob to the CI pipeline via a PR, and updated the CI Gate to include it — demonstrating how the gate pattern makes the pipeline extensible without touching branch protection configuration
The CI pipeline you now understand is the safety net for every PR that follows. When it’s green, maintainers can trust that tests pass, content is formatted consistently, and the A2A schema hasn’t drifted. When it’s red, the PR stays open until it’s fixed.
What’s Next
Module 06 · Security on GitHub →
You’ll enable the full GitHub security suite on the A2A project — Dependabot
alerts, Secret Scanning, CodeQL code scanning, and branch protection rulesets.
You’ll write a CODEOWNERS file that routes AI agent PRs to the right
reviewers automatically, and generate a Software Bill of Materials for the
first versioned release.