%%{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
GitHub Actions as CI/CD
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:
.github/workflows/publish.yml: the Actions workflow that triggers the build_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-pagesThis 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.ymlrecords the publish target so the project is reproducibly configured regardless of who (or what) runs thepublishcommand..github/workflows/publish.ymllistens for a push tomain, spins up a runner, installs Quarto, and then callsquarto publishusing 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.
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/ -vWhat it does
- The workflow above triggers on pushes to
test(fromdev) and on pull requests targetingprod pip install -e ".[test]"installs thepenguin_predictorpackage and its test dependencies (pytest) frompyproject.toml- To enforce that tests must pass before merging to
prod, enable branch protection rules in GitHub (Settings > Branches > Add rule forprod) 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 (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
testthattests 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
testand on pull requests targetingprod, identical to the Python version r-lib/actions/setup-r-dependencies@v2readsDESCRIPTIONand installs declared packages — the R equivalent ofpip install -e ".[test]"- The system dependency step (
libcurl4-openssl-dev,libssl-dev) is needed becausehttr2links against these libraries on Linux runners - Tests run via
testthat::test_dir()withload_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
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.
The original app is in
_labs/lab04/Python/app/. The refactored package used here is in_labs/lab05/cicd/Python/penguin_predictor/.↩︎The package name
penguinpredictorfollows R conventions (no underscores in package names).↩︎