GitHub Actions as CI/CD

Published

2026-06-18

WarningCaution

This section is being revised. Thank you for your patience.

In this lab, we’re going to cover using GitHub Actions (or just Actions) for deploying R or Python code (i.e., models, APIs, apps, etc) to a published website. View the original lab file here.

To help explain/demonstrate GitHub Actions, I’ll cover a few examples I use often.

Publishing Quarto/GitHub Pages

This book is published using GitHub Pages, which is a free hosting service provided by GitHub. Quarto provides an easy Terminal command for publishing using GitHub Pages:

quarto publish gh-pages 

The lab for this section will accomplish the same thing, but does so using a GitHub Action workflow file. However, if you run the commands above (like I did) the workflow files won’t be generated because the site isn’t published via GitHub Actions. Instead, running quarto publish gh-pages creates a local setup. This means Quarto renders the files locally, then pushes the rendered HTML directly to the gh-pages branch. The commit log for this branch confirms it:

git log gh-pages --oneline -5
# 76175e88 Built site for gh-pages
# 559df417 Built site for gh-pages
# ...

The Built site for gh-pages messages are Quarto’s signature commit message when it pushes to the branch itself. No workflow file is involved.

To convert this to CI publishing, we need to create and commit two files so GitHub Actions can render and deploy on every push to main:

  1. .github/workflows/publish.yml: the Actions workflow that triggers the build
  2. _publish.yml: the Quarto publish configuration that tells Quarto where to deploy

Workflow file

Create .github/workflows/publish.yml with the following:

# .github/workflows/publish.yml

name: Publish to GitHub Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Quarto
        uses: quarto-dev/quarto-actions/setup@v2

      - name: Render and publish
        uses: quarto-dev/quarto-actions/publish@v2
        with:
          target: gh-pages
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The workflow_dispatch trigger lets you kick off a manual build from the GitHub Actions UI without needing to push a commit.

_publish.yml

Create _publish.yml at the project root and include the following:

- source: project
  gh-pages:
    branch: gh-pages

This tells Quarto to publish the rendered output to the gh-pages branch. Quarto reads this file during the publish step so it knows where to send the output without requiring any flags on the command line.

What they do

The two files divide the work cleanly between Quarto and GitHub Actions:

  • _publish.yml records the publish target so the project is reproducibly configured regardless of who (or what) runs the publish command.

  • .github/workflows/publish.yml listens for a push to main, spins up a runner, installs Quarto, and then calls quarto publish using the settings in _publish.yml.

The GITHUB_TOKEN is provided automatically by GitHub for every workflow run, so no manual secret setup is needed. Once both files are committed and pushed, every subsequent push to main will trigger a fresh render and deploy.

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace'}}}%%
flowchart TD
    Push("Developer<br>pushes to <code>main</code>") -->|"triggers"| GHA("GitHub Actions<br>Runner<br>(ubuntu-latest)")

    GHA --> Step1("<strong>Step 1</strong><br>Checkout Repository<br><code>actions/checkout@v4</code>")
    Step1 --> Step2("<strong>Step 2</strong><br>Set up Quarto<br><code>quarto-actions/setup@v2</code>")
    Step2 --> Step3("<strong>Step 3</strong><br>Render & Publish<br><code>quarto-actions/publish@v2</code>")

    Step3 -->|"authenticates with"| Token("<cdde>GITHUB_TOKEN</code><br>(auto-provided<br>by GitHub)")
    Token -->|"writes rendered output"| GHPages("<code>gh-pages</code><br>branch")
    GHPages -->|"served as"| Site("Live GitHub<br>Pages Site")
    style Push fill:#5B8C5A,stroke:#000000,stroke-width:1px,color:#ffffff
    style GHA fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style Step1 fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style Step2 fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style Step3 fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style Token fill:#1B2A41,stroke:#000000,stroke-width:1px,color:#ffffff
    style GHPages fill:#2A6F77,stroke:#000000,stroke-width:1px,color:#ffffff
    style Site fill:#2A6F77,stroke:#000000,stroke-width:1px,color:#ffffff

Publishing to GitHub pages

Running Tests (Python)

You can also use GitHub Actions to run tests on your code. For example, if we had Shiny for Python package, we could set up a workflow to run unit tests when the code is pushed to the test branch, and only promote to the prod branch if the tests pass. We’ll use the application from Python App Logging.1

Workflow file

# stored in _labs/lab05/cicd/Python/penguin_predictor/.github/workflows/python-tests.yml

name: Python Tests

on:
  push:
    branches: [test]
  pull_request:
    branches: [prod]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install package and test dependencies
        run: pip install -e ".[test]"

      - name: Run tests
        run: pytest tests/ -v

What it does

  • The workflow above triggers on pushes to test (from dev) and on pull requests targeting prod
  • pip install -e ".[test]" installs the penguin_predictor package and its test dependencies (pytest) from pyproject.toml
  • To enforce that tests must pass before merging to prod, enable branch protection rules in GitHub (Settings > Branches > Add rule for prod) and require this workflow as a status check

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace'}}}%%
flowchart TD
    DevBr("<code>dev</code> branch") -->|"push to test"| TestBr("<code>test</code> branch")
    TestBr -->|"triggers"| GHA("GitHub Actions<br>Run <code>pytest</code> suite")
    GHA -->|"tests pass"| PR("Pull Request<br><code>test</code> to <code>prod</code>")
    GHA -->|"tests fail"| Block("Promotion<br>Blocked")
    PR -->|"approved & merged"| ProdBr("<code>prod</code> branch")
    style DevBr fill:#5B8C5A,stroke:#000000,stroke-width:1px,color:#ffffff
    style TestBr fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style GHA fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style PR fill:#1B2A41,stroke:#000000,stroke-width:1px,color:#ffffff
    style Block fill:#1B2A41,stroke:#000000,stroke-width:1px,color:#ffffff
    style ProdBr fill:#2A6F77,stroke:#000000,stroke-width:1px,color:#ffffff

Running tests

Running Tests (R)

The R equivalent lives in _labs/lab05/cicd/R/penguinpredictor/.2 It follows the same pattern as the Python package: pure functions are extracted from the Shiny app into a proper R package so they can be tested with testthat independently of the UI.

The package structure mirrors a standard R package:

penguinpredictor/
├── DESCRIPTION
├── NAMESPACE
├── R/
│   ├── data.R
│   └── logging_setup.R
├── tests/
│   └── testthat/
│       └── test-data.R
└── app.R
1
validate_inputs(), encode_inputs(), parse_prediction() — the same three functions extracted from the app
2
21 testthat tests covering valid inputs, boundary conditions, encoding, and response parsing

Workflow file

# stored in _labs/lab05/cicd/R/penguinpredictor/.github/workflows/r-tests.yml

name: R Tests

on:
  push:
    branches: [test]
  pull_request:
    branches: [prod]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Set up R
        uses: r-lib/actions/setup-r@v2
        with:
          r-version: "4.4"

      - name: Install system dependencies
        run: sudo apt-get install -y libcurl4-openssl-dev libssl-dev

      - name: Install package and test dependencies
        uses: r-lib/actions/setup-r-dependencies@v2
        with:
          extra-packages: |
            any::testthat
            any::pkgload

      - name: Run tests
        run: Rscript -e "testthat::test_dir('tests/testthat', package = 'penguinpredictor', load_package = 'installed')"

What it does

  • The workflow triggers on pushes to test and on pull requests targeting prod, identical to the Python version
  • r-lib/actions/setup-r-dependencies@v2 reads DESCRIPTION and installs declared packages — the R equivalent of pip install -e ".[test]"
  • The system dependency step (libcurl4-openssl-dev, libssl-dev) is needed because httr2 links against these libraries on Linux runners
  • Tests run via testthat::test_dir() with load_package = 'installed', which installs the package before running tests rather than sourcing the files directly

%%{init: {'theme': 'base', 'themeVariables': {'fontFamily': 'monospace'}}}%%
flowchart TD
    DevBr("<code>dev</code> branch") -->|"push to test"| TestBr("<code>test</code> branch")
    TestBr -->|"triggers"| GHA("GitHub Actions<br>Run <code>testthat</code> suite")
    GHA -->|"tests pass"| PR("Pull Request<br><code>test</code> to <code>prod</code>")
    GHA -->|"tests fail"| Block("Promotion<br>Blocked")
    PR -->|"approved & merged"| ProdBr("<code>prod</code> branch")
    style DevBr fill:#5B8C5A,stroke:#000000,stroke-width:1px,color:#ffffff
    style TestBr fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style GHA fill:#D2562B,stroke:#000000,stroke-width:1px,color:#ffffff
    style PR fill:#1B2A41,stroke:#000000,stroke-width:1px,color:#ffffff
    style Block fill:#1B2A41,stroke:#000000,stroke-width:1px,color:#ffffff
    style ProdBr fill:#2A6F77,stroke:#000000,stroke-width:1px,color:#ffffff

Running R tests

A note on workflow file placement

The workflow files in .github/workflows/python-tests.yml and .github/workflows/r-tests.yml are included as references and won’t execute when this book is pushed to GitHub.

GitHub Actions only picks up workflow files located in the repository root’s .github/workflows/ directory, so anything nested deeper is treated as an ordinary text file.

If these app-packages were their own projects, the same workflow file would go at the root of the repository:

penguin_predictor/
├── app.py
├── .github/
   └── workflows/
       └── python-tests.yml
├── .gitignore
├── penguin_predictor/
   ├── data.py
   └── logging_setup.py
├── pyproject.toml
├── requirements.txt
└── tests/
    └── test_data.py
1
GitHub Actions reads this
penguinpredictor/
├── app.R
├── DESCRIPTION
├── .github/
   └── workflows/
       └── r-tests.yml
├── .gitignore
├── NAMESPACE
├── R
   ├── data.R
   └── logging_setup.R
├── .Rbuildignore
├── README.md
└── tests
    └── testthat
        └── test-data.R
1
GitHub Actions reads this

With that structure in place, every push to test and every pull request targeting prod will trigger the workflow automatically.


  1. The original app is in _labs/lab04/Python/app/. The refactored package used here is in _labs/lab05/cicd/Python/penguin_predictor/.↩︎

  2. The package name penguinpredictor follows R conventions (no underscores in package names).↩︎