Skip to main content

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:

  1. 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) (a tryCatch keeps 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, since devtools::test() defaults to stop_on_failure = FALSE);
  2. calls testthat::snapshot_accept(), which accepts whatever the new output is, unconditionally;
  3. 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_TOKEN to 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.