update-snapshots.yml
Regenerate testthat snapshots for an R package: run the test suite, accept the output the code now produces (testthat::snapshot_accept()), re-run the tests, then commit and push the changed files under tests/testthat/_snaps/. Optionally sets up Julia first, for packages whose tests call a bundled Julia project.
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
ref |
string | '' |
Branch or SHA to check out. Defaults to the event ref when empty; pass a branch name for workflow_dispatch callers or github.head_ref for pull_request callers. |
pr-mode |
boolean | false |
Fetch the PR’s head branch with r-lib/actions/pr-fetch and push back to it, instead of checking out and pushing ref directly. Set true for PR-comment (issue_comment) triggers. |
julia |
boolean | false |
Set up the Julia runtime before running tests. |
julia-version |
string | '1' |
Julia version to install. Only used when julia is true. |
julia-project |
string | 'inst/julia' |
Path to the Julia project to instantiate. Only used when julia is true. |
extra-packages |
string | 'any::testthat' |
Extra R packages forwarded to r-lib/actions/setup-r-dependencies. |
needs |
string | 'check' |
needs label forwarded to r-lib/actions/setup-r-dependencies. |
apt-packages |
string | (standard R system-deps list) | Space-separated apt packages to install before R setup. The default covers the system libraries a typical R package’s dependencies need to build from source (Cairo, fonts, graphics, gettext, OpenMP). Pass your own list to override, or an empty string to skip the install step. |
preinstalled-toolchain |
boolean | false |
The runner already provides R with pak, Julia and the apt-packages, and has no sudo, as a self-hosted runner does. Skips the Julia setup and cache, the apt install, setup-r and setup-r-dependencies, and installs the package’s dependencies (Suggests included) and extra-packages with pak. needs then has no effect. |
commit-message |
string | 'Update testthat snapshots [auto]' |
Commit message for the snapshot update commit. |
committer-name |
string | 'github-actions[bot]' |
Git committer name. |
committer-email |
string | '41898282+github-actions[bot]@users.noreply.github.com' |
Git committer email. |
Secrets
| Secret | Required | Description |
|---|---|---|
WORKFLOW_TOKEN |
no | Token used for the final push: a fine-grained personal access token with Contents: Read and write on the branch’s repository. Without it, or when pushing with it fails, the push uses GITHUB_TOKEN, which starts no workflow runs (see below). Pass it explicitly from the caller’s secrets: block. Available from @v3; @v2 rejects it as an unknown secret. |
Permissions
Grant contents: write so the job can push the snapshot commit back to the branch.
The workflow runs in two jobs. update-snapshots runs the branch’s tests with a read-only token and uploads the snapshot commit as a git bundle. push runs none of the branch’s code: it re-fetches the branch, checks that the bundled commit sits directly on the branch’s head and changes only files under tests/testthat/_snaps/, and pushes it. Keeping the push separate means code from the branch under test, which in pr-mode can be a fork PR, never shares a runner with contents: write or with WORKFLOW_TOKEN.
When are the updated snapshots checked?
The workflow itself checks only consistency, never correctness. Its test-and-accept step:
- runs the test suite once, so that failing snapshot expectations write their pending new snapshot files (with
TESTTHAT_MAX_FAILS=Inf, since testthat otherwise stops after 10 failures and never reaches the snapshots past that point) (atryCatchkeeps a hard R error, e.g. a test file that errors outright, from killing the job before the accept step; plain expectation failures don’t stop this run anyway, sincedevtools::test()defaults tostop_on_failure = FALSE); - calls
testthat::snapshot_accept(), which accepts whatever the new output is, unconditionally; - runs
devtools::test(stop_on_failure = TRUE)a second time, failing the job, and skipping the commit-and-push steps, if the suite still fails.
That second test run proves the suite passes against the freshly accepted snapshots. It cannot judge whether the new output is right: code that produces wrong-but-stable output passes it.
The semantic check, “do the updated snapshots make sense?”, is left to pull request review. When the workflow runs against a PR’s head branch (pr-mode: true from an issue_comment trigger, a workflow_dispatch with ref set to the PR’s branch, or a pull_request-triggered caller passing ref: github.head_ref), the snapshot commit lands on the PR, and the reviewer inspects the tests/testthat/_snaps/ diff before merge. That review is the only point where snapshot content is judged; nothing re-checks it after merge.
Two consequences worth planning around:
- Dispatching against a plain branch skips review entirely. The example caller stub defaults to
main, where the snapshot commit lands directly, with no reviewable diff in between. Prefer running against a PR branch when the snapshot changes deserve eyes. - The snapshot commit needs
WORKFLOW_TOKENto get CI. See the next section.
CI on the snapshot commit
Events triggered by GITHUB_TOKEN do not create new workflow runs, so a snapshot commit pushed with that token becomes the branch head with no R CMD check, coverage, or review runs on it. On a pull request GitHub may instead create the runs in an approval-required state, which a user with write access starts from the “Approve workflows to run” prompt in the merge box; either way nothing runs until someone acts, and the PR reads as if CI never ran (Morrison-Lab/bcs#1110, a private repository).
Pass a WORKFLOW_TOKEN secret to avoid this. The workflow then pushes with that token, and the push triggers the caller’s push and pull_request workflows like any other push. The token needs only Contents: Read and write on the repository the branch lives in. The push is a plain git push, so it needs no pull-request scope even in pr-mode.
Without the secret, or when the push with it fails (an expired token, or one without write access to the branch’s repository), the workflow pushes with GITHUB_TOKEN and posts a warning annotation and a job-summary line naming the commit that has no CI. Re-trigger the checks yourself in that case, e.g. by pushing an empty commit.
A caller using secrets: inherit passes its repository’s WORKFLOW_TOKEN automatically when one exists.
If the caller itself runs on push or pull_request and passes the token, the snapshot commit starts the caller again. That second run normally finds nothing to commit and stops, but a snapshot whose output changes on every run (a timestamp, unseeded random output) would commit and retrigger indefinitely. Trigger such callers from a comment or workflow_dispatch instead.
Example
# Copy to .github/workflows/update-snapshots.yml in your repo.
name: Update snapshots
on:
workflow_dispatch:
inputs:
branch:
description: Branch to update snapshots on
required: true
default: main
jobs:
update-snapshots:
uses: Morrison-Lab/gha/.github/workflows/update-snapshots.yml@v3
permissions:
contents: write
with:
ref: ${{ inputs.branch }}
# julia: true
# julia-version: '1'
# julia-project: inst/julia
# apt-packages: '' # default installs the standard R system-deps list; set '' to skip, or pass your own
# extra-packages: any::testthat
# needs: check
# preinstalled-toolchain: true # runner has R with pak, Julia and the apt packages, and no sudo
# commit-message: 'Update testthat snapshots [auto]'
# committer-name: github-actions[bot]
# committer-email: 41898282+github-actions[bot]@users.noreply.github.com
# Optional: push with a token so the snapshot commit triggers CI.
# secrets:
# WORKFLOW_TOKEN: ${{ secrets.WORKFLOW_TOKEN }}See examples/update-snapshots.yml for the full caller stub.