diff --git a/content/1-hour/0-setup.md b/content/1-hour/0-setup.md
index 86cddf6..61dd9c5 100644
--- a/content/1-hour/0-setup.md
+++ b/content/1-hour/0-setup.md
@@ -17,14 +17,14 @@ Let's create the repository you'll use for your workshop.
1. Navigate to [the repository root](/)
2. Select **Use this template** > **Create a new repository**
- 
+ 
3. Under **Owner**, select the name of your GitHub handle, or the owner specified by your workshop leader.
4. Under **Repository**, set the name to **pets-workshop**, or the name specified by your workshop leader.
5. Ensure **Public** is selected for the visibility, or the value indicated by your workshop leader.
6. Select **Create repository from template**.
- 
+ 
In a few moments a new repository will be created from the template for this workshop!
diff --git a/content/README.md b/content/README.md
index c3fe1aa..63c20ae 100644
--- a/content/README.md
+++ b/content/README.md
@@ -1,11 +1,12 @@
# Pets workshop
-This repository contains two workshops:
+This repository contains three workshops:
- a [one hour](./1-hour/README.md) workshop focused on GitHub Copilot.
- a [full-day](./full-day/README.md) workshop which covers a full day-in-the-life of a developer using GitHub for their DevOps processes.
+- a [GitHub Actions](./github-actions/README.md) workshop covering CI/CD pipelines from running tests to deploying to Azure.
-Both workshops are built around a fictional dog shelter, where you are a volunteer helping them build out their website.
+All workshops are built around a fictional dog shelter, where you are a volunteer helping them build out their website.
## Get started
diff --git a/content/full-day/0-setup.md b/content/full-day/0-setup.md
index 3bb7aa3..4a29341 100644
--- a/content/full-day/0-setup.md
+++ b/content/full-day/0-setup.md
@@ -12,12 +12,12 @@ Let's create the repository you'll use for your workshop.
1. Navigate to [the repository root][repo-root]
2. Select **Use this template** > **Create a new repository**
- 
+ 
3. Under **Owner**, select the name of your GitHub handle, or the owner specified by your workshop leader.
4. Under **Repository**, set the name to **pets-workshop**, or the name specified by your workshop leader.
5. Ensure **Public** is selected for the visibility, or the value indicated by your workshop leader.
6. Select **Create repository from template**.
- 
+ 
In a few moments a new repository will be created from the template for this workshop!
diff --git a/content/github-actions/0-setup.md b/content/github-actions/0-setup.md
new file mode 100644
index 0000000..3bccf7f
--- /dev/null
+++ b/content/github-actions/0-setup.md
@@ -0,0 +1,51 @@
+# Workshop Setup
+
+| [← GitHub Actions: From CI to CD][walkthrough-previous] | [Next: Introduction & Your First Workflow →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+To complete this workshop you will need to create a repository with a copy of the contents of this repository. While this can be done by [forking a repository][fork-repo], the goal of a fork is to eventually merge code back into the original (or upstream) source. In our case we want a separate copy as we don't intend to merge our changes. This is accomplished through the use of a [template repository][template-repo]. Template repositories are a great way to provide starters for your organization, ensuring consistency across projects.
+
+The repository for this workshop is configured as a template, so we can use it to create your repository.
+
+## Create your repository
+
+Let's create the repository you'll use for your workshop.
+
+1. Navigate to [the repository root][repo-root]
+2. Select **Use this template** > **Create a new repository**
+
+ 
+
+3. Under **Owner**, select the name of your GitHub handle, or the owner specified by your workshop leader.
+4. Under **Repository**, set the name to **pets-workshop**, or the name specified by your workshop leader.
+5. Ensure **Public** is selected for the visibility, or the value indicated by your workshop leader.
+6. Select **Create repository from template**.
+
+ 
+
+In a few moments a new repository will be created from the template for this workshop!
+
+## Open your codespace
+
+Now let's open a codespace so you have a development environment ready to go.
+
+1. Navigate to the main page of your newly created repository.
+2. Select **Code** > **Codespaces** > **Create codespace on main**.
+
+ In a few moments a codespace will open in your browser with a full VS Code editor. This is where you'll create and edit files throughout the workshop.
+
+> [!TIP]
+> If your codespace ever disconnects or you close the tab, you can reopen it by navigating to your repository and selecting **Code** > **Codespaces** and the name of your codespace.
+
+## Summary and next steps
+
+You've created the repository and opened a codespace — you're ready to start building! Next let's [create your first workflow][walkthrough-next].
+
+| [← GitHub Actions: From CI to CD][walkthrough-previous] | [Next: Introduction & Your First Workflow →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[fork-repo]: https://docs.github.com/get-started/quickstart/fork-a-repo
+[template-repo]: https://docs.github.com/repositories/creating-and-managing-repositories/creating-a-template-repository
+[repo-root]: /
+[walkthrough-previous]: README.md
+[walkthrough-next]: 1-introduction.md
diff --git a/content/github-actions/1-introduction.md b/content/github-actions/1-introduction.md
new file mode 100644
index 0000000..065f1d5
--- /dev/null
+++ b/content/github-actions/1-introduction.md
@@ -0,0 +1,126 @@
+# Introduction & Your First Workflow
+
+| [← Workshop Setup][walkthrough-previous] | [Next: Securing the Development Pipeline →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[GitHub Actions][github-actions] is an automation platform built into GitHub that lets you build, test, and deploy your code directly from your repository. While it's most commonly used for CI/CD, it can automate just about any task in your development workflow — from labeling issues to resizing images.
+
+Before diving in, here are the key terms you'll encounter:
+
+- **Workflow**: An automated process defined in a YAML file, stored in `.github/workflows/`.
+- **Event**: A trigger that starts a workflow, such as a `push`, `pull_request`, or `workflow_dispatch`.
+- **Job**: A set of steps that run on the same runner. Jobs run in parallel by default.
+- **Step**: An individual task within a job — either a shell command (`run`) or a reusable action (`uses`).
+- **Runner**: The virtual machine that executes your jobs (e.g., `ubuntu-latest`).
+- **Action**: A reusable unit of code that performs a specific task, published on the [Actions Marketplace][actions-marketplace].
+
+## Scenario
+
+The shelter has built its application — a Flask API and Astro frontend — and the team is ready to start automating their development workflow. Before diving into CI/CD, let's start with the basics: creating a simple workflow, triggering it manually, and understanding the logs.
+
+## Background
+
+A workflow file is written in YAML and lives in the `.github/workflows/` directory. Here are the core sections you'll work with:
+
+- `name`: A human-readable name for the workflow, displayed in the **Actions** tab.
+- `on`: Defines the events that trigger the workflow (e.g., `push`, `pull_request`, `workflow_dispatch`).
+- `jobs`: Contains one or more jobs, each with a unique identifier.
+ - `runs-on`: Specifies the runner environment (e.g., `ubuntu-latest`).
+ - `steps`: An ordered list of tasks the job performs.
+ - `uses`: References a reusable action (e.g., `actions/checkout@v4`).
+ - `run`: Executes a shell command.
+
+## Create your first workflow
+
+Let's start with the classic "Hello World" — a workflow you can trigger manually from the GitHub UI.
+
+1. In your codespace, create the folder `.github/workflows/` if it doesn't already exist.
+2. Create a new file named `.github/workflows/hello.yml`.
+3. Add the following content:
+
+ ```yaml
+ name: Hello World
+
+ on:
+ workflow_dispatch:
+
+ jobs:
+ greet:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Say hello
+ run: echo "Hello, GitHub Actions!"
+
+ - name: Show environment info
+ run: |
+ echo "Runner OS: $RUNNER_OS"
+ echo "Repository: $GITHUB_REPOSITORY"
+ echo "Triggered by: $GITHUB_ACTOR"
+ ```
+
+4. Save the file.
+
+> [!NOTE]
+> The `workflow_dispatch` event lets you trigger the workflow manually from the **Actions** tab. This is useful for testing workflows without needing to push code changes every time.
+
+## Push and run
+
+Now let's push the workflow and trigger it by hand.
+
+1. Open the terminal in your codespace by pressing Ctl+`.
+2. Stage and commit your changes:
+
+ ```bash
+ git add .github/workflows/hello.yml
+ git commit -m "Add hello world workflow"
+ ```
+
+3. Push to your repository:
+
+ ```bash
+ git push
+ ```
+
+4. Navigate to your repository on GitHub and select the **Actions** tab.
+5. In the left sidebar, select the **Hello World** workflow.
+6. Select the **Run workflow** button, keep the default branch, and select **Run workflow** again to confirm.
+
+## Explore the logs
+
+Once the run completes, let's explore what happened.
+
+1. Select the workflow run that just completed.
+2. Select the **greet** job to expand it.
+3. Explore the logs for each step:
+ - **Say hello** — you'll see the `echo` output.
+ - **Show environment info** — notice the environment variables that GitHub Actions provides automatically (`RUNNER_OS`, `GITHUB_REPOSITORY`, `GITHUB_ACTOR`).
+4. Also look at the **Set up job** and **Complete job** steps that Actions adds automatically — these show the runner setup and cleanup.
+
+> [!TIP]
+> You can search within the logs using the search box at the top of the log viewer, and expand or collapse individual steps. This becomes very useful as workflows grow more complex.
+
+## Summary and next steps
+
+Congratulations! You've created and run your first GitHub Actions workflow. You've learned how to define a workflow in YAML, trigger it manually with `workflow_dispatch`, and navigate the logs in the Actions UI.
+
+Next, we'll put this knowledge to work by [securing the development pipeline][walkthrough-next] with code scanning, Dependabot, and secret scanning.
+
+### Resources
+
+- [GitHub Actions documentation][github-actions-docs]
+- [Workflow syntax for GitHub Actions][workflow-syntax]
+- [Events that trigger workflows][workflow-triggers]
+- [Understanding GitHub Actions][understanding-actions]
+
+| [← Workshop Setup][walkthrough-previous] | [Next: Securing the Development Pipeline →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[github-actions]: https://github.com/features/actions
+[github-actions-docs]: https://docs.github.com/actions
+[understanding-actions]: https://docs.github.com/actions/about-github-actions/understanding-github-actions
+[workflow-syntax]: https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions
+[workflow-triggers]: https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
+[walkthrough-previous]: 0-setup.md
+[walkthrough-next]: 2-code-scanning.md
diff --git a/content/github-actions/2-code-scanning.md b/content/github-actions/2-code-scanning.md
new file mode 100644
index 0000000..7df9fe8
--- /dev/null
+++ b/content/github-actions/2-code-scanning.md
@@ -0,0 +1,121 @@
+# Securing the Development Pipeline
+
+| [← Introduction & Your First Workflow][walkthrough-previous] | [Next: Running Tests →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+In the previous exercise you created your first GitHub Actions workflow — a manually triggered "Hello World." Before building out CI/CD, let's explore security. Ensuring code security is imperative in today's environment, and GitHub provides tools that automate this for you — many of which are powered by GitHub Actions under the hood.
+
+When we think about how we create code today, there are three main areas to secure:
+
+- The **code we write** — which may contain vulnerabilities
+- The **libraries we use** — which may have known security issues
+- The **credentials we manage** — which may accidentally leak into source code
+
+[GitHub Advanced Security][advanced-security] provides a suite of tools covering each of these areas. Let's explore and enable them on our repository.
+
+## Scenario
+
+Security is important in every application. By detecting potential vulnerabilities early, teams can make updates before incidents occur. The shelter wants to ensure insecure code and libraries are detected as early as possible. You'll enable Dependabot, secret scanning, and code scanning to meet these needs.
+
+## Background
+
+[GitHub Advanced Security][advanced-security-docs] is a set of security features available directly in GitHub. The three pillars are:
+
+- **Code scanning** analyzes your source code for security vulnerabilities using [CodeQL][about-code-scanning], GitHub's semantic code analysis engine. When enabled, it runs as a GitHub Actions workflow — the same automation platform you used in the previous exercise. Every push and pull request triggers the analysis automatically.
+- **Dependabot** monitors your project's dependencies for known vulnerabilities and can automatically create [pull requests][about-prs] to update insecure packages to safe versions.
+- **Secret scanning** detects tokens, keys, and other credentials that have been committed to your repository, and can block pushes that contain [supported secrets][supported-secrets].
+
+> [!NOTE]
+> Code scanning is built on [GitHub Actions][github-actions]. When you enable CodeQL's default setup, GitHub creates and manages a workflow for you behind the scenes. You'll see this connection more clearly when you navigate to the **Actions** tab after enabling it. This is a great example of how Actions powers automation across the GitHub platform — not just CI/CD pipelines you write yourself.
+
+## Configure Dependabot
+
+Most projects depend on open source and external libraries. While modern development would be impossible without them, we always need to ensure the dependencies we use are secure. [Dependabot][dependabot-quickstart] monitors your repository's dependencies and raises alerts — or even creates pull requests — to update insecure packages.
+
+Public repositories on GitHub automatically have Dependabot alerts enabled. Let's configure Dependabot to also create PRs that update insecure library versions automatically.
+
+1. Navigate to your repository on GitHub.
+2. Select **Settings** > **Code security** (under **Security** in the sidebar).
+3. Locate the **Dependabot** section.
+
+ 
+
+4. Select **Enable** next to **Dependabot security updates** to configure Dependabot to create PRs to resolve alerts.
+
+You've now enabled Dependabot alerts and security updates! When an insecure library is detected, you'll receive an alert, and Dependabot will create a pull request to update to a secure version.
+
+> [!TIP]
+> Dependabot doesn't just alert you — it can automatically create pull requests that bump library versions to secure ones. When you pair this with a CI pipeline that runs tests on every PR (which you'll build in the [next exercise][walkthrough-next]), those Dependabot PRs are automatically tested before merging. This creates a powerful feedback loop: vulnerabilities are detected, fixes are proposed, and your tests verify the update won't break anything — all without manual intervention.
+
+> [!IMPORTANT]
+> After enabling Dependabot security updates you may notice new pull requests created for potentially outdated packages. For this workshop you can ignore these pull requests.
+
+## Enable secret scanning
+
+Many developers have accidentally checked in code containing tokens or credentials. Regardless of the reason, even seemingly innocuous tokens can create a security issue. [Secret scanning][about-secret-scanning] detects tokens in your source code and raises alerts. With push protection enabled, pushes containing supported secrets are blocked before they reach your repository.
+
+1. On the same **Code security** settings page, locate the **Secret scanning** section.
+2. Next to **Receive alerts on GitHub for detected secrets, keys or other tokens**, select **Enable**.
+3. Next to **Push protection**, select **Enable** to block pushes containing a [supported secret][supported-secrets].
+
+ 
+
+You've now enabled secret scanning and push protection — helping prevent credentials from reaching your repository.
+
+## Enable code scanning
+
+There is a direct relationship between the amount of code an organization writes and its potential attack surface. [Code scanning][about-code-scanning] analyzes your source code for known vulnerabilities. When an issue is detected on a pull request, a comment is added highlighting the affected line with contextual information for the developer.
+
+Let's enable code scanning with the default CodeQL setup. This runs automatically whenever code is pushed to `main` or a pull request targets `main`, and on a regular schedule to catch newly discovered vulnerabilities.
+
+1. On the same **Code security** settings page, locate the **Code scanning** section.
+2. Next to **CodeQL analysis**, select **Set up** > **Default**.
+
+ 
+
+3. On the **CodeQL default configuration** dialog, select **Enable CodeQL**.
+
+ 
+
+> [!IMPORTANT]
+> Your list of languages may be different from what's shown in the screenshot.
+
+A background process starts and configures a CodeQL analysis workflow for your repository.
+
+> [!TIP]
+> After enabling CodeQL, navigate to the **Actions** tab in your repository. You'll see a new **CodeQL** workflow listed alongside the **Hello World** workflow you created earlier. This is the Actions workflow that GitHub created automatically to run code scanning — proof that Actions isn't just for CI/CD, but powers many of GitHub's built-in features.
+
+## Summary and next steps
+
+You've enabled GitHub Advanced Security for your repository:
+
+- **Dependabot** monitors dependencies for known vulnerabilities and creates PRs to update them.
+- **Secret scanning** detects leaked credentials and blocks pushes containing supported secrets.
+- **Code scanning** analyzes your source code using CodeQL, running as a GitHub Actions workflow on every push and PR.
+
+These tools run automatically in the background, catching security issues before they reach production. Now that you've seen how GitHub uses Actions internally for security automation, it's time to build your own CI workflow. Next, we'll [automate testing][walkthrough-next] for the shelter's application.
+
+### Resources
+
+- [About GitHub Advanced Security][advanced-security-docs]
+- [About code scanning with CodeQL][about-code-scanning]
+- [Dependabot quickstart guide][dependabot-quickstart]
+- [About secret scanning][about-secret-scanning]
+- [GitHub Skills: Secure your repository's supply chain][skills-supply-chain]
+- [GitHub Skills: Secure code game][skills-secure-code]
+
+| [← Introduction & Your First Workflow][walkthrough-previous] | [Next: Running Tests →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[about-code-scanning]: https://docs.github.com/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning
+[about-prs]: https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
+[about-secret-scanning]: https://docs.github.com/code-security/secret-scanning/introduction/about-secret-scanning
+[advanced-security]: https://github.com/features/security
+[advanced-security-docs]: https://docs.github.com/get-started/learning-about-github/about-github-advanced-security
+[dependabot-quickstart]: https://docs.github.com/code-security/getting-started/dependabot-quickstart-guide
+[github-actions]: https://github.com/features/actions
+[supported-secrets]: https://docs.github.com/code-security/secret-scanning/introduction/supported-secret-scanning-patterns
+[skills-supply-chain]: https://github.com/skills/secure-repository-supply-chain
+[skills-secure-code]: https://github.com/skills/secure-code-game
+[walkthrough-previous]: 1-introduction.md
+[walkthrough-next]: 3-running-tests.md
diff --git a/content/github-actions/3-running-tests.md b/content/github-actions/3-running-tests.md
new file mode 100644
index 0000000..faddfc4
--- /dev/null
+++ b/content/github-actions/3-running-tests.md
@@ -0,0 +1,202 @@
+# Running Tests
+
+| [← Securing the Development Pipeline][walkthrough-previous] | [Next: Caching →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+Now that you know the basics of GitHub Actions and have seen how GitHub uses it for code scanning, it's time to build your own workflow. In this exercise you'll create a **continuous integration (CI)** pipeline that automatically runs the shelter's tests.
+
+## Scenario
+
+The shelter's app is growing, and the team wants to make sure new changes don't break existing functionality. The application has two test suites: **unit tests** for the Flask API, and **end-to-end (e2e) tests** that use [Playwright][playwright] to test the full stack in a browser. The goal is to run both automatically on every push and pull request (PR) to `main`.
+
+## Background
+
+As you saw in the [introduction][introduction], the `on` declaration specifies when a workflow will run. For true automation, you'll use `on` to indicate the [triggers][workflow-triggers] for the workflow to run automatically. In our scenario, this will be whenever a PR is made to the `main` branch, or when code is pushed or merged into it.
+
+Most workflows have a relatively common set of tasks. You typically need to install libraries, perform builds, and run various commands. Rather than having to script everything out by hand, there's a collection of available actions in a marketplace - the aptly named [Actions Marketplace][actions-marketplace]. There you can find pluggable, reusable actions, ready to be added to any workflow.
+
+## Using the Actions Marketplace
+
+The [Actions Marketplace][actions-marketplace] contains tens of thousands of community created actions. These include those from OSS contributors of all sizes, and vendors to allow for quick integration of their products.
+
+For most actions, you can just add the name of the action, typically `vendor/action-name`, the necessary configuration, and it's now part of your workflow!
+
+### Security and the Actions Marketplace
+
+The marketplace offers various protections to ensure you're using the right action at the right time. For starters, creators can be [verified][marketplace-badges] by GitHub, giving you the confidence the organization who says they built an action is the one who actually built it.
+
+In addition, you can [pin to a specific version, SHA or branch][action-versioning]. This both increases security, knowing the code you expect to run is what runs, and consistency as it'll always be the same code over and over.
+
+## Create the CI workflow
+
+Our application has a Flask backend with unit tests, and an Astro frontend that's validated with end-to-end tests. Let's begin building a workflow to run these tests. We'll start with the unit tests, then add the end-to-end tests a bit later in this lesson.
+
+To run the unit tests, you'll need to do the following in the workflow:
+
+- checkout the code.
+- install Python.
+- install the necessary Python libraries.
+- run the tests.
+
+Let's build that out!
+
+1. In your codespace, create a new file named `.github/workflows/run-tests.yml`.
+2. Add the following content:
+
+ ```yaml
+ name: Run Tests
+
+ on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+ permissions:
+ contents: read
+
+ jobs:
+ test-api:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.14'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r server/requirements.txt
+
+ - name: Run tests
+ working-directory: ./server
+ run: |
+ python -m unittest test_app -v
+ ```
+
+3. Save the file.
+
+Notice how this workflow differs from the hello world:
+- It triggers on `push` and `pull_request` events instead of `workflow_dispatch` — so it runs automatically when a PR or merge is made to the specified branch(es).
+- It declares explicit **`permissions`** — we'll explain this next.
+- It uses `actions/checkout@v4` to clone your repository code onto the runner, using the `checkout` action from the marketplace.
+- It uses `actions/setup-python@v5` to install a specific Python version, yet another action from the marketplace.
+- Next, it installs the necessary libraries using `pip`, just like you normally would.
+- Finally, it's time to run the tests - again, just like before!
+
+## Understanding `GITHUB_TOKEN` and permissions
+
+Every workflow run automatically receives a token called **`GITHUB_TOKEN`**. This is a short-lived credential that actions use behind the scenes to interact with your repository — for example, `actions/checkout` uses it to clone your code. The token is created when the workflow starts and revoked when the run ends.
+
+The **`permissions`** block controls what this token can do. For our CI workflow, we only need `contents: read` — enough to clone the repository. This follows the [principle of least privilege][principle-least-privilege]: grant only the permissions your workflow actually needs, nothing more.
+
+> [!IMPORTANT]
+> Always set explicit `permissions` in your workflows. Without it, the token inherits the repository-level defaults (**Settings** > **Actions** > **General** > **Workflow permissions**), which may be more permissive than your workflow requires. Being explicit ensures your workflow only has the access it needs — even if someone changes the repository defaults later.
+
+## Push and explore
+
+A bit later you'll use a more standard branching approach for changes. But for our purposes right now, let's push straight to `main`. What you'll notice is the workflow will automatically run, since the workflow will now exist on `main`!
+
+1. Open the terminal in your codespace by pressing Ctl+`, then stage, commit, and push:
+
+ ```bash
+ git add .github/workflows/run-tests.yml
+ git commit -m "Add CI workflow with unit tests"
+ git push
+ ```
+
+2. Navigate to the **Actions** tab — the **Run Tests** workflow should already be running (triggered by the push).
+3. Select the **test-api** job and explore the logs. Notice the flow of checkout, Python setup, and dependency installation.
+
+## Add e2e tests in parallel
+
+The unit tests cover the API, but the shelter also has Playwright e2e tests that verify the full application works end-to-end in a real browser. Let's add a second job that runs alongside the unit tests.
+
+1. Return to your codespace and open `.github/workflows/run-tests.yml`. Add the following job to the bottom of the file:
+
+ ```yaml
+ test-e2e:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.14'
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r server/requirements.txt
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Node dependencies
+ working-directory: ./client
+ run: npm ci
+
+ - name: Install Playwright browsers
+ working-directory: ./client
+ run: npx playwright install --with-deps chromium
+
+ - name: Run e2e tests
+ working-directory: ./client
+ run: npx playwright test
+ ```
+
+2. Save the file.
+
+> [!NOTE]
+> Because we haven't added a `needs` key, `test-api` and `test-e2e` will run **in parallel**. Each job gets its own runner, so they don't interfere with each other and the total CI time is closer to the duration of the slower job rather than the sum of both. The `test-e2e` job needs both Python and Node.js because the Playwright tests launch the full stack — the Flask API and the Astro frontend — before running browser tests against them.
+
+1. In the terminal, stage, commit, and push:
+
+ ```bash
+ git add .github/workflows/run-tests.yml
+ git commit -m "Add e2e tests running in parallel"
+ git push
+ ```
+
+2. Navigate to the **Actions** tab and select the new workflow run. You should see both **test-api** and **test-e2e** running side by side.
+
+## Summary and next steps
+
+You've built a CI pipeline with two jobs running in parallel — unit tests for the API and end-to-end tests for the full application. This is the foundation of continuous integration — catching problems early so they don't reach production.
+
+Now, let's work to [improve the performance of our CI job][walkthrough-next] by reusing steps and caching dependencies.
+
+### Resources
+
+- [GitHub Actions documentation][github-actions-docs]
+- [Workflow syntax for GitHub Actions][workflow-syntax]
+- [Events that trigger workflows][workflow-triggers]
+- [Using jobs in a workflow][jobs-docs]
+- [Automatic token authentication][automatic-token-auth]
+- [Assigning permissions to jobs][permissions-docs]
+
+| [← Securing the Development Pipeline][walkthrough-previous] | [Next: Caching →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[action-versioning]: https://docs.github.com/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions#using-release-management-for-your-custom-actions
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[automatic-token-auth]: https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication
+[github-actions-docs]: https://docs.github.com/actions
+[introduction]: 1-introduction.md
+[jobs-docs]: https://docs.github.com/actions/writing-workflows/choosing-what-your-workflow-does/using-jobs-in-a-workflow
+[marketplace-badges]: https://docs.github.com/actions/how-tos/create-and-publish-actions/publish-in-github-marketplace#about-badges-in-github-marketplace
+[permissions-docs]: https://docs.github.com/actions/writing-workflows/choosing-what-your-workflow-does/assigning-permissions-to-jobs
+[playwright]: https://playwright.dev/
+[principle-least-privilege]: https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
+[workflow-syntax]: https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions
+[workflow-triggers]: https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
+[walkthrough-previous]: 2-code-scanning.md
+[walkthrough-next]: 4-caching.md
diff --git a/content/github-actions/4-caching.md b/content/github-actions/4-caching.md
new file mode 100644
index 0000000..8d539dd
--- /dev/null
+++ b/content/github-actions/4-caching.md
@@ -0,0 +1,117 @@
+# Caching
+
+| [← Running Tests][walkthrough-previous] | [Next: Matrix Strategies & Parallel Testing →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+The [GitHub Actions Marketplace][actions-marketplace] is a collection of pre-built actions created by GitHub and the community. Actions can set up tools, run tests, deploy code, send notifications, and much more. Rather than writing everything from scratch, you can leverage the work of thousands of developers.
+
+In this exercise you'll also learn about **caching** — a technique to speed up your workflows by reusing previously downloaded dependencies instead of fetching them from the internet on every run.
+
+## Scenario
+
+The CI workflow from the previous exercise works, but both jobs reinstall every dependency from scratch on every run. That means downloading Python packages, Node modules, and Playwright browsers each time — even when they haven't changed. You want to ensure workflows run as quickly as possible, to move from idea to deployed as quickly as possible.
+
+## Background
+
+[Caching][caching-docs] stores downloaded dependencies between workflow runs so they don't need to be fetched from the internet every time. Each cache is identified by a key — typically derived from the package manager and lock file. When a workflow runs, it checks for an existing cache matching that key. On a hit, the cached files are restored and the install step completes in seconds. On a miss, the dependencies are downloaded normally and then saved for next time.
+
+Many popular setup actions — like `actions/setup-python` and `actions/setup-node` — have caching built right in, so you can enable it with a single line. GitHub provides 10 GB of cache storage per repository, with least-recently-used entries evicted when the limit is reached.
+
+## Add caching to the unit test job
+
+Many popular setup actions have caching built right in. Let's start with the `test-api` job, which uses Python. Libraries are installed for Python using `pip`, which will become the key name. This instructs the workflow to cache any libraries installed using `pip`.
+
+1. In your codespace, open `.github/workflows/run-tests.yml`.
+2. Update the **Set up Python** step in the `test-api` job to enable pip caching:
+
+ ```yaml
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.14'
+ cache: 'pip'
+ ```
+
+> [!NOTE]
+> The `cache: 'pip'` option tells `setup-python` to cache downloaded pip packages. On the first run it saves the cache; on subsequent runs it restores it, skipping most download time.
+
+3. Save the file.
+
+## Add caching to the e2e test job
+
+The e2e job has two dependencies to cache — Python packages and the Node modules. We can follow the same path here! To make sure our packages are updated when versions change, we're going to set the `package-lock.json` file as a dependency. When the workflow runs, it will look to see if that file has changed; if it has it'll perform a reinstall. If not, it'll use the cache!
+
+4. Update the **Set up Python** step in the `test-e2e` job the same way:
+
+ ```yaml
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.14'
+ cache: 'pip'
+ ```
+
+5. Update the **Set up Node.js** step in the `test-e2e` job to enable npm caching:
+
+ ```yaml
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: 'client/package-lock.json'
+ ```
+
+6. Save the file.
+
+> [!NOTE]
+> You might wonder about caching Playwright browsers too. Playwright's [official CI guidance][playwright-ci] recommends running `npx playwright install --with-deps` on every run rather than caching browsers, since browser binaries are tightly coupled to the Playwright version and caching them can lead to subtle version mismatches.
+
+## Compare run times
+
+Now let's push the changes and see the impact of caching.
+
+1. In the terminal (Ctl+` to toggle), stage, commit, and push your changes:
+
+ ```bash
+ git add .github/workflows/run-tests.yml
+ git commit -m "Add caching to CI workflow"
+ git push
+ ```
+
+2. Navigate to the **Actions** tab on GitHub and observe the workflow run.
+3. Once it completes, check the logs for the setup steps. You should see output indicating a **cache miss** — this is expected on the first run since there's nothing cached yet.
+4. To see caching in action, trigger a second run. You can push a small change (such as adding a comment to `run-tests.yml`) or use the GitHub UI:
+ - Update the `on` section to add `workflow_dispatch:` so you can trigger runs manually
+ - Push that change, then use the **Run workflow** button on the **Actions** tab
+
+5. On the second run, check the setup step logs again. You should see a **cache hit**, and the overall run time should be noticeably shorter.
+
+> [!TIP]
+> You can view cache usage for your repository by navigating to **Actions** > **Caches** in the left sidebar. This shows all active caches, their sizes, and when they were last used.
+
+## Summary and next steps
+
+The Actions Marketplace provides thousands of pre-built actions so you don't have to reinvent the wheel. Many setup actions like `setup-python` and `setup-node` have caching built in, making it easy to dramatically reduce workflow run times by reusing previously downloaded dependencies.
+
+Next, we'll explore [matrix strategies][walkthrough-next] to test across multiple configurations simultaneously.
+
+### Resources
+
+- [GitHub Actions Marketplace][actions-marketplace]
+- [Caching dependencies to speed up workflows][caching-docs]
+- [Playwright CI documentation][playwright-ci]
+- [actions/setup-python][setup-python-action]
+- [actions/setup-node][setup-node]
+
+| [← Running Tests][walkthrough-previous] | [Next: Matrix Strategies & Parallel Testing →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[caching-docs]: https://docs.github.com/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows
+[marketplace]: https://github.com/marketplace
+[playwright-ci]: https://playwright.dev/docs/ci
+[setup-node]: https://github.com/actions/setup-node
+[setup-python-action]: https://github.com/actions/setup-python
+[walkthrough-previous]: 3-running-tests.md
+[walkthrough-next]: 5-matrix-strategies.md
diff --git a/content/github-actions/5-matrix-strategies.md b/content/github-actions/5-matrix-strategies.md
new file mode 100644
index 0000000..d3b7987
--- /dev/null
+++ b/content/github-actions/5-matrix-strategies.md
@@ -0,0 +1,131 @@
+# Matrix Strategies & Parallel Testing
+
+| [← Caching][walkthrough-previous] | [Next: Deploying to Azure with azd →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+Matrix strategies let you run a job across multiple configurations in parallel — different language versions, operating systems, or test targets. This is powerful for ensuring compatibility and catching environment-specific bugs early in the development cycle.
+
+## Scenario
+
+While the goal is to deploy the project to Azure, in the future you may look to host the app on other platforms. As part of the testing, you want to ensure the Python code will run correctly on different versions of the language runtime. This will avoid future surprises.
+
+## Background
+
+A [matrix][matrix-docs] allows you to create an array for a workflow to iterate through. This can be various configurations, operating systems, or anything else where you need to have a part of a workflow run multiple times. You define the values for the matrix in an array, then utilize the `matrix` keyword to retrieve the current value. GitHub Actions will handle the looping automatically for you!
+
+## Add a matrix to the test job
+
+Let's update the CI workflow to test the API across multiple Python versions.
+
+1. Open `.github/workflows/run-tests.yml` in your codespace.
+2. Locate the `test-api` job.
+3. Add a `strategy` block with a `matrix` definition, and update the `python-version` input to reference the matrix value.
+4. Replace the existing `test-api` job with the following:
+
+ ```yaml
+ test-api:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ['3.12', '3.13', '3.14']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r server/requirements.txt
+
+ - name: Run tests
+ working-directory: ./server
+ run: |
+ python -m unittest test_app -v
+ ```
+
+> [!IMPORTANT]
+> Make sure to quote version numbers like `'3.12'` in the matrix array. Without quotes, YAML may interpret them as floating-point numbers — for example, `3.10` becomes `3.1`, which would cause the setup step to fail.
+
+5. In the terminal (Ctl+` to toggle), stage, commit, and push your changes:
+
+ ```bash
+ git add .github/workflows/run-tests.yml
+ git commit -m "Add Python version matrix to test-api job"
+ git push
+ ```
+
+6. Navigate to the **Actions** tab on GitHub. You should see three parallel jobs running — one for each Python version.
+
+## Understanding matrix behavior
+
+By default, GitHub Actions uses **fail-fast** mode: if any matrix job fails, all remaining jobs are cancelled. This is efficient but can hide failures in other configurations.
+
+- **`fail-fast: false`** — continues running all matrix jobs even if one fails. This is valuable when you want to see the full picture of which configurations pass and which don't.
+- **`max-parallel`** — limits the number of jobs running concurrently. Useful when you have resource constraints or are hitting rate limits.
+
+Update the strategy block to disable fail-fast:
+
+```yaml
+strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.12', '3.13', '3.14']
+```
+
+> [!TIP]
+> Setting `fail-fast: false` is particularly useful during initial setup or when debugging, as it provides a complete view of compatibility across all configurations.
+
+## Using include and exclude
+
+Matrix strategies support `include` and `exclude` to fine-tune which combinations run.
+
+- **`include`** adds extra combinations or additional variables to existing combinations.
+- **`exclude`** removes specific combinations from the matrix.
+
+Here's an example that adds an extra combination with an additional environment variable, and excludes a specific one:
+
+```yaml
+strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.12', '3.13', '3.14']
+ os: [ubuntu-latest, ubuntu-22.04]
+ exclude:
+ - python-version: '3.14'
+ os: ubuntu-22.04
+ include:
+ - python-version: '3.14'
+ os: ubuntu-latest
+ experimental: true
+```
+
+In this example:
+
+- The `exclude` block skips Python 3.12 on `ubuntu-22.04`.
+- The `include` block adds an `experimental` flag to the Python 3.14 / `ubuntu-latest` combination, which you could reference with `${{ matrix.experimental }}` in your steps.
+
+> [!NOTE]
+> You don't need to add this to your workflow right now. This is provided as a reference for more advanced matrix configurations.
+
+## Summary and next steps
+
+Matrix strategies let you test across multiple configurations — language versions, operating systems, and more — with minimal YAML duplication. Combined with `fail-fast`, `max-parallel`, `include`, and `exclude`, you have fine-grained control over parallel testing. Next we'll [deploy to Azure using azd][walkthrough-next].
+
+### Resources
+
+- [Using a matrix for your jobs][matrix-docs]
+- [Workflow syntax for `jobs..strategy`][strategy-syntax]
+
+| [← Caching][walkthrough-previous] | [Next: Deploying to Azure with azd →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[matrix-docs]: https://docs.github.com/actions/using-jobs/using-a-matrix-for-your-jobs
+[strategy-syntax]: https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategy
+[walkthrough-previous]: 4-caching.md
+[walkthrough-next]: 6-deploy-azure.md
diff --git a/content/github-actions/6-deploy-azure.md b/content/github-actions/6-deploy-azure.md
new file mode 100644
index 0000000..a150c13
--- /dev/null
+++ b/content/github-actions/6-deploy-azure.md
@@ -0,0 +1,269 @@
+# Deploying to Azure with azd
+
+| [← Matrix Strategies & Parallel Testing][walkthrough-previous] | [Next: Creating custom actions →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+With CI in place, it's time for CD — continuous deployment or continuous delivery. We'll use the [Azure Developer CLI (azd)][azd-docs], Microsoft's recommended tool for deploying to Azure. **azd** handles the heavy lifting: generating infrastructure-as-code (Bicep), configuring passwordless authentication (OIDC), and creating the GitHub Actions workflow.
+
+## Scenario
+
+With the prototype built, the shelter is ready to share their application with the world! They want to ensure last minute testing is done on the application before it's deployed to production.
+
+## Background
+
+### Secrets and variables
+
+Speaking of secrets and variables... In a prior exercise you utilized `GITHUB_TOKEN`. `GITHUB_TOKEN` is a special secret automatically available to every workflow, and provides access to the current repository. You can add your own secrets and variables to your repository for use in workflows.
+
+Secrets are exactly that - secret. These are passwords and other values you don't want the public to be able to see. You can add secrets via the CLI, APIs, and your repository's page on github.com. Secrets are write-only, and are only available to be read by a running workflow. In fact, there's even a filter so if the workflow attempts to write or log a secret it'll automatically be hidden. You can confidently add secrets to a public repository, and the only visible aspect will be its name and not the value.
+
+Variables, on the other hand, are designed to be public values. They're settings like URLs or names, or other values that aren't sensitive. Variables can be both read and written. Use variables whenever you need the ability to configure a value outside a workflow.
+
+### Environments
+
+There's many approaches to deployment of an application. A classic is a **staging** > **production** setup, where **staging** is as close to mimicking the real world as possible, and **production** is, well, the actual application. By using this strategy it allows for any last checks to be performed before opening the doors to the public.
+
+Typically each environment will have its own configuration - its own server, URLs, databases, etc. Deploying to each will typically require different configurations. Actions supports this through the use of environments. With an environment you can create a set of secrets or variables for each environment, ensuring the right ones are used at the right time.
+
+Environments can have deployment rules, which allow you to control when a workflow is allowed to use a particular environment. Sticking with the staging/production approach, you'll typically have a broader set of team members who have permissions to deploy to staging, but limit those who can deploy to production. In our scenario, we're going to allow anyone to deploy to staging, but you'll be the only one who's allowed to deploy to production.
+
+## Create your environments
+
+1. Navigate to your repository on GitHub.
+2. Select **Settings** > **Environments**.
+3. Select **New environment**, name it `staging`, and select **Configure environment**. No additional rules are needed for now — select **Save protection rules**.
+4. Return to **Settings** > **Environments** and select **New environment** again.
+5. Name it `production` and select **Configure environment**.
+6. Under **Deployment protection rules**, check **Required reviewers**.
+7. Add yourself as a required reviewer and select **Save protection rules**.
+
+> [!NOTE]
+> In the next steps, `azd` will automatically configure OIDC credentials and store them as secrets in your repository. You don't need to manually create any Azure credentials.
+
+## Install and initialize azd
+
+Let's set up the Azure Developer CLI and scaffold the infrastructure for our project.
+
+1. Open the terminal in your codespace (or press Ctl+` to toggle it).
+2. Install azd by running:
+
+ ```bash
+ curl -fsSL https://aka.ms/install-azd.sh | bash
+ ```
+
+3. Initialize the project by running:
+
+ ```bash
+ azd init --from-code
+ ```
+
+4. Follow the prompts, accepting the defaults provided by the tool. When asked for a namespace, choose something unique (this will be used to name your Azure resources).
+5. Explore the generated `infra/` directory. You'll see Bicep files (`.bicep`) that define the Azure resources for your application:
+
+ ```bash
+ ls infra/
+ ```
+
+> [!TIP]
+> Bicep is Azure's domain-specific language for defining infrastructure as code. If you have GitHub Copilot, try asking it to explain the generated Bicep files!
+
+## Configure the infrastructure
+
+The generated Bicep files define the Azure Container Apps that will host the client and server. We need to add an environment variable so the client knows where to find the API server.
+
+1. Open `infra/resources.bicep` in your codespace.
+2. Find the section (around line 130) that reads:
+
+ ```bicep
+ {
+ name: 'PORT'
+ value: '4321'
+ }
+ ```
+
+3. Create a new line below the closing `}` and add the following:
+
+ ```bicep
+ {
+ name: 'API_SERVER_URL'
+ value: 'https://${server.outputs.fqdn}'
+ }
+ ```
+
+> [!NOTE]
+> While the syntax resembles JSON, **it's not JSON**. You'll need to resist the natural urge to add commas between the objects!
+
+## Create the CD workflow
+
+By default, `azd pipeline config` generates a simple workflow that deploys on every push to `main`. That works for getting started, but we want staged deployments with approval gates. The good news: if you create the workflow file *first*, `azd` will detect it and configure credentials around your custom workflow instead of generating the default.
+
+Let's create a workflow that:
+- Only deploys **after CI passes** — using [`workflow_run`][workflow-run-docs]
+- Deploys to **staging** first, automatically
+- Requires **manual approval** before deploying to **production**
+- Prevents **conflicting deployments** with concurrency controls
+
+1. Create a new file at `.github/workflows/azure-dev.yml`.
+2. Add the following content:
+
+ ```yaml
+ name: Deploy App
+
+ on:
+ workflow_dispatch:
+ workflow_run:
+ workflows: ["Run Tests"]
+ branches: [main]
+ types: [completed]
+
+ permissions:
+ id-token: write
+ contents: read
+
+ jobs:
+ deploy-staging:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
+ environment: staging
+ concurrency:
+ group: deploy-${{ github.ref }}-staging
+ cancel-in-progress: true
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install azd
+ uses: Azure/setup-azd@v2
+
+ - name: Log in with Azure (Federated Credentials)
+ run: |
+ azd auth login \
+ --client-id "${{ vars.AZURE_CLIENT_ID }}" \
+ --federated-credential-provider "github" \
+ --tenant-id "${{ vars.AZURE_TENANT_ID }}"
+
+ - name: Provision and deploy to staging
+ run: azd up --environment staging --no-prompt
+ env:
+ AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
+ AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
+ AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
+
+ deploy-production:
+ runs-on: ubuntu-latest
+ needs: deploy-staging
+ environment: production
+ concurrency:
+ group: deploy-${{ github.ref }}-production
+ cancel-in-progress: false
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install azd
+ uses: Azure/setup-azd@v2
+
+ - name: Log in with Azure (Federated Credentials)
+ run: |
+ azd auth login \
+ --client-id "${{ vars.AZURE_CLIENT_ID }}" \
+ --federated-credential-provider "github" \
+ --tenant-id "${{ vars.AZURE_TENANT_ID }}"
+
+ - name: Provision and deploy to production
+ run: azd up --environment production --no-prompt
+ env:
+ AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
+ AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
+ AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
+ ```
+
+3. Save the file.
+
+Let's walk through the key parts:
+
+- **`permissions: id-token: write`** — In the [Running Tests][running-tests] module you set `contents: read`. Here, `id-token: write` is added because the workflow needs to request OIDC tokens from Azure. This is how passwordless authentication works — no stored credentials, just short-lived tokens.
+- **`vars.*`** — Variables like `${{ vars.AZURE_CLIENT_ID }}` reference **repository variables** that `azd pipeline config` will create for you in the next step.
+- **`workflow_run`** triggers this workflow whenever the **Run Tests** workflow completes on `main`. The `if` condition ensures it only proceeds when tests **succeeded** — or when triggered manually via `workflow_dispatch`.
+- **`environment: staging`** and **`environment: production`** link each job to the GitHub Environments you created earlier. The production environment will trigger the approval gate you configured.
+- **`needs: deploy-staging`** on the production job creates the sequential flow: staging must succeed before production is offered for review.
+- **`concurrency`** groups prevent conflicting deployments to the same environment. Note `cancel-in-progress: false` on production to avoid accidentally cancelling an active deployment.
+- **`azd up`** provisions infrastructure and deploys your application in one command, targeted at a specific environment.
+
+## Set up Azure authentication
+
+Now let's authenticate with Azure and let `azd` configure the pipeline credentials. Because the workflow file already exists, `azd` will configure OIDC and variables around it rather than generating a new one.
+
+1. Authenticate with Azure:
+
+ ```bash
+ azd auth login
+ ```
+
+2. Follow the prompts to complete the authentication (a browser window will open for you to sign in).
+3. Configure the deployment pipeline:
+
+ ```bash
+ azd pipeline config
+ ```
+
+ This command will:
+ - Create OIDC credentials in Azure for passwordless authentication
+ - Store the necessary secrets and variables in your repository automatically
+ - Detect your existing workflow file and configure it
+
+4. When prompted to commit and push your local changes, say **yes**.
+
+> [!TIP]
+> After `azd pipeline config` completes, navigate to **Settings** > **Secrets and variables** > **Actions** > **Variables** tab to see the repository variables it created (like `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, etc.). These are the `vars.*` values your workflow references.
+
+## Test the pipeline
+
+When you said **yes** to `azd pipeline config`'s commit prompt, it pushed your changes — including the workflow file. Let's verify everything is working.
+
+1. Navigate to the **Actions** tab. The push will trigger the **Run Tests** workflow first.
+2. Once tests complete successfully, the **Deploy App** workflow will start automatically.
+3. Observe the pipeline stages:
+ - **deploy-staging** proceeds automatically
+ - After staging completes, **deploy-production** shows a **Waiting for review** badge
+4. Select **Review deployments** on the production job.
+5. Check the **production** environment and select **Approve and deploy**.
+6. Once the production deployment completes, expand the deploy step logs to find the application URLs.
+7. Open the client URL in your browser — you should see the pet shelter application live!
+
+> [!TIP]
+> You can also find your deployment URLs by running `azd show` in the terminal.
+
+## Summary and next steps
+
+Congratulations! You've deployed the pet shelter application to Azure with a proper CI/CD pipeline:
+
+- **CI-gated deployment** — CD only runs after CI passes, using `workflow_run`
+- **Staged environments** — staging deploys automatically, production requires approval
+- **OIDC authentication** — passwordless, short-lived tokens instead of stored credentials
+- **Concurrency controls** — preventing conflicting deployments
+- **azd integration** — `azd pipeline config` configured credentials around your custom workflow
+
+Next we'll [create custom actions][walkthrough-next] to reduce duplication and make our workflows more maintainable.
+
+### Resources
+
+- [What is the Azure Developer CLI?][azd-docs]
+- [Create a custom pipeline definition][azd-pipeline-definition]
+- [Events that trigger workflows: workflow_run][workflow-run-docs]
+- [About security hardening with OpenID Connect][oidc-docs]
+- [Deploying with GitHub Actions][actions-deploy]
+- [Using environments for deployment][environments-docs]
+
+| [← Matrix Strategies & Parallel Testing][walkthrough-previous] | [Next: Creating custom actions →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[actions-deploy]: https://docs.github.com/actions/use-cases-and-examples/deploying/deploying-with-github-actions
+[azd-docs]: https://learn.microsoft.com/azure/developer/azure-developer-cli/overview
+[azd-pipeline-definition]: https://learn.microsoft.com/azure/developer/azure-developer-cli/pipeline-create-definition
+[environments-docs]: https://docs.github.com/actions/managing-workflow-runs-and-deployments/managing-deployments/using-environments-for-deployment
+[oidc-docs]: https://docs.github.com/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect
+[running-tests]: 3-running-tests.md
+[workflow-run-docs]: https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run
+[walkthrough-previous]: 5-matrix-strategies.md
+[walkthrough-next]: 7-custom-actions.md
diff --git a/content/github-actions/7-custom-actions.md b/content/github-actions/7-custom-actions.md
new file mode 100644
index 0000000..2c9c7e0
--- /dev/null
+++ b/content/github-actions/7-custom-actions.md
@@ -0,0 +1,268 @@
+# Creating Custom Actions
+
+| [← Deploy to Azure][walkthrough-previous] | [Next: Reusable Workflows →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+Custom actions let you encapsulate reusable logic into a single step you can use across workflows. GitHub Actions supports three types of custom actions: **[composite][creating-composite-action]** (combines multiple steps), **[JavaScript][creating-javascript-action]** (runs Node.js code), and **[Docker container][creating-docker-container-action]** (runs in a container). Composite actions are the most approachable and a great starting point for bundling common step patterns.
+
+In this exercise you'll create a composite action that sets up the Python environment and seeds the test database, then use it in your CI workflow.
+
+## Scenario
+
+The pet shelter's test workflows need to seed the database before running tests. This involves setting up Python, installing dependencies, and running `seed_test_database.py`. Rather than duplicating these steps in every workflow, we'll create a custom composite action that any workflow can reference in a single step.
+
+## Background
+
+The great advantage to a composite action is it builds upon the knowledge you already have. You've defined actions already, and a custom action uses a very similar syntax, all defined in YAML.
+
+Every custom action is defined by an `action.yml` file. This file describes the action's interface and behavior:
+
+- **`name`**: A human-readable name for the action.
+- **`description`**: A short summary of what the action does.
+- **`inputs`**: Parameters the caller can pass to the action.
+- **`outputs`**: Values the action makes available to subsequent steps.
+- **`runs`**: Defines how the action executes. Composite actions use `runs.using: 'composite'` with a list of `steps`.
+
+Inputs and outputs let the action communicate with the calling workflow, making the action flexible and reusable across different contexts.
+
+## Create the setup-python-env action
+
+Let's create a composite action that sets up Python, installs dependencies, and seeds the test database.
+
+1. In your codespace, open a terminal window by selecting Ctl+\`.
+1. Create the directory for the action by executing the following command in the terminal:
+
+ ```bash
+ mkdir -p .github/actions/setup-python-env
+ ```
+
+2. In the newly created `setup-python-env` folder, create a new file named `action.yml` to store your composite action.
+3. Add the following YAML to the file to define your composite action:
+
+ ```yaml
+ name: 'Setup Python Environment'
+ description: 'Sets up Python, installs dependencies, and seeds the test database'
+
+ inputs:
+ python-version:
+ description: 'Python version to use'
+ required: false
+ default: '3.14'
+ database-path:
+ description: 'Path to the test database file'
+ required: false
+ default: './test_dogshelter.db'
+
+ outputs:
+ database-file:
+ description: 'Path to the seeded database file'
+ value: ${{ steps.seed.outputs.database-file }}
+
+ runs:
+ using: 'composite'
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install dependencies
+ run: pip install -r server/requirements.txt
+ shell: bash
+
+ - name: Seed the database
+ id: seed
+ run: python server/utils/seed_test_database.py
+ shell: bash
+ env:
+ DATABASE_PATH: ${{ inputs.database-path }}
+
+ - name: Set output
+ run: echo "database-file=${{ inputs.database-path }}" >> $GITHUB_OUTPUT
+ shell: bash
+ id: set-output
+ ```
+
+> [!NOTE]
+> Composite action steps must include `shell: bash` for every `run` step — this is required even though it seems redundant. Without it, the workflow will fail with a validation error.
+
+3. Review the key parts of the action:
+ - **Inputs** provide sensible defaults so callers only need to override what's different.
+ - **Outputs** reference the `seed` step's output, making the database path available to the calling workflow.
+ - Each `run` step explicitly declares `shell: bash` as required by composite actions.
+
+## Use the action in the CI workflow
+
+Now let's update the CI workflow to use the custom action instead of the individual setup and install steps. We'll also store the test database path as a repository variable — configured once in your repository settings and available to every workflow.
+
+1. Navigate to your repository on GitHub and go to **Settings** > **Secrets and variables** > **Actions** > **Variables** tab. Select **New repository variable** and create:
+ - **Name**: `TEST_DATABASE_PATH`
+ - **Value**: `./test_dogshelter.db`
+
+ This is the same `vars.*` mechanism that `azd pipeline config` used in the [deploy lesson][deploy-azure] for Azure credentials. Repository variables keep configuration out of your workflow files, making them easier to change without a code commit.
+
+2. Return to your codespace and open `.github/workflows/run-tests.yml`. In the `test-api` job, replace the **Set up Python** and **Install dependencies** steps (lines 23–32) with a single call to the composite action:
+
+ ```yaml
+ - name: Setup Python environment
+ id: seed
+ uses: ./.github/actions/setup-python-env
+ with:
+ python-version: ${{ matrix.python-version }}
+ database-path: ${{ vars.TEST_DATABASE_PATH }}
+ ```
+
+3. Update the **Run tests** step in `test-api` (line 34) to pass the database path from the action's output:
+
+ ```yaml
+ - name: Run tests
+ run: python -m unittest test_app -v
+ working-directory: ./server
+ env:
+ DATABASE_PATH: ${{ steps.seed.outputs.database-file }}
+ ```
+
+4. The `test-e2e` job has the same **Set up Python** and **Install Python dependencies** steps — a perfect chance to reuse the action. Replace those two steps with the same composite action call (no `python-version` override needed since the action defaults to 3.14):
+
+ ```yaml
+ - name: Setup Python environment
+ id: seed
+ uses: ./.github/actions/setup-python-env
+ with:
+ database-path: ${{ vars.TEST_DATABASE_PATH }}
+ ```
+
+ Then update the **Run e2e tests** step to pass the database path so the Flask server started by Playwright can find the seeded database:
+
+ ```yaml
+ - name: Run e2e tests
+ working-directory: ./client
+ run: npx playwright test
+ env:
+ DATABASE_PATH: ${{ steps.seed.outputs.database-file }}
+ ```
+
+5. Here's the complete updated `run-tests.yml` — use this to verify your work:
+
+ ```yaml
+ name: Run Tests
+
+ on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+ permissions:
+ contents: read
+
+ jobs:
+ test-api:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ['3.12', '3.13', '3.14']
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python environment
+ id: seed
+ uses: ./.github/actions/setup-python-env
+ with:
+ python-version: ${{ matrix.python-version }}
+ database-path: ${{ vars.TEST_DATABASE_PATH }}
+
+ - name: Run tests
+ run: python -m unittest test_app -v
+ working-directory: ./server
+ env:
+ DATABASE_PATH: ${{ steps.seed.outputs.database-file }}
+
+ test-e2e:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python environment
+ id: seed
+ uses: ./.github/actions/setup-python-env
+ with:
+ database-path: ${{ vars.TEST_DATABASE_PATH }}
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: 'client/package-lock.json'
+
+ - name: Install Node dependencies
+ working-directory: ./client
+ run: npm ci
+
+ - name: Install Playwright browsers
+ working-directory: ./client
+ run: npx playwright install --with-deps chromium
+
+ - name: Run e2e tests
+ working-directory: ./client
+ run: npx playwright test
+ env:
+ DATABASE_PATH: ${{ steps.seed.outputs.database-file }}
+ ```
+
+6. In the terminal (Ctl+` to toggle), commit and push your changes:
+
+ ```bash
+ git add .github/actions/setup-python-env/action.yml .github/workflows/run-tests.yml
+ git commit -m "Add setup-python-env composite action"
+ git push
+ ```
+
+7. Navigate to the **Actions** tab on GitHub and verify the workflow runs successfully with the new action.
+
+> [!TIP]
+> When developing custom actions, you can test them by pushing to a branch and triggering a workflow run. Check the workflow logs to ensure each step in your composite action executes as expected.
+
+## Types of custom actions
+
+GitHub Actions supports three types of custom actions, each suited to different use cases:
+
+| Type | Best for | Runs on | Complexity |
+|------|----------|---------|------------|
+| **Composite** | Bundling multiple existing steps into one | Directly on the runner | Easiest to create |
+| **JavaScript** | Complex logic, API calls, or custom computations | Node.js runtime | Moderate |
+| **Docker container** | Actions that need specific tools or environments | Inside a container | Most involved |
+
+- **Composite actions** are ideal when you want to combine several existing steps (like we did with setup, install, and seed) into a single reusable unit. They're the fastest to create because they use the same step syntax you already know.
+- **JavaScript actions** are best when you need custom logic, such as making API calls, processing data, or interacting with the GitHub API. They run on Node.js and have access to the `@actions/core` and `@actions/github` packages.
+- **Docker container actions** are best when your action requires specific tools, operating system libraries, or a particular runtime environment. They run in a Docker container, giving you full control over the execution environment.
+
+## Summary and next steps
+
+Custom actions reduce duplication and make workflows cleaner. You've created a composite action that encapsulates Python setup and database seeding into a single reusable step. Any workflow in the repository can now prepare the Python environment with a single `uses` reference.
+
+Next, we'll take reusability to the next level by exploring [reusable workflows][walkthrough-next] for sharing entire workflow patterns across your CI/CD pipeline.
+
+## Resources
+
+- [Creating a composite action][creating-composite-action]
+- [About custom actions][about-custom-actions]
+- [Metadata syntax for GitHub Actions][metadata-syntax]
+- [GitHub Skills: Reusable workflows][skills-reusable-workflows]
+
+| [← Deploy to Azure][walkthrough-previous] | [Next: Reusable Workflows →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[about-custom-actions]: https://docs.github.com/actions/sharing-automations/creating-actions/about-custom-actions
+[creating-composite-action]: https://docs.github.com/actions/sharing-automations/creating-actions/creating-a-composite-action
+[creating-docker-container-action]: https://docs.github.com/actions/sharing-automations/creating-actions/creating-a-docker-container-action
+[creating-javascript-action]: https://docs.github.com/actions/sharing-automations/creating-actions/creating-a-javascript-action
+[deploy-azure]: 6-deploy-azure.md
+[metadata-syntax]: https://docs.github.com/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions
+[skills-reusable-workflows]: https://github.com/skills/reusable-workflows
+[walkthrough-previous]: 6-deploy-azure.md
+[walkthrough-next]: 8-reusable-workflows.md
diff --git a/content/github-actions/8-reusable-workflows.md b/content/github-actions/8-reusable-workflows.md
new file mode 100644
index 0000000..40a04b6
--- /dev/null
+++ b/content/github-actions/8-reusable-workflows.md
@@ -0,0 +1,206 @@
+# Reusable Workflows
+
+| [← Creating Custom Actions][walkthrough-previous] | [Next: Required Workflows, Protection & Wrap-Up →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+Reusable workflows let you define an entire workflow that other workflows can call, like a function. This is different from custom actions — actions encapsulate individual *steps*, while reusable workflows encapsulate entire *jobs*. They're triggered with the `workflow_call` event and can accept inputs, secrets, and produce outputs.
+
+In this exercise you'll extract the deployment pattern into a reusable workflow, then call it from your CD pipeline for both staging and production.
+
+## Scenario
+
+The shelter's CD pipeline has two deploy jobs — staging and production — that run the exact same steps: checkout, install azd, authenticate with Azure, and deploy. The only differences are the environment name and the azd environment. Rather than maintaining duplicate job definitions, let's extract the shared pattern into a reusable workflow that both can call.
+
+## Background
+
+In the [previous exercise][walkthrough-previous] you created a composite action to bundle steps together. Reusable workflows solve a similar problem — avoiding duplication — but at a different level. It's important to understand when to reach for each one.
+
+A **composite action** combines multiple *steps* into a single step that runs inside a job. A **reusable workflow** packages one or more entire *jobs* that a caller workflow references at the job level. Here's a side-by-side comparison:
+
+| | Composite Action | Reusable Workflow |
+|---|---|---|
+| **What it encapsulates** | Multiple steps, run as a single step | One or more complete jobs |
+| **Where it lives** | `action.yml` in any directory (e.g. `.github/actions/`) | `.github/workflows/` directory only |
+| **How it's called** | `uses:` inside a job's `steps` | `uses:` directly on a `job`, not inside steps |
+| **Runner control** | Runs on the caller job's runner | Each job specifies its own runner |
+| **Secrets** | Cannot access secrets directly | Can receive secrets via `secrets:` or `secrets: inherit` |
+| **Logging** | Appears as one collapsed step in the log | Every job and step is logged individually |
+| **Nesting depth** | Up to 10 composite actions per workflow | Up to 10 levels of workflow nesting |
+| **Marketplace** | Can be published to the [Actions Marketplace][actions-marketplace] | Cannot be published to the Marketplace |
+
+**When to use which:**
+
+- Choose a **composite action** when you want to bundle a handful of related steps that run within a single job — like the `setup-python-env` action you just built.
+- Choose a **reusable workflow** when you want to share entire job definitions — including runner selection, environment targeting, and concurrency controls — across multiple workflows. Deployment pipelines are a classic use case, which is exactly what we'll build next.
+
+## Understanding secrets in reusable workflows
+
+Reusable workflows often need access to secrets and variables — for example, deployment credentials. There are two approaches:
+
+### Pass all secrets
+
+Using `secrets: inherit` to forward every secret available in the calling workflow to the reusable workflow.
+
+ ```yaml
+ deploy-staging:
+ uses: ./.github/workflows/reusable-deploy.yml
+ with:
+ environment-name: staging
+ secrets: inherit
+ ```
+
+### Define specific secrets
+
+For a more controlled approach, you can identify which specific secrets to pass n the reusable workflow's `on.workflow_call.secrets` section:
+
+```yaml
+on:
+ workflow_call:
+ inputs:
+ environment-name:
+ required: true
+ type: string
+ secrets:
+ AZURE_CLIENT_ID:
+ required: true
+ AZURE_TENANT_ID:
+ required: true
+ AZURE_SUBSCRIPTION_ID:
+ required: true
+```
+
+Then caller then passes each secret explicitly:
+
+```yaml
+deploy-staging:
+ uses: ./.github/workflows/reusable-deploy.yml
+ with:
+ environment-name: staging
+ secrets:
+ AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+```
+
+> [!IMPORTANT]
+> For deployment workflows that need Azure credentials, `secrets: inherit` is the simplest approach. However, defining specific secrets provides better documentation and prevents accidentally exposing secrets the reusable workflow doesn't need. We'll use `secrets: inherit` in this exercise for simplicity.
+
+## Create a reusable deployment workflow
+
+Let's extract the shared deploy steps into a reusable workflow.
+
+1. In your codespace, create a new file at `.github/workflows/reusable-deploy.yml`.
+
+2. Define the `workflow_call` trigger with inputs for the environment:
+
+ ```yaml
+ name: Reusable Deploy Workflow
+
+ on:
+ workflow_call:
+ inputs:
+ environment-name:
+ description: 'Deployment environment (staging or production)'
+ required: true
+ type: string
+ azd-env-name:
+ description: 'Azure Developer CLI environment name'
+ required: true
+ type: string
+ ```
+
+3. Add a single job that checks out the code, authenticates with Azure, and deploys:
+
+ ```yaml
+ jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment-name }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Install azd
+ uses: Azure/setup-azd@v2
+
+ - name: Log in with Azure (Federated Credentials)
+ run: |
+ azd auth login `
+ --client-id "${{ vars.AZURE_CLIENT_ID }}" `
+ --federated-credential-provider "github" `
+ --tenant-id "${{ vars.AZURE_TENANT_ID }}"
+ shell: pwsh
+
+ - name: Deploy application
+ run: azd up --environment ${{ inputs.azd-env-name }} --no-prompt
+ env:
+ AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
+ AZURE_ENV_NAME: ${{ inputs.azd-env-name }}
+ AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}
+ ```
+
+> [!NOTE]
+> Reusable workflows have a few important limitations: they can be nested up to 4 levels deep, and the workflow file must be located in the `.github/workflows` directory. You also cannot call a reusable workflow from within a reusable workflow's `steps` — they are called at the job level.
+
+## Call the reusable workflow
+
+Now update your `azure-dev.yml` to call the reusable workflow instead of duplicating the deploy steps in each job.
+
+1. Replace the `deploy-staging` and `deploy-production` jobs with calls to the reusable workflow:
+
+ ```yaml
+ deploy-staging:
+ if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
+ uses: ./.github/workflows/reusable-deploy.yml
+ with:
+ environment-name: staging
+ azd-env-name: pet-shelter-staging
+ secrets: inherit
+
+ deploy-production:
+ needs: [deploy-staging]
+ uses: ./.github/workflows/reusable-deploy.yml
+ with:
+ environment-name: production
+ azd-env-name: pet-shelter-production
+ secrets: inherit
+ ```
+
+2. In the terminal (Ctl+` to toggle), commit and push your changes:
+
+ ```bash
+ git add .github/workflows/reusable-deploy.yml .github/workflows/azure-dev.yml
+ git commit -m "Extract reusable deploy workflow"
+ git push
+ ```
+
+3. Navigate to the **Actions** tab on GitHub and verify that both deploy jobs run successfully. Notice how each appears as a separate job in the workflow visualization, even though they share the same underlying workflow definition.
+
+> [!TIP]
+> When viewing a workflow run that calls reusable workflows, GitHub shows each caller job separately. Select a job to see the steps from the reusable workflow running inside it.
+
+This pattern keeps your deployment logic in one place. When you need to update the deployment process, you change it once in the reusable workflow and every caller benefits.
+
+## Summary and next steps
+
+Reusable workflows reduce duplication at the workflow level. You've extracted the shared deployment pattern into a template that both staging and production call with a single `uses` reference. This keeps your CD pipeline maintainable as it grows — any change to the deploy process only needs to happen in one place.
+
+Next, we'll ensure quality gates are enforced with [branch protection, required workflows, and more][walkthrough-next].
+
+## Resources
+
+- [Reusing workflows][reusing-workflows]
+- [The `workflow_call` event][workflow-call-event]
+- [Sharing workflows with your organization][sharing-workflows]
+- [GitHub Skills: Reusable workflows][skills-reusable-workflows]
+
+| [← Creating Custom Actions][walkthrough-previous] | [Next: Required Workflows, Protection & Wrap-Up →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[reusing-workflows]: https://docs.github.com/actions/sharing-automations/reusing-workflows
+[sharing-workflows]: https://docs.github.com/actions/sharing-automations/sharing-workflows-secrets-and-runners-with-your-organization
+[skills-reusable-workflows]: https://github.com/skills/reusable-workflows
+[workflow-call-event]: https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_call
+[walkthrough-previous]: 7-custom-actions.md
+[walkthrough-next]: 9-required-workflows.md
diff --git a/content/github-actions/9-required-workflows.md b/content/github-actions/9-required-workflows.md
new file mode 100644
index 0000000..aeeec88
--- /dev/null
+++ b/content/github-actions/9-required-workflows.md
@@ -0,0 +1,149 @@
+# Required Workflows, Protection & Wrap-Up
+
+| [← Reusable Workflows][walkthrough-previous] | [Next: GitHub Actions section overview →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+Building a CI/CD pipeline is only half the battle — you also need to enforce it. Branch protection rules ensure that code can't be merged without passing checks. Required workflows go further, allowing organizations to mandate specific workflows across all repositories. In this final exercise, you'll configure branch protection, explore required workflows and rulesets, and add manual deployment triggers.
+
+## Scenario
+
+The shelter's CI/CD pipeline is comprehensive, but nothing currently prevents someone from merging code without passing CI. The organization also wants to ensure all repositories run security scanning. Let's lock things down with branch protection and explore how required workflows enforce standards at scale.
+
+## Background
+
+GitHub provides two mechanisms for enforcing rules on branches: **branch protection rules** and **repository rulesets**. Both can prevent code from being merged without meeting criteria you define, but they work differently.
+
+### Branch protection rules
+
+[Branch protection rules][about-protected-branches] are the original way to protect important branches. You configure them per-branch (e.g. `main`) to enforce requirements like:
+
+- **Required status checks** — CI must pass before merging.
+- **Required pull request reviews** — a minimum number of approvals before merging.
+- **Restrict who can push** — limit direct pushes to specific people or teams.
+
+Branch protection rules are available on all GitHub plans (including Free for public repos) and are configured at **Settings > Branches** in each repository.
+
+### Repository rulesets
+
+[Rulesets][about-rulesets] are the newer, more flexible approach. They offer several advantages over branch protection rules:
+
+| | Branch Protection Rules | Repository Rulesets |
+|---|---|---|
+| **Layering** | One rule per branch pattern | Multiple rulesets can apply to the same branch; the most restrictive rule wins |
+| **Status management** | Delete to disable | Toggle between **Active** and **Disabled** without losing configuration |
+| **Visibility** | Only admins can view | Anyone with read access can see active rulesets |
+| **Scope** | Repository-level only | Repository-level or organization-wide (GitHub Enterprise) |
+| **Bypass permissions** | Limited | Granular bypass for specific roles, teams, or GitHub Apps |
+| **Required workflows** | Not supported | Can require specific workflows to pass before merging |
+
+Rulesets and branch protection rules can coexist — when both apply to the same branch, their rules are aggregated and the most restrictive version of each rule applies.
+
+### Required workflows
+
+One of the most powerful ruleset features is the ability to **require specific workflows to pass before merging**. This is particularly useful at the organization level:
+
+1. An organization creates a reusable workflow (e.g. `security-scan.yml`) in a central repository.
+2. An organization-wide ruleset requires that workflow for all (or a subset of) repositories.
+3. Every PR across those repositories now runs the required workflow automatically — individual repository owners can't skip it.
+
+Common use cases include security scanning, license compliance, and code quality checks. Required workflows via rulesets replaced the earlier "Actions Required Workflows" feature, which was deprecated in October 2023.
+
+## Configure branch protection
+
+Branch protection rules prevent code from being merged into important branches without meeting specific criteria.
+
+1. Navigate to your repository on GitHub.
+2. Select **Settings** > **Branches** (under **Code and automation** in the sidebar).
+3. Select **Add branch protection rule** (or **Add rule** if using rulesets).
+4. Under **Branch name pattern**, enter `main`.
+5. Enable **Require status checks to pass before merging**.
+ - Select **Require branches to be up to date before merging**.
+ - In the search box, search for and select your CI workflow status check names (for example, `test-api` and `build-client`).
+6. Optionally enable **Require a pull request before merging** to ensure peer review.
+7. Select **Create** (or **Save changes**) to apply the rule.
+
+> [!TIP]
+> If your status checks don't appear in the search, make sure the CI workflow has run at least once on the repository. GitHub only shows status checks that have been reported previously.
+
+## Test the protection
+
+Let's verify that branch protection is working as expected.
+
+1. Return to your codespace and open the terminal (Ctl+` to toggle). Create a new branch and make a small change (for example, update a comment in `server/app.py`):
+
+ ```bash
+ git checkout -b test-protection
+ echo "# test change" >> server/app.py
+ git add server/app.py
+ git commit -m "Test branch protection"
+ git push -u origin test-protection
+ ```
+
+2. Navigate to your repository on GitHub and create a pull request from `test-protection` to `main`.
+3. Observe that the **Merge pull request** button is disabled and a message indicates that required status checks must pass.
+4. Watch the CI workflow run. Once all required checks pass, the merge button becomes enabled.
+5. You can merge or close the pull request — the important thing is that the protection is working!
+
+> [!IMPORTANT]
+> Branch protection ensures that your CI pipeline isn't just a suggestion — it's a requirement. Code cannot reach `main` without passing the checks you've defined.
+
+## Required workflows in practice
+
+As covered in the background section, organization-wide rulesets can mandate that specific workflows run across all repositories. This pairs naturally with the reusable workflows you built in the [previous exercise](8-reusable-workflows.md) — an organization could create a reusable security-scanning workflow in a central `.github` repository, then enforce it via a ruleset so every PR across the organization runs it automatically.
+
+> [!NOTE]
+> Organization-wide rulesets with required workflows require a GitHub Enterprise plan. For personal repositories or Free/Team organizations, repository-level branch protection (as configured above) provides similar enforcement.
+
+## Advanced features to explore
+
+Here are some additional GitHub Actions features you can explore on your own:
+
+- **Service containers**: Spin up databases, caches, or other services alongside your test jobs. Define them under `services` in a job, and GitHub Actions handles the lifecycle for you.
+- **Job summaries**: Write Markdown to the `$GITHUB_STEP_SUMMARY` environment file to create rich, formatted output that appears on the workflow run summary page.
+- **Self-hosted runners**: Run workflows on your own infrastructure for specialized hardware needs, compliance requirements, or to stay within your network. Useful when you need GPUs, specific OS versions, or access to internal resources.
+- **`repository_dispatch`**: Trigger workflows from external events via the GitHub API. This is useful for integrating GitHub Actions with external systems like monitoring tools, chatbots, or other CI/CD platforms.
+
+## Wrap-up and congratulations
+
+Congratulations! You've built a complete CI/CD pipeline for the pet shelter application. Let's review what you've accomplished:
+
+- **Continuous integration**: Tests run on every push and pull request across multiple Python versions, catching bugs before they reach `main`.
+- **Continuous deployment**: Automated deployment to Azure via `azd`, with staging and production environments.
+- **Custom actions**: Encapsulated Python setup and database seeding into a reusable composite action, eliminating duplication across jobs.
+- **Reusable workflows**: Extracted the deployment pattern into a callable workflow template used by both staging and production.
+- **Branch protection**: Enforced quality gates so code can't be merged without passing CI checks.
+- **Manual triggers**: Added on-demand deployment capability for rollbacks and hotfixes.
+
+This pipeline follows the same patterns used by teams across GitHub. As the shelter's application grows, this foundation will scale with it.
+
+### Continue learning
+
+If you want to keep exploring, here are some suggested next steps:
+
+- Add a code scanning workflow using [GitHub Advanced Security][github-security].
+- Implement environment-based deployment approvals using [GitHub Environments][environments-docs].
+- Explore the [GitHub Actions Marketplace][actions-marketplace] for community-built actions.
+- Take the [GitHub Skills: Deploy to Azure][skills-deploy-azure] course for a deeper dive into Azure deployment.
+
+## Resources
+
+- [About protected branches][about-protected-branches]
+- [About rulesets][about-rulesets]
+- [Required workflows][required-workflows]
+- [The `workflow_dispatch` event][workflow-dispatch]
+- [GitHub Skills: Deploy to Azure][skills-deploy-azure]
+- [GitHub Actions Marketplace][actions-marketplace]
+
+| [← Reusable Workflows][walkthrough-previous] | [Next: GitHub Actions section overview →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[about-protected-branches]: https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
+[about-rulesets]: https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[environments-docs]: https://docs.github.com/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment
+[github-security]: https://github.com/features/security
+[required-workflows]: https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets
+[skills-deploy-azure]: https://github.com/skills/deploy-to-azure
+[workflow-dispatch]: https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_dispatch
+[walkthrough-previous]: 8-reusable-workflows.md
+[walkthrough-next]: README.md
diff --git a/content/github-actions/README.md b/content/github-actions/README.md
new file mode 100644
index 0000000..f05541c
--- /dev/null
+++ b/content/github-actions/README.md
@@ -0,0 +1,68 @@
+# GitHub Actions: From CI to CD
+
+| [← Pets workshop selection][walkthrough-previous] | [Next: Workshop Setup →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[GitHub Actions][github-actions] is a powerful automation platform available right in your GitHub repository. With Actions you can build, test, and deploy your code — and automate just about anything else in your software development lifecycle. This workshop walks you through building a complete CI/CD pipeline, starting with running tests on every push and ending with automated deployment to Azure.
+
+## Scenario
+
+You're a developer, volunteering for a pet adoption shelter. They have a [Flask][flask] API and an [Astro][astro] frontend. They're ready to productionize their app, and deploy it to the cloud! But they also know there's some processes that should be followed to ensure everything flows smoothly. The goal is to work to automate all of those - through the use of GitHub Actions!
+
+## Prerequisites
+
+To complete this workshop, you will need the following:
+
+- A [GitHub account][github-signup]
+- An [Azure subscription][azure-free] (for the deployment exercises)
+- Familiarity with Git basics (commit, push, pull)
+
+> [!NOTE]
+> If you have access to [GitHub Copilot][github-copilot], it can help you write workflow YAML files. You'll see tips throughout the exercises on how to use it effectively.
+
+## Exercises
+
+0. [Workshop Setup][setup] — Create your repository from the template
+1. [Introduction & Your First Workflow][introduction] — Create your first workflow and explore the Actions UI
+2. [Securing the Development Pipeline][code-scanning] — Enable code scanning, Dependabot, and secret scanning
+3. [Running Tests][ci] — Automate unit and e2e testing with parallel jobs
+4. [Caching][marketplace] — Speed up workflows by caching dependencies
+5. [Matrix strategies & parallel testing][matrix] — Test across multiple configurations simultaneously
+6. [Deploying to Azure with azd][deployment] — Set up continuous deployment to Azure
+7. [Creating custom actions][custom-actions] — Build your own reusable action
+8. [Reusable workflows][reusable-workflows] — Share workflow logic across repositories
+9. [Required workflows, protection & wrap-up][protection] — Enforce standards and protect your branches
+
+## Resources
+
+- [GitHub Actions documentation][github-actions-docs]
+- [GitHub Actions Marketplace][actions-marketplace]
+- [Workflow syntax reference][workflow-syntax]
+- [Azure Developer CLI (azd) documentation][azd-docs]
+
+| [← Pets workshop selection][walkthrough-previous] | [Next: Workshop Setup →][walkthrough-next] |
+|:-----------------------------------|------------------------------------------:|
+
+[actions-marketplace]: https://github.com/marketplace?type=actions
+[astro]: https://astro.build/
+[azure-free]: https://azure.microsoft.com/free/
+[azd-docs]: https://learn.microsoft.com/azure/developer/azure-developer-cli/overview
+[ci]: ./3-running-tests.md
+[code-scanning]: ./2-code-scanning.md
+[custom-actions]: ./7-custom-actions.md
+[deployment]: ./6-deploy-azure.md
+[flask]: https://flask.palletsprojects.com/
+[github-actions]: https://github.com/features/actions
+[github-actions-docs]: https://docs.github.com/actions
+[github-copilot]: https://github.com/features/copilot
+[github-signup]: https://github.com/join
+[introduction]: ./1-introduction.md
+[marketplace]: ./4-caching.md
+[matrix]: ./5-matrix-strategies.md
+[protection]: ./9-required-workflows.md
+[repo-root]: /
+[reusable-workflows]: ./8-reusable-workflows.md
+[setup]: ./0-setup.md
+[walkthrough-next]: ./0-setup.md
+[walkthrough-previous]: ../README.md
+[workflow-syntax]: https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions
diff --git a/content/shared-images/code-scanning-dialog.png b/content/shared-images/code-scanning-dialog.png
new file mode 100644
index 0000000..e43dec0
Binary files /dev/null and b/content/shared-images/code-scanning-dialog.png differ
diff --git a/content/shared-images/code-scanning-setup.png b/content/shared-images/code-scanning-setup.png
new file mode 100644
index 0000000..d653bef
Binary files /dev/null and b/content/shared-images/code-scanning-setup.png differ
diff --git a/content/shared-images/dependabot-settings.png b/content/shared-images/dependabot-settings.png
new file mode 100644
index 0000000..48f13e4
Binary files /dev/null and b/content/shared-images/dependabot-settings.png differ
diff --git a/content/shared-images/secret-scanning-settings.png b/content/shared-images/secret-scanning-settings.png
new file mode 100644
index 0000000..cca3c85
Binary files /dev/null and b/content/shared-images/secret-scanning-settings.png differ
diff --git a/content/1-hour/images/0-setup-configure.png b/content/shared-images/setup-configure-repo.png
similarity index 100%
rename from content/1-hour/images/0-setup-configure.png
rename to content/shared-images/setup-configure-repo.png
diff --git a/content/1-hour/images/0-setup-template.png b/content/shared-images/setup-use-template.png
similarity index 100%
rename from content/1-hour/images/0-setup-template.png
rename to content/shared-images/setup-use-template.png