diff --git a/.github/workflows/R-CMD-check-dev.yaml b/.github/workflows/R-CMD-check-dev.yaml
index f0dc0b1..e31f491 100644
--- a/.github/workflows/R-CMD-check-dev.yaml
+++ b/.github/workflows/R-CMD-check-dev.yaml
@@ -2,40 +2,41 @@
 # Can't be run as part of commits
 on:
   schedule:
-    - cron:  '5 0 * * *' # 05:00 UTC every day only run on main branch
+    - cron: "0 5 * * *" # 05:00 UTC every day only run on main branch
   push:
     branches:
-    - "cran-*"
+      - "cran-*"
     tags:
-    - "v*"
+      - "v*"
 
 name: rcc dev
 
 jobs:
   matrix:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     outputs:
       matrix: ${{ steps.set-matrix.outputs.matrix }}
 
     name: Collect deps
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - uses: ./.github/workflows/rate-limit
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
 
       - uses: r-lib/actions/setup-r@v2
-        with:
-          install-r: false
 
       - id: set-matrix
         uses: ./.github/workflows/dep-matrix
 
   check-matrix:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
     needs: matrix
+
+    name: Check deps
+
     steps:
       - name: Install json2yaml
         run: |
@@ -48,12 +49,56 @@ jobs:
           echo $matrix | jq .
           echo $matrix | json2yaml
 
+  R-CMD-check-base:
+    runs-on: ubuntu-22.04
+
+    name: base
+
+    # Begin custom: services
+    # End custom: services
+
+    strategy:
+      fail-fast: false
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: ./.github/workflows/custom/before-install
+        if: hashFiles('.github/workflows/custom/before-install/action.yml') != ''
+
+      - uses: ./.github/workflows/install
+        with:
+          cache-version: rcc-dev-base-1
+          needs: build, check
+          extra-packages: "any::rcmdcheck any::remotes ."
+          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Session info
+        run: |
+          options(width = 100)
+          if (!requireNamespace("sessioninfo", quietly = TRUE)) install.packages("sessioninfo")
+          pkgs <- installed.packages()[, "Package"]
+          sessioninfo::session_info(pkgs, include_base = TRUE)
+        shell: Rscript {0}
+
+      - uses: ./.github/workflows/custom/after-install
+        if: hashFiles('.github/workflows/custom/after-install/action.yml') != ''
+
+      - uses: ./.github/workflows/update-snapshots
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+
+      - uses: ./.github/workflows/check
+        with:
+          results: ${{ matrix.package }}
+
   R-CMD-check-dev:
-    needs: matrix
+    needs:
+      - matrix
+      - R-CMD-check-base
 
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
 
-    name: ${{ matrix.package }}
+    name: 'rcc-dev: ${{ matrix.package }}'
 
     # Begin custom: services
     # End custom: services
@@ -63,16 +108,15 @@ jobs:
       matrix: ${{fromJson(needs.matrix.outputs.matrix)}}
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - uses: ./.github/workflows/custom/before-install
         if: hashFiles('.github/workflows/custom/before-install/action.yml') != ''
 
       - uses: ./.github/workflows/install
         with:
-          install-r: false
           cache-version: rcc-dev-${{ matrix.package }}-1
-          needs: check
+          needs: build, check
           extra-packages: "any::rcmdcheck any::remotes ."
           token: ${{ secrets.GITHUB_TOKEN }}
 
diff --git a/.github/workflows/R-CMD-check-status.yaml b/.github/workflows/R-CMD-check-status.yaml
new file mode 100644
index 0000000..a997e2b
--- /dev/null
+++ b/.github/workflows/R-CMD-check-status.yaml
@@ -0,0 +1,79 @@
+# Workflow to update the status of a commit for the R-CMD-check workflow
+# Necessary because remote PRs cannot update the status of the commit
+on:
+  workflow_run:
+    workflows:
+      - rcc
+    types:
+      - requested
+      - completed
+
+name: rcc-status
+
+jobs:
+  rcc-status:
+    runs-on: ubuntu-24.04
+
+    name: "Update commit status"
+
+    permissions:
+      contents: read
+      statuses: write
+
+    steps:
+      - name: "Update commit status"
+        # Only run if triggered by rcc workflow
+        if: github.event.workflow_run.name == 'rcc'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -x
+
+          if [ "${{ github.event.workflow_run.status }}" == "completed" ]; then
+            if [ "${{ github.event.workflow_run.conclusion }}" == "success" ]; then
+              state="success"
+            else
+              state="failure"
+            fi
+
+            # Read artifact ID
+            artifact_id=$(gh api \
+              -H "Accept: application/vnd.github+json" \
+              -H "X-GitHub-Api-Version: 2022-11-28" \
+              repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts | jq -r '.artifacts[] | select(.name == "rcc-smoke-sha") | .id')
+
+            if [ -n "${artifact_id}" ]; then
+              # Download artifact
+              curl -L -o rcc-smoke-sha.zip \
+                -H "Accept: application/vnd.github+json" \
+                -H "Authorization: Bearer ${GH_TOKEN}" \
+                -H "X-GitHub-Api-Version: 2022-11-28" \
+                https://api.github.com/repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip
+
+              # Unzip artifact
+              unzip rcc-smoke-sha.zip
+
+              # Read artifact
+              sha=$(cat rcc-smoke-sha.txt)
+
+              # Clean up
+              rm rcc-smoke-sha.zip rcc-smoke-sha.txt
+            fi
+          else
+            state="pending"
+          fi
+
+          if [ -z "${sha}" ]; then
+            sha=${{ github.event.workflow_run.head_sha }}
+          fi
+
+          html_url=${{ github.event.workflow_run.html_url }}
+          description=${{ github.event.workflow_run.name }}
+
+          gh api \
+            --method POST \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            repos/${{ github.repository }}/statuses/${sha} \
+            -f "state=${state}" -f "target_url=${html_url}" -f "description=${description}" -f "context=rcc"
+        shell: bash
diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml
index 0d46feb..d7522af 100644
--- a/.github/workflows/R-CMD-check.yaml
+++ b/.github/workflows/R-CMD-check.yaml
@@ -1,4 +1,4 @@
-# Workflow derived from https://github.com/r-lib/actions/tree/master/examples
+# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
 # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
 #
 # NOTE: This workflow is overkill for most R packages and
@@ -9,32 +9,94 @@ on:
     branches:
       - main
       - master
+      - release
+      - cran-*
   pull_request:
     branches:
       - main
       - master
+  workflow_dispatch:
+    inputs:
+      ref:
+        description: "Branch, tag, or commit to check out"
+        required: false
+        default: "main"
+      versions-matrix:
+        description: "Create a matrix of R versions"
+        type: boolean
+        default: false
+      dep-suggests-matrix:
+        description: "Create a matrix of suggested dependencies"
+        type: boolean
+        default: false
+  merge_group:
+    types:
+      - checks_requested
   schedule:
-    - cron:  '10 0 * * *'
+    - cron: "10 1 * * *"
 
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || '' }}-${{ github.base_ref || '' }}
+  group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.ref || github.head_ref || github.sha }}-${{ github.base_ref || '' }}
   cancel-in-progress: true
 
 name: rcc
 
 jobs:
   rcc-smoke:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
+
     outputs:
       sha: ${{ steps.commit.outputs.sha }}
+      versions-matrix: ${{ steps.versions-matrix.outputs.matrix }}
+      dep-suggests-matrix: ${{ steps.dep-suggests-matrix.outputs.matrix }}
 
     name: "Smoke test: stock R"
 
+    permissions:
+      contents: write
+      statuses: write
+      pull-requests: write
+      actions: write
+
     # Begin custom: services
     # End custom: services
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ inputs.ref }}
+
+      - name: Update status for rcc
+        # FIXME: Wrap into action
+        if: github.event_name == 'workflow_dispatch'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # Check status of this workflow
+          state="pending"
+          sha=${{ inputs.ref }}
+          if [ -z "${sha}" ]; then
+            sha=${{ github.head_ref }}
+          fi
+          if [ -z "${sha}" ]; then
+            sha=${{ github.sha }}
+          fi
+          sha=$(git rev-parse ${sha})
+
+          html_url=$(gh api \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            repos/${{ github.repository }}/actions/runs/${{ github.run_id }} | jq -r .html_url)
+
+          description="${{ github.workflow }} / ${{ github.job }}"
+
+          gh api \
+            --method POST \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            repos/${{ github.repository }}/statuses/${sha} \
+            -f "state=${state}" -f "target_url=${html_url}" -f "description=${description}" -f "context=rcc"
+        shell: bash
 
       - uses: ./.github/workflows/rate-limit
         with:
@@ -48,100 +110,148 @@ jobs:
       - uses: ./.github/workflows/install
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
-          install-r: false
-          cache-version: rcc-smoke-1
-          needs: check
-          extra-packages: any::rcmdcheck any::roxygen2 r-lib/styler
+          cache-version: rcc-smoke-2
+          needs: build, check, website
+          # Beware of using dev pkgdown here, has brought in dev dependencies in the past
+          extra-packages: any::rcmdcheck r-lib/roxygen2 any::decor r-lib/styler r-lib/pkgdown deps::.
 
       - uses: ./.github/workflows/custom/after-install
         if: hashFiles('.github/workflows/custom/after-install/action.yml') != ''
 
+      # Must come after the custom after-install workflow
+      - name: Install package
+        run: |
+          _R_SHLIB_STRIP_=true R CMD INSTALL .
+        shell: bash
+
+      - id: versions-matrix
+        # Only run for pull requests if the base repo is different from the head repo, not for workflow_dispatch if not requested, always run for other events
+        if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository) && (github.event_name != 'workflow_dispatch' || inputs.versions-matrix)
+        uses: ./.github/workflows/versions-matrix
+
+      - id: dep-suggests-matrix
+        # Not for workflow_dispatch if not requested, always run for other events
+        if: github.event_name != 'workflow_dispatch' || inputs.dep-suggests-matrix
+        uses: ./.github/workflows/dep-suggests-matrix
+
       - uses: ./.github/workflows/update-snapshots
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+        with:
+          base: ${{ inputs.ref || github.head_ref }}
 
       - uses: ./.github/workflows/style
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
 
       - uses: ./.github/workflows/roxygenize
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+
+      - name: Remove config files from previous iteration
+        run: |
+          rm -f .github/dep-suggests-matrix.json .github/versions-matrix.json
+        shell: bash
 
       - id: commit
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
         uses: ./.github/workflows/commit
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
 
       - uses: ./.github/workflows/check
         with:
           results: ${{ runner.os }}-smoke-test
 
-  # Runs in a separate workflow, because it's using dev pkgdown
-  # which might bring in other dev dependencies
-  pkgdown:
-    needs: rcc-smoke
+      - uses: ./.github/workflows/pkgdown-build
+        if: github.event_name != 'push'
 
-    runs-on: ubuntu-latest
+      - uses: ./.github/workflows/pkgdown-deploy
+        if: github.event_name == 'push'
 
-    name: "pkgdown"
+      # Upload sha as artifact
+      - run: |
+          echo -n "${{ steps.commit.outputs.sha }}" > rcc-smoke-sha.txt
+        shell: bash
 
-    # Begin custom: services
-    # End custom: services
+      - uses: actions/upload-artifact@v4
+        with:
+          name: rcc-smoke-sha
+          path: rcc-smoke-sha.txt
+
+      - name: Update status for rcc
+        # FIXME: Wrap into action
+        if: always() && github.event_name == 'workflow_dispatch'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # Check status of this workflow
+          if [ "${{ job.status }}" == "success" ]; then
+            state="success"
+          else
+            state="failure"
+          fi
+
+          sha=${{ steps.commit.outputs.sha }}
+          if [ -z "${sha}" ]; then
+            sha=${{ inputs.ref }}
+          fi
+          if [ -z "${sha}" ]; then
+            sha=${{ github.head_ref }}
+          fi
+          if [ -z "${sha}" ]; then
+            sha=${{ github.sha }}
+          fi
+            sha=$(git rev-parse ${sha})
+
+          html_url=$(gh api \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            repos/${{ github.repository }}/actions/runs/${{ github.run_id }} | jq -r .html_url)
+
+          description="${{ github.workflow }} / ${{ github.job }}"
+
+          gh api \
+            --method POST \
+            -H "Accept: application/vnd.github+json" \
+            -H "X-GitHub-Api-Version: 2022-11-28" \
+            repos/${{ github.repository }}/statuses/${sha} \
+            -f "state=${state}" -f "target_url=${html_url}" -f "description=${description}" -f "context=rcc"
+        shell: bash
+
+  rcc-smoke-check-matrix:
+    runs-on: ubuntu-24.04
+
+    name: "Check matrix"
+
+    needs:
+      - rcc-smoke
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
         with:
           ref: ${{ needs.rcc-smoke.outputs.sha }}
 
-      - uses: ./.github/workflows/rate-limit
+      - uses: ./.github/workflows/matrix-check
         with:
-          token: ${{ secrets.GITHUB_TOKEN }}
+          matrix: ${{ needs.rcc-smoke.outputs.versions-matrix }}
 
-      - uses: ./.github/workflows/git-identity
-        if: github.event_name == 'push'
-
-      - uses: ./.github/workflows/custom/before-install
-        if: hashFiles('.github/workflows/custom/before-install/action.yml') != ''
-
-      - uses: ./.github/workflows/install
+      - uses: ./.github/workflows/matrix-check
         with:
-          token: ${{ secrets.GITHUB_TOKEN }}
-          install-r: false
-          cache-version: pkgdown-1
-          needs: website
-          extra-packages: r-lib/pkgdown local::.
-
-      - uses: ./.github/workflows/custom/after-install
-        if: hashFiles('.github/workflows/custom/after-install/action.yml') != ''
+          matrix: ${{ needs.rcc-smoke.outputs.dep-suggests-matrix }}
 
-      - uses: ./.github/workflows/pkgdown-build
-        if: github.event_name != 'push'
-
-      - uses: ./.github/workflows/pkgdown-deploy
-        if: github.event_name == 'push'
+  rcc-full:
+    needs:
+      - rcc-smoke
 
-  # Windows checks can be run in parallel and independently
-  # when they alone take as long as the smoke and full tests combined.
-  # To achieve this, remove the "needs:" element below.
-  rcc-windows:
-    # Begin custom: early run
-    needs: rcc-smoke
-    # End custom: early run
+    runs-on: ${{ matrix.os }}
 
-    runs-on: ${{ matrix.config.os }}
+    if: ${{ needs.rcc-smoke.outputs.versions-matrix != '' }}
 
-    name: ${{ matrix.config.os }} (${{ matrix.config.r }}) ${{ matrix.config.desc }}
+    name: 'rcc: ${{ matrix.os }} (${{ matrix.r }}) ${{ matrix.desc }}'
 
     # Begin custom: services
     # End custom: services
 
     strategy:
       fail-fast: false
-      matrix:
-        config:
-          - {os: windows-latest, r: 'release'}
-          # Use 3.6 to trigger usage of RTools35
-          - {os: windows-latest, r: '3.6'}
+      matrix: ${{fromJson(needs.rcc-smoke.outputs.versions-matrix)}}
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
         with:
           ref: ${{ needs.rcc-smoke.outputs.sha }}
 
@@ -150,80 +260,107 @@ jobs:
 
       - uses: ./.github/workflows/install
         with:
-          r-version: ${{ matrix.config.r }}
-          cache-version: rcc-main-1
+          r-version: ${{ matrix.r }}
+          cache-version: rcc-full-1
           token: ${{ secrets.GITHUB_TOKEN }}
-          needs: check
+          needs: build, check
 
       - uses: ./.github/workflows/custom/after-install
         if: hashFiles('.github/workflows/custom/after-install/action.yml') != ''
 
+      - name: Must allow NOTEs if packages are missing, even with _R_CHECK_FORCE_SUGGESTS_
+        run: |
+          if (Sys.getenv("RCMDCHECK_ERROR_ON") %in% c("", "note")) {
+            pkgs <- setdiff(desc::desc_get_deps()$package, "R")
+            installable <- vapply(pkgs, FUN.VALUE = logical(1), requireNamespace, quietly = TRUE)
+            if (any(!installable)) {
+              message("Missing packages: ", paste(pkgs[!installable], collapse = ", "))
+              cat('RCMDCHECK_ERROR_ON="warning"\n', file = Sys.getenv("GITHUB_ENV"), append = TRUE)
+            }
+          }
+        shell: Rscript {0}
+
       - uses: ./.github/workflows/update-snapshots
         if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
 
       - uses: ./.github/workflows/check
+        if: ${{ ! matrix.covr }}
         with:
-          results: ${{ runner.os }}-r${{ matrix.config.r }}
+          results: ${{ runner.os }}-r${{ matrix.r }}
 
-  rcc-full:
-    needs: rcc-smoke
+      - uses: ./.github/workflows/covr
+        if: ${{ matrix.covr }}
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+# The status update is taken care of by R-CMD-check-status.yaml
+
+  rcc-suggests:
+    needs:
+      - rcc-smoke
 
-    runs-on: ${{ matrix.config.os }}
+    runs-on: ubuntu-22.04
 
-    name: ${{ matrix.config.os }} (${{ matrix.config.r }}) ${{ matrix.config.desc }}
+    if: ${{ needs.rcc-smoke.outputs.dep-suggests-matrix != '' }}
+
+    name: Without ${{ matrix.package }}
 
     # Begin custom: services
     # End custom: services
 
     strategy:
       fail-fast: false
-      matrix:
-        config:
-          - {os: macOS-latest,   r: 'release'}
-
-          - {os: ubuntu-20.04,   r: 'release'}
-
-          # Use older ubuntu to maximise backward compatibility
-          - {os: ubuntu-18.04,   r: 'devel',     http-user-agent: 'release'}
-          - {os: ubuntu-18.04,   r: 'release',   covr: true,                  desc: 'with covr'}
-          - {os: ubuntu-18.04,   r: 'oldrel-1'}
-
-          # Begin custom: R 3.6
-          - {os: ubuntu-18.04,   r: 'oldrel-2'}
-          # End custom: R 3.6
-
-          # Begin custom: R 3.5
-          - {os: ubuntu-18.04,   r: 'oldrel-3'}
-          # End custom: R 3.5
-
-          # Begin custom: R 3.4
-          - {os: ubuntu-18.04,   r: 'oldrel-4'}
-          # End custom: R 3.4
-
-          # Begin custom: matrix elements
-          # End custom: matrix elements
+      matrix: ${{fromJson(needs.rcc-smoke.outputs.dep-suggests-matrix)}}
 
     steps:
-      - uses: actions/checkout@v2
-        with:
-          ref: ${{ needs.rcc-smoke.outputs.sha }}
+      - uses: actions/checkout@v4
 
       - uses: ./.github/workflows/custom/before-install
         if: hashFiles('.github/workflows/custom/before-install/action.yml') != ''
 
       - uses: ./.github/workflows/install
         with:
-          r-version: ${{ matrix.config.r }}
-          cache-version: rcc-main-1
+          cache-version: rcc-dev-${{ matrix.package }}-1
+          needs: build, check
+          extra-packages: "any::rcmdcheck any::remotes ."
           token: ${{ secrets.GITHUB_TOKEN }}
-          needs: check
+
+      - name: Remove ${{ matrix.package }} and all strong dependencies
+        run: |
+          pkg <- "${{ matrix.package }}"
+          pkgs <- tools::package_dependencies(pkg, reverse = TRUE)[[1]]
+          installed <- rownames(utils::installed.packages())
+          to_remove <- c(pkg, intersect(pkgs, installed))
+          print(to_remove)
+          remove.packages(to_remove)
+        shell: Rscript {0}
+
+      - name: Session info
+        run: |
+          options(width = 100)
+          if (!requireNamespace("sessioninfo", quietly = TRUE)) install.packages("sessioninfo")
+          pkgs <- installed.packages()[, "Package"]
+          sessioninfo::session_info(pkgs, include_base = TRUE)
+        shell: Rscript {0}
 
       - uses: ./.github/workflows/custom/after-install
         if: hashFiles('.github/workflows/custom/after-install/action.yml') != ''
 
-      - uses: ./.github/workflows/update-snapshots
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+      - name: Must allow NOTEs, even with _R_CHECK_FORCE_SUGGESTS_
+        run: |
+          if (Sys.getenv("RCMDCHECK_ERROR_ON") %in% c("", "note")) {
+            cat('RCMDCHECK_ERROR_ON="warning"\n', file = Sys.getenv("GITHUB_ENV"), append = TRUE)
+          }
+        shell: Rscript {0}
+
+      - name: Check env vars
+        run: |
+          print(Sys.getenv('_R_CHECK_FORCE_SUGGESTS_'))
+          print(Sys.getenv('RCMDCHECK_ERROR_ON'))
+        shell: Rscript {0}
 
       - uses: ./.github/workflows/check
         with:
-          results: ${{ runner.os }}-r${{ matrix.config.r }}
+          results: ${{ matrix.package }}
+
+# The status update is taken care of by R-CMD-check-status.yaml
diff --git a/.github/workflows/check/action.yml b/.github/workflows/check/action.yml
index c90120e..16e5b5f 100644
--- a/.github/workflows/check/action.yml
+++ b/.github/workflows/check/action.yml
@@ -1,4 +1,4 @@
-name: 'Actions to check an R package'
+name: "Actions to check an R package"
 inputs:
   results:
     description: Slug for check results
@@ -9,18 +9,10 @@ runs:
   steps:
     - uses: r-lib/actions/check-r-package@v2
       with:
+        # Fails on R 3.6 on Windows, remove when this job is removed?
+        args: 'c("--no-manual", "--as-cran", "--no-multiarch")'
         error-on: ${{ env.RCMDCHECK_ERROR_ON || '"note"' }}
 
-    - name: Run coverage check
-      if: ${{ matrix.config.covr }}
-      run: |
-        if (dir.exists("tests/testthat")) {
-          covr::codecov()
-        } else {
-          message("No tests found, coverage not tested.")
-        }
-      shell: Rscript {0}
-
     - name: Show test output
       if: always()
       run: |
diff --git a/.github/workflows/commit/action.yml b/.github/workflows/commit/action.yml
index bad98a6..a0a3692 100644
--- a/.github/workflows/commit/action.yml
+++ b/.github/workflows/commit/action.yml
@@ -1,4 +1,8 @@
-name: 'Action to commit changes to the repository'
+name: "Action to commit changes to the repository"
+inputs:
+  token:
+    description: "GitHub token"
+    required: true
 outputs:
   sha:
     description: "SHA of generated commit"
@@ -7,23 +11,53 @@ outputs:
 runs:
   using: "composite"
   steps:
-    - name: Commit if changed
+    - name: Commit if changed, create a PR if protected
       id: commit
+      env:
+        GITHUB_TOKEN: ${{ inputs.token }}
       run: |
         set -x
         if [ -n "$(git status --porcelain)" ]; then
           echo "Changed"
-          git fetch
-          if [ -n "${GITHUB_HEAD_REF}" ]; then
+          protected=${{ github.ref_protected }}
+          foreign=${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
+          if [ "${foreign}" = "true" ]; then
+            # https://github.com/krlmlr/actions-sync/issues/44
+            echo "Can't push to foreign branch"
+          elif [ "${protected}" = "true" ]; then
+            current_branch=$(git branch --show-current)
+            new_branch=gha-commit-$(git rev-parse --short HEAD)
+            git checkout -b ${new_branch}
             git add .
-            git stash save
-            git switch ${GITHUB_HEAD_REF}
-            git merge origin/${GITHUB_BASE_REF} --no-edit
-            git stash pop
+            git commit -m "chore: Auto-update from GitHub Actions"$'\n'$'\n'"Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+            # Force-push, used in only one place
+            # Alternative: separate branch names for each usage
+            git push -u origin HEAD -f
+
+            existing_pr=$(gh pr list --state open --base main --head ${new_branch} --json number --jq '.[] | .number')
+            if [ -n "${existing_pr}" ]; then
+              echo "Existing PR: ${existing_pr}"
+            else
+              gh pr create --base main --head ${new_branch} --title "chore: Auto-update from GitHub Actions" --body "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+            fi
+
+            gh workflow run rcc -f ref=$(git rev-parse HEAD)
+            gh pr merge --merge --auto
+          else
+            git fetch
+            if [ -n "${GITHUB_HEAD_REF}" ]; then
+              git add .
+              git stash save
+              git switch ${GITHUB_HEAD_REF}
+              git merge origin/${GITHUB_BASE_REF} --no-edit
+              git stash pop
+            fi
+            git add .
+            git commit -m "chore: Auto-update from GitHub Actions"$'\n'$'\n'"Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+            git push -u origin HEAD
+
+            # Only set output if changed
+            echo sha=$(git rev-parse HEAD) >> $GITHUB_OUTPUT
           fi
-          git add .
-          git commit -m "Auto-update from GitHub Actions"$'\n'$'\n'"Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
-          git push -u origin HEAD
-          echo ::set-output name=sha::$(git rev-parse HEAD)
         fi
       shell: bash
diff --git a/.github/workflows/covr/action.yml b/.github/workflows/covr/action.yml
new file mode 100644
index 0000000..76f593a
--- /dev/null
+++ b/.github/workflows/covr/action.yml
@@ -0,0 +1,46 @@
+name: "Actions to run covr for an R package"
+description: "Run covr to check code coverage for an R package and upload results to Codecov."
+inputs:
+  token:
+    description: codecov token
+    required: false
+
+runs:
+  using: "composite"
+  steps:
+    - name: Run coverage check
+      run: |
+        if (dir.exists("tests/testthat")) {
+          cov <- covr::package_coverage(
+            quiet = FALSE,
+            clean = FALSE,
+            install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package")
+          )
+          covr::to_cobertura(cov)
+        } else {
+          message("No tests found, coverage not tested.")
+        }
+      shell: Rscript {0}
+
+    - uses: codecov/codecov-action@v5
+      with:
+        # Fail if token is given
+        fail_ci_if_error: ${{ inputs.token != '' }}
+        files: ./cobertura.xml
+        plugins: noop
+        disable_search: true
+        token: ${{ inputs.token }}
+
+    - name: Show testthat output
+      if: always()
+      run: |
+        ## --------------------------------------------------------------------
+        find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true
+      shell: bash
+
+    - name: Upload test results
+      if: failure()
+      uses: actions/upload-artifact@v4
+      with:
+        name: coverage-test-failures
+        path: ${{ runner.temp }}/package
diff --git a/.github/workflows/dep-matrix/action.yml b/.github/workflows/dep-matrix/action.yml
index 64cafbc..35dcb3c 100644
--- a/.github/workflows/dep-matrix/action.yml
+++ b/.github/workflows/dep-matrix/action.yml
@@ -1,4 +1,4 @@
-name: 'Actions to compute a matrix with all dependent packages'
+name: "Actions to compute a matrix with all dependent packages"
 outputs:
   matrix:
     description: "Generated matrix"
@@ -130,5 +130,5 @@ runs:
           ']}'
         )
         writeLines(json)
-        writeLines(paste0("::set-output name=matrix::", json))
+        writeLines(paste0("matrix=", json), Sys.getenv("GITHUB_OUTPUT"))
       shell: Rscript {0}
diff --git a/.github/workflows/dep-suggests-matrix/action.R b/.github/workflows/dep-suggests-matrix/action.R
new file mode 100644
index 0000000..f719735
--- /dev/null
+++ b/.github/workflows/dep-suggests-matrix/action.R
@@ -0,0 +1,52 @@
+# FIXME: Dynamic lookup by parsing https://svn.r-project.org/R/tags/
+get_deps <- function() {
+  # Determine package dependencies
+  if (!requireNamespace("desc", quietly = TRUE)) {
+    install.packages("desc")
+  }
+
+  deps_df <- desc::desc_get_deps()
+  deps_df_optional <- deps_df$package[deps_df$type %in% c("Suggests", "Enhances")]
+  deps_df_hard <- deps_df$package[deps_df$type %in% c("Depends", "Imports", "LinkingTo")]
+  deps_df_base <- unlist(tools::standard_package_names(), use.names = FALSE)
+
+  packages <- sort(deps_df_optional)
+  packages <- intersect(packages, rownames(available.packages()))
+
+  # Too big to fail, or can't be avoided:
+  off_limits <- c("testthat", "rmarkdown", "rcmdcheck", deps_df_hard, deps_df_base)
+  off_limits_dep <- unlist(tools::package_dependencies(off_limits, recursive = TRUE, which = "strong"))
+  setdiff(packages, c(off_limits, off_limits_dep))
+}
+
+if (Sys.getenv("GITHUB_BASE_REF") != "") {
+  print(Sys.getenv("GITHUB_BASE_REF"))
+  system("git fetch origin ${GITHUB_BASE_REF}")
+  # Use .. to avoid having to fetch the entire history
+  # https://github.com/krlmlr/actions-sync/issues/45
+  diff_cmd <- "git diff origin/${GITHUB_BASE_REF}.. -- R/ tests/ | egrep '^[+][^+]' | grep -q ::"
+  diff_lines <- system(diff_cmd, intern = TRUE)
+  if (length(diff_lines) > 0) {
+    writeLines("Changes using :: in R/ or tests/:")
+    writeLines(diff_lines)
+    packages <- get_deps()
+  } else {
+    writeLines("No changes using :: found in R/ or tests/, not checking without suggested packages")
+    packages <- character()
+  }
+} else {
+  writeLines("No GITHUB_BASE_REF, checking without suggested packages")
+  packages <- get_deps()
+}
+
+if (length(packages) > 0) {
+  json <- paste0(
+    '{"package":[',
+    paste0('"', packages, '"', collapse = ","),
+    "]}"
+  )
+  writeLines(paste0("matrix=", json), Sys.getenv("GITHUB_OUTPUT"))
+  writeLines(json)
+} else {
+  writeLines("No suggested packages found.")
+}
diff --git a/.github/workflows/dep-suggests-matrix/action.yml b/.github/workflows/dep-suggests-matrix/action.yml
new file mode 100644
index 0000000..0f5e649
--- /dev/null
+++ b/.github/workflows/dep-suggests-matrix/action.yml
@@ -0,0 +1,13 @@
+name: "Actions to compute a matrix with all suggested packages"
+outputs:
+  matrix:
+    description: "Generated matrix"
+    value: ${{ steps.set-matrix.outputs.matrix }}
+
+runs:
+  using: "composite"
+  steps:
+    - id: set-matrix
+      run: |
+        Rscript ./.github/workflows/dep-suggests-matrix/action.R
+      shell: bash
diff --git a/.github/workflows/fledge.yaml b/.github/workflows/fledge.yaml
new file mode 100644
index 0000000..5b2131a
--- /dev/null
+++ b/.github/workflows/fledge.yaml
@@ -0,0 +1,122 @@
+name: fledge
+
+on:
+  # for manual triggers
+  workflow_dispatch:
+    inputs:
+      pr:
+        description: "Create PR"
+        required: false
+        type: boolean
+        default: false
+  # daily run
+  schedule:
+  - cron: "30 0 * * *"
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || '' }}-${{ github.base_ref || '' }}
+  cancel-in-progress: true
+
+jobs:
+  check_fork:
+    runs-on: ubuntu-24.04
+    outputs:
+      is_forked: ${{ steps.check.outputs.is_forked }}
+    steps:
+      - name: Check if the repo is forked
+        id: check
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          is_forked=$(gh api repos/${{ github.repository }} | jq .fork)
+          echo "is_forked=${is_forked}" >> $GITHUB_OUTPUT
+        shell: bash
+
+  fledge:
+    runs-on: ubuntu-24.04
+    needs: check_fork
+    if: needs.check_fork.outputs.is_forked == 'false'
+    permissions:
+      contents: write
+      pull-requests: write
+      actions: write
+    env:
+      FLEDGE_GHA_CI: true
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          fetch-tags: true
+
+      - name: Configure Git identity
+        run: |
+          env | sort
+          git config --local user.name "$GITHUB_ACTOR"
+          git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com"
+        shell: bash
+
+      - name: Update apt
+        run: |
+          sudo apt-get update
+        shell: bash
+
+      - uses: r-lib/actions/setup-r@v2
+        with:
+          use-public-rspm: true
+
+      - uses: r-lib/actions/setup-r-dependencies@v2
+        env:
+          GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          pak-version: devel
+          packages: cynkra/fledge
+          cache-version: fledge-1
+
+      - name: Count rulesets
+        # Assume that branch is protected if ruleset exists
+        id: rulesets
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          n_rulesets=$(gh api repos/${{ github.repository }}/rulesets -q length)
+          echo "count=${n_rulesets}" >> $GITHUB_OUTPUT
+        shell: bash
+
+      - name: Switch to branch if branch protection is enabled
+        if: github.ref_protected == 'true' || inputs.pr == 'true' || steps.rulesets.outputs.count > 0
+        run: |
+          git checkout -b fledge
+          git push -f -u origin HEAD
+        shell: bash
+
+      - name: Bump version
+        env:
+          GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          check_default_branch <- ("${{ github.ref_protected == 'true' || inputs.pr == 'true' || steps.rulesets.outputs.count > 0 }}" != "true")
+          if (fledge::bump_version(which = "dev", no_change_behavior = "noop", check_default_branch = check_default_branch)) {
+            fledge::finalize_version(push = TRUE)
+          }
+        shell: Rscript {0}
+
+      - name: Create and merge PR if branch protection is enabled
+        if: github.ref_protected == 'true' || inputs.pr == 'true' || steps.rulesets.outputs.count > 0
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -ex
+          if [ -n "$(git diff main --numstat)" ]; then
+            gh pr create --base main --head fledge --fill-first
+            gh workflow run rcc -f ref=$(git rev-parse HEAD)
+            gh pr merge --squash --auto
+          else
+            echo "No changes."
+          fi
+        shell: bash
+
+      - name: Check release
+        env:
+          GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          fledge:::release_after_cran_built_binaries()
+        shell: Rscript {0}
diff --git a/.github/workflows/get-extra/action.yml b/.github/workflows/get-extra/action.yml
index fcd0bb3..84c56d9 100644
--- a/.github/workflows/get-extra/action.yml
+++ b/.github/workflows/get-extra/action.yml
@@ -1,4 +1,4 @@
-name: 'Action to determine extra packages to be installed'
+name: "Action to determine extra packages to be installed"
 outputs:
   packages:
     description: "List of extra packages"
@@ -12,5 +12,5 @@ runs:
       run: |
         set -x
         packages=$( ( grep Config/gha/extra-packages DESCRIPTION || true ) | cut -d " " -f 2)
-        echo ::set-output name=packages::$packages
+        echo packages=$packages >> $GITHUB_OUTPUT
       shell: bash
diff --git a/.github/workflows/git-identity/action.yml b/.github/workflows/git-identity/action.yml
index 7741d93..7234dab 100644
--- a/.github/workflows/git-identity/action.yml
+++ b/.github/workflows/git-identity/action.yml
@@ -1,4 +1,4 @@
-name: 'Actions to set up a Git identity'
+name: "Actions to set up a Git identity"
 
 runs:
   using: "composite"
diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml
index 1acec32..d825d90 100644
--- a/.github/workflows/install/action.yml
+++ b/.github/workflows/install/action.yml
@@ -1,4 +1,4 @@
-name: 'Actions to run for installing R packages'
+name: "Actions to run for installing R packages"
 inputs:
   token:
     description: GitHub token, set to secrets.GITHUB_TOKEN
@@ -14,7 +14,11 @@ inputs:
   needs:
     description: Passed on to r-lib/actions/setup-r-dependencies@v2
     required: false
-    default: ''
+    default: ""
+  packages:
+    description: Passed on to r-lib/actions/setup-r-dependencies@v2
+    required: false
+    default: deps::., any::sessioninfo
   extra-packages:
     description: Passed on to r-lib/actions/setup-r-dependencies@v2
     required: false
@@ -35,12 +39,53 @@ runs:
         echo "_R_CHECK_FUTURE_FILE_TIMESTAMPS_=false" | tee -a $GITHUB_ENV
         # prevent rgl issues because no X11 display is available
         echo "RGL_USE_NULL=true" | tee -a $GITHUB_ENV
+        # from https://github.com/r-devel/r-dev-web/blob/main/CRAN/QA/Kurt/lib/R/Scripts/check_CRAN_incoming.R
+        echo "_R_CHECK_CRAN_INCOMING_CHECK_FILE_URIS_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_CRAN_INCOMING_NOTE_GNU_MAKE_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_PACKAGE_DEPENDS_IGNORE_MISSING_ENHANCES_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_CODE_CLASS_IS_STRING_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_CODOC_VARIABLES_IN_USAGES_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_CONNECTIONS_LEFT_OPEN_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_DATALIST_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_NEWS_IN_PLAIN_TEXT_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_PACKAGES_USED_CRAN_INCOMING_NOTES_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_RD_CONTENTS_KEYWORDS_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_R_DEPENDS_=warn" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_S3_METHODS_SHOW_POSSIBLE_ISSUES_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_THINGS_IN_TEMP_DIR_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_UNDOC_USE_ALL_NAMES_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_URLS_SHOW_301_STATUS_=true" | tee -a $GITHUB_ENV
+        echo "_R_CXX_USE_NO_REMAP_=true" | tee -a $GITHUB_ENV
+        # There is no way to disable recency and frequency checks when the incoming checks are run
+        # echo "_R_CHECK_CRAN_INCOMING_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_CRAN_INCOMING_SKIP_LARGE_VERSION_=true" | tee -a $GITHUB_ENV
+        echo "_R_CHECK_FORCE_SUGGESTS_=false" | tee -a $GITHUB_ENV
+      shell: bash
+
+    - name: Set environment variables (non-macOS only)
+      if: runner.os != 'macOS'
+      run: |
+        echo "_R_CHECK_THINGS_IN_OTHER_DIRS_=true" | tee -a $GITHUB_ENV
+      shell: bash
+
+    - name: Set environment variables (non-Windows only)
+      if: runner.os != 'Windows'
+      run: |
+        echo "_R_CHECK_BASHISMS_=true" | tee -a $GITHUB_ENV
       shell: bash
 
     - name: Update apt
       if: runner.os == 'Linux'
       run: |
         sudo apt-get update
+        sudo apt-get install -y aspell
+        echo "_R_CHECK_CRAN_INCOMING_USE_ASPELL_=true" | tee -a $GITHUB_ENV
+      shell: bash
+
+    - name: Remove pkg-config@0.29.2
+      if: runner.os == 'macOS'
+      run: |
+        brew uninstall pkg-config@0.29.2 || true
       shell: bash
 
     - uses: r-lib/actions/setup-pandoc@v2
@@ -53,14 +98,20 @@ runs:
         use-public-rspm: true
 
     - id: get-extra
-      uses: ./.github/workflows/get-extra
+      run: |
+        set -x
+        packages=$( ( grep Config/gha/extra-packages DESCRIPTION || true ) | cut -d " " -f 2-)
+        echo packages=$packages >> $GITHUB_OUTPUT
+      shell: bash
 
     - uses: r-lib/actions/setup-r-dependencies@v2
       env:
         GITHUB_PAT: ${{ inputs.token }}
       with:
+        pak-version: stable
         needs: ${{ inputs.needs }}
-        extra-packages: ${{ inputs.extra-packages }} ${{ matrix.config.covr && 'any::covr' }} ${{ steps.get-extra.outputs.packages }}
+        packages: ${{ inputs.packages }}
+        extra-packages: ${{ inputs.extra-packages }} ${{ ( matrix.covr && 'r-lib/covr#611 xml2' ) || '' }} ${{ steps.get-extra.outputs.packages }}
         cache-version: ${{ inputs.cache-version }}
 
     - name: Add pkg.lock to .gitignore
@@ -77,3 +128,40 @@ runs:
         sudo ln -s $(which true) /usr/local/bin/qpdf
         sudo ln -s $(which true) /usr/local/bin/checkbashisms
       shell: bash
+
+    - name: Install ccache
+      uses: krlmlr/ccache-action@parallel-dir
+      with:
+        max-size: 10G
+        verbose: 1
+        save: false
+        restore: false
+
+    - name: Use ccache for compiling R code, and parallelize
+      run: |
+        mkdir -p ~/.R
+        echo 'CC      :=  ccache $(CC)' >> ~/.R/Makevars
+        echo 'CXX     :=  ccache $(CXX)' >> ~/.R/Makevars
+        echo 'CXX11   :=  ccache $(CXX11)' >> ~/.R/Makevars
+        echo 'CXX14   :=  ccache $(CXX14)' >> ~/.R/Makevars
+        echo 'CXX17   :=  ccache $(CXX17)' >> ~/.R/Makevars
+        echo 'MAKEFLAGS = -j2' >> ~/.R/Makevars
+        cat ~/.R/Makevars
+
+        echo 'CCACHE_SLOPPINESS=locale,time_macros' | tee -a $GITHUB_ENV
+
+        # echo 'CCACHE_DEBUG=true' | tee -a $GITHUB_ENV
+        # echo "CCACHE_DEBUGDIR=$(dirname $(pwd))/ccache-debug" | tee -a $GITHUB_ENV
+        # mkdir -p $(dirname $(pwd))/.ccache-debug
+
+        echo 'PKG_BUILD_EXTRA_FLAGS=false' | tee -a $GITHUB_ENV
+
+        # Repair
+        git rm -rf .ccache || true
+        rm -rf .ccache
+      shell: bash
+
+    - name: Show R CMD config --all
+      run: |
+        R CMD config --all
+      shell: bash
diff --git a/.github/workflows/lock.yaml b/.github/workflows/lock.yaml
index a08861f..2cd4837 100644
--- a/.github/workflows/lock.yaml
+++ b/.github/workflows/lock.yaml
@@ -1,19 +1,23 @@
-name: 'Lock threads'
-
+name: "Lock threads"
+permissions:
+  issues: write
+  pull-requests: write
+  discussions: write
 on:
+  workflow_dispatch:
   schedule:
-    - cron: '0 0 * * *'
+    - cron: "37 2 * * *"
 
 jobs:
   lock:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
     steps:
-      - uses: dessant/lock-threads@v2
+      - uses: krlmlr/lock-threads@patch-1
         with:
           github-token: ${{ github.token }}
-          issue-lock-inactive-days: '365'
-          issue-lock-reason: ''
-          issue-lock-comment: >
+          issue-inactive-days: "365"
+          issue-lock-reason: ""
+          issue-comment: >
             This old thread has been automatically locked. If you think you have
             found something related to this, please open a new issue and link to this
             old issue if necessary.
diff --git a/.github/workflows/matrix-check/action.yml b/.github/workflows/matrix-check/action.yml
new file mode 100644
index 0000000..b943048
--- /dev/null
+++ b/.github/workflows/matrix-check/action.yml
@@ -0,0 +1,23 @@
+name: "Actions to check a matrix with all R and OS versions, computed with the versions-matrix action"
+inputs:
+  matrix:
+    description: "Generated matrix"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    - name: Install json2yaml
+      run: |
+        sudo npm install -g json2yaml
+      shell: bash
+
+    - run: |
+        matrix='${{ inputs.matrix }}'
+        if [ -n "${matrix}" ]; then
+          echo $matrix | jq .
+          echo $matrix | json2yaml
+        else
+          echo "No matrix found"
+        fi
+      shell: bash
diff --git a/.github/workflows/pkgdown-build/action.yml b/.github/workflows/pkgdown-build/action.yml
index e9f0e7f..381d067 100644
--- a/.github/workflows/pkgdown-build/action.yml
+++ b/.github/workflows/pkgdown-build/action.yml
@@ -1,4 +1,4 @@
-name: 'Action to build a pkgdown website'
+name: "Action to build a pkgdown website"
 
 runs:
   using: "composite"
diff --git a/.github/workflows/pkgdown-deploy/action.yml b/.github/workflows/pkgdown-deploy/action.yml
index 0e385df..b7c3ff4 100644
--- a/.github/workflows/pkgdown-deploy/action.yml
+++ b/.github/workflows/pkgdown-deploy/action.yml
@@ -1,9 +1,12 @@
-name: 'Action to deploy a pkgdown website'
+name: "Action to deploy a pkgdown website"
 
 runs:
   using: "composite"
   steps:
     - name: Deploy site
-      run: |
-        pkgdown::deploy_to_branch(new_process = FALSE)
-      shell: Rscript {0}
+      uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 15
+        max_attempts: 10
+        command: |
+          R -q -e 'pkgdown::deploy_to_branch(new_process = FALSE)'
diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml
index dda4431..80afde8 100644
--- a/.github/workflows/pkgdown.yaml
+++ b/.github/workflows/pkgdown.yaml
@@ -1,17 +1,24 @@
-# Workflow derived from https://github.com/r-lib/actions/tree/master/examples
+# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
 # Also included in R-CMD-check.yaml, this workflow only listens to pushes to branches
-# that start with "docs*"
+# that start with "docs*" or "cran-*" and does not need to act on pushes to the main branch.
 on:
   push:
     branches:
       - "docs*"
       - "cran-*"
+      # The main branch is excluded here, it is handled by the R-CMD-check workflow.
+      # This workflow is only for handling pushes to designated branches.
+  workflow_dispatch:
 
 name: pkgdown
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.sha }}-${{ github.base_ref || '' }}
+  cancel-in-progress: true
+
 jobs:
   pkgdown:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     name: "pkgdown"
 
@@ -19,7 +26,7 @@ jobs:
     # End custom: services
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - uses: ./.github/workflows/rate-limit
         with:
@@ -34,8 +41,7 @@ jobs:
       - uses: ./.github/workflows/install
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
-          install-r: false
-          cache-version: pkgdown-1
+          cache-version: pkgdown-2
           needs: website
           extra-packages: r-lib/pkgdown local::.
 
diff --git a/.github/workflows/pr-commands.yaml b/.github/workflows/pr-commands.yaml
index 9047d4e..b07a6f3 100644
--- a/.github/workflows/pr-commands.yaml
+++ b/.github/workflows/pr-commands.yaml
@@ -11,13 +11,11 @@ jobs:
     env:
       GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
     steps:
-      - uses: actions/checkout@v2
-      - uses: r-lib/actions/pr-fetch@master
+      - uses: actions/checkout@v4
+      - uses: r-lib/actions/pr-fetch@v2
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
-      - uses: r-lib/actions/setup-r@master
-        with:
-          install-r: false
+      - uses: r-lib/actions/setup-r@v2
       - name: Configure Git identity
         run: |
           env | sort
@@ -39,7 +37,7 @@ jobs:
             git add man/ NAMESPACE
             git commit -m 'Document'
           fi
-      - uses: r-lib/actions/pr-push@master
+      - uses: r-lib/actions/pr-push@v2
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
   style:
@@ -50,13 +48,11 @@ jobs:
     env:
       GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
     steps:
-      - uses: actions/checkout@v2
-      - uses: r-lib/actions/pr-fetch@master
+      - uses: actions/checkout@v4
+      - uses: r-lib/actions/pr-fetch@v2
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
-      - uses: r-lib/actions/setup-r@master
-        with:
-          install-r: false
+      - uses: r-lib/actions/setup-r@v2
       - name: Configure Git identity
         run: |
           env | sort
@@ -77,13 +73,13 @@ jobs:
             git add '*.R' '*.Rmd'
             git commit -m 'Style'
           fi
-      - uses: r-lib/actions/pr-push@master
+      - uses: r-lib/actions/pr-push@v2
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
   merge:
     if: startsWith(github.event.comment.body, '/merge')
     name: merge
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     steps:
       - name: Create and merge pull request
         run: |
@@ -98,6 +94,6 @@ jobs:
           # curl -s -X PUT --header "authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" $PR_URL/merge
   # A mock job just to ensure we have a successful build status
   finish:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     steps:
       - run: true
diff --git a/.github/workflows/rate-limit/action.yml b/.github/workflows/rate-limit/action.yml
index 03e3218..8180fe4 100644
--- a/.github/workflows/rate-limit/action.yml
+++ b/.github/workflows/rate-limit/action.yml
@@ -1,6 +1,6 @@
-name: 'Check GitHub rate limits'
+name: "Check GitHub rate limits"
 inputs:
-  token:  # id of input
+  token: # id of input
     description: GitHub token, pass secrets.GITHUB_TOKEN
     required: true
 
diff --git a/.github/workflows/revdep.yaml b/.github/workflows/revdep.yaml
index dc8e608..0fa67dc 100644
--- a/.github/workflows/revdep.yaml
+++ b/.github/workflows/revdep.yaml
@@ -8,7 +8,7 @@ name: revdep
 
 jobs:
   matrix:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
     outputs:
       matrix: ${{ steps.set-matrix.outputs.matrix }}
 
@@ -29,7 +29,7 @@ jobs:
           curl -s --header "authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/rate_limit
         shell: bash
 
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       # FIXME: Avoid reissuing succesful jobs
       # https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#list-jobs-for-a-workflow-run
@@ -44,11 +44,11 @@ jobs:
             ']}'
           )
           writeLines(json)
-          writeLines(paste0("::set-output name=matrix::", json))
+          writeLines(paste0("matrix=", json), Sys.getenv("GITHUB_OUTPUT"))
         shell: Rscript {0}
 
   check-matrix:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
     needs: matrix
     steps:
       - name: Install json2yaml
@@ -65,9 +65,9 @@ jobs:
   R-CMD-check:
     needs: matrix
 
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-22.04
 
-    name: ${{ matrix.package }}
+    name: 'revdep: ${{ matrix.package }}'
 
     # Begin custom: services
     # End custom: services
@@ -91,7 +91,7 @@ jobs:
           curl -s --header "authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/rate_limit
         shell: bash
 
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       # Begin custom: before install
       # End custom: before install
@@ -113,7 +113,7 @@ jobs:
         if: runner.os == 'Linux'
         run: |
           sudo apt-get update -y
-          Rscript -e 'writeLines(remotes::system_requirements("ubuntu", "18.04")); package <- "${{ matrix.package }}"; deps <- tools::package_dependencies(package, which = "Suggests")[[1]]; lapply(c(package, deps), function(x) { writeLines(remotes::system_requirements("ubuntu", "18.04", package = x)) })' | sort | uniq > .github/deps.sh
+          Rscript -e 'writeLines(remotes::system_requirements("ubuntu", "22.04")); package <- "${{ matrix.package }}"; deps <- tools::package_dependencies(package, which = "Suggests")[[1]]; lapply(c(package, deps), function(x) { writeLines(remotes::system_requirements("ubuntu", "22.04", package = x)) })' | sort | uniq > .github/deps.sh
           cat .github/deps.sh
           sudo sh < .github/deps.sh
 
diff --git a/.github/workflows/roxygenize/action.yml b/.github/workflows/roxygenize/action.yml
index 26941ce..ee1d652 100644
--- a/.github/workflows/roxygenize/action.yml
+++ b/.github/workflows/roxygenize/action.yml
@@ -1,4 +1,4 @@
-name: 'Action to create documentation with roxygen2'
+name: "Action to create documentation with roxygen2"
 
 runs:
   using: "composite"
diff --git a/.github/workflows/style/action.yml b/.github/workflows/style/action.yml
index b3c7f09..a5e83a5 100644
--- a/.github/workflows/style/action.yml
+++ b/.github/workflows/style/action.yml
@@ -1,61 +1,31 @@
-name: 'Action to auto-style a package'
+name: "Action to auto-style a package"
 
 runs:
   using: "composite"
   steps:
-    - name: Check styler options
+    - name: Check air.toml
       id: check
       run: |
         set -x
-        scope=$( ( grep Config/autostyle/scope DESCRIPTION || true ) | cut -d " " -f 2)
-        strict=$( ( grep Config/autostyle/strict DESCRIPTION || true ) | cut -d " " -f 2)
-        echo ::set-output name=scope::$scope
-        echo ::set-output name=strict::$strict
+        if [ -f air.toml ]; then
+          echo enabled=true >> $GITHUB_OUTPUT
+        else
+          echo enabled=false >> $GITHUB_OUTPUT
+        fi
       shell: bash
 
-    - uses: actions/cache@v2
-      if: ${{ steps.check.outputs.scope }}
-      with:
-        path: |
-          ~/.cache/R/R.cache
-        key: ${{ runner.os }}-2-${{ github.run_id }}-
-        restore-keys: |
-          ${{ runner.os }}-2-
+    - name: Install air
+      if: ${{ steps.check.outputs.enabled == 'true' }}
+      uses: posit-dev/setup-air@v1
 
-    - name: Show cache
-      if: ${{ steps.check.outputs.scope }}
+    - name: Run air
+      if: ${{ steps.check.outputs.enabled == 'true' }}
       run: |
-        ls -l ~/.cache/R/R.cache
-        ls -l ~/.cache/R/R.cache/styler
-        gdu -s --inodes ~/.cache/R/R.cache/styler/* || du -s --inodes ~/.cache/R/R.cache/styler/*
+        air format .
       shell: bash
 
-    - name: Imprint run ID
-      if: ${{ steps.check.outputs.scope }}
+    - name: Check Git status
+      if: ${{ steps.check.outputs.enabled == 'true' }}
       run: |
-        touch ~/.cache/R/R.cache/${{ github.run_id }}
-      shell: bash
-
-    - name: Enable styler cache
-      if: ${{ steps.check.outputs.scope }}
-      run: |
-        styler::cache_activate(verbose = TRUE)
-      shell: Rscript {0}
-
-    - name: Run styler
-      if: ${{ steps.check.outputs.scope }}
-      run: |
-        strict <- as.logical("${{ steps.check.outputs.strict }}")
-        if (is.na(strict)) {
-          strict = FALSE
-        }
-        styler::style_pkg(scope = "${{ steps.check.outputs.scope }}", strict = strict)
-      shell: Rscript {0}
-
-    - name: Show cache again
-      if: ${{ steps.check.outputs.scope }}
-      run: |
-        ls -l ~/.cache/R/R.cache
-        ls -l ~/.cache/R/R.cache/styler
-        gdu -s --inodes ~/.cache/R/R.cache/styler/* || du -s --inodes ~/.cache/R/R.cache/styler/*
+        git status
       shell: bash
diff --git a/.github/workflows/update-snapshots/action.yml b/.github/workflows/update-snapshots/action.yml
index cd58bf7..86149ce 100644
--- a/.github/workflows/update-snapshots/action.yml
+++ b/.github/workflows/update-snapshots/action.yml
@@ -3,6 +3,11 @@ description: >
   This action will run `testthat::test_local()` for tests that seem to use snapshots,
   this is determined by reading and grepping the test files.
   If the tests are failing, snapshots are updated, and a pull request is opened.
+inputs:
+  base:
+    description: "The base branch to create the pull request against."
+    required: false
+    default: "main"
 
 runs:
   using: "composite"
@@ -20,13 +25,14 @@ runs:
           tryCatch(
             {
               result <- as.data.frame(testthat::test_local(pattern = pattern, reporter = "silent", stop_on_failure = FALSE))
+              print(result)
               failures <- result[result$failed + result$warning > 0, ]
               if (nrow(failures) > 0) {
                 writeLines("Snapshot tests failed/warned.")
                 print(failures[names(failures) != "result"])
                 print(failures$result)
                 testthat::snapshot_accept()
-                writeLines("::set-output name=changed::true")
+                writeLines("changed=true", Sys.getenv("GITHUB_OUTPUT"))
               } else {
                 writeLines("Snapshot tests ran successfully.")
               }
@@ -50,7 +56,7 @@ runs:
       if: ${{ steps.run-tests.outputs.changed }}
       id: check-changed
       run: |
-        echo "::set-output name=changed::$(git status --porcelain -- tests/testthat/_snaps)"
+        echo "changed=$(git status --porcelain -- tests/testthat/_snaps | head -n 1)" >> $GITHUB_OUTPUT
       shell: bash
 
     - name: Derive branch name
@@ -58,19 +64,20 @@ runs:
       id: matrix-desc
       run: |
         config=$(echo '${{ toJSON(matrix) }}' | jq -c .)
-        echo "::set-output name=text::$(echo ${config})"
-        echo "::set-output name=branch::$(echo ${config} | sed -r 's/[^0-9a-zA-Z]+/-/g;s/^-//;s/-$//')"
+        echo "text=$(echo ${config})" >> $GITHUB_OUTPUT
+        echo "branch=$(echo ${config} | sed -r 's/[^0-9a-zA-Z]+/-/g;s/^-//;s/-$//')" >> $GITHUB_OUTPUT
       shell: bash
 
     - name: Create pull request
       if: ${{ steps.check-changed.outputs.changed }}
       id: cpr
-      uses: peter-evans/create-pull-request@v3
+      uses: peter-evans/create-pull-request@v6
       with:
-        base: ${{ github.head_ref }}
-        branch: snapshot-${{ github.ref_name }}-${{ github.job }}-${{ steps.matrix-desc.outputs.branch }}
+        base: ${{ inputs.base }}
+        branch: snapshot-${{ inputs.base }}-${{ github.job }}-${{ steps.matrix-desc.outputs.branch }}
         delete-branch: true
-        title: Snapshot updates for ${{ github.job }} (${{ steps.matrix-desc.outputs.text }})
+        title: "test: Snapshot updates for ${{ github.job }} (${{ steps.matrix-desc.outputs.text }})"
+        body: "Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action${{ github.event.number && format(' for #{0}', github.event.number) || '' }}."
         add-paths: |
           tests/testthat/_snaps
 
diff --git a/.github/workflows/versions-matrix/action.R b/.github/workflows/versions-matrix/action.R
new file mode 100644
index 0000000..b6a6dc0
--- /dev/null
+++ b/.github/workflows/versions-matrix/action.R
@@ -0,0 +1,65 @@
+# Determine active versions of R to test against
+tags <- xml2::read_html("https://svn.r-project.org/R/tags/")
+
+bullets <-
+  tags |>
+  xml2::xml_find_all("//li") |>
+  xml2::xml_text()
+
+version_bullets <- grep("^R-([0-9]+-[0-9]+-[0-9]+)/$", bullets, value = TRUE)
+versions <- unique(gsub("^R-([0-9]+)-([0-9]+)-[0-9]+/$", "\\1.\\2", version_bullets))
+
+r_release <- head(sort(as.package_version(versions), decreasing = TRUE), 5)
+
+deps <- desc::desc_get_deps()
+r_crit <- deps$version[deps$package == "R"]
+if (length(r_crit) == 1) {
+  min_r <- as.package_version(gsub("^>= ([0-9]+[.][0-9]+)(?:.*)$", "\\1", r_crit))
+  r_release <- r_release[r_release >= min_r]
+}
+
+r_versions <- c("devel", as.character(r_release))
+
+macos <- data.frame(os = "macos-latest", r = r_versions[2:3])
+windows <- data.frame(os = "windows-latest", r = r_versions[1:3])
+linux_devel <- data.frame(os = "ubuntu-22.04", r = r_versions[1], `http-user-agent` = "release", check.names = FALSE)
+linux <- data.frame(os = "ubuntu-22.04", r = r_versions[-1])
+covr <- data.frame(os = "ubuntu-22.04", r = r_versions[2], covr = "true", desc = "with covr")
+
+include_list <- list(macos, windows, linux_devel, linux, covr)
+
+if (file.exists(".github/versions-matrix.R")) {
+  custom <- source(".github/versions-matrix.R")$value
+  if (is.data.frame(custom)) {
+    custom <- list(custom)
+  }
+  include_list <- c(include_list, custom)
+}
+
+print(include_list)
+
+filter <- read.dcf("DESCRIPTION")[1, ]["Config/gha/filter"]
+if (!is.na(filter)) {
+  filter_expr <- parse(text = filter)[[1]]
+  subset_fun_expr <- bquote(function(x) subset(x, .(filter_expr)))
+  subset_fun <- eval(subset_fun_expr)
+  include_list <- lapply(include_list, subset_fun)
+  print(include_list)
+}
+
+to_json <- function(x) {
+  if (nrow(x) == 0) return(character())
+  parallel <- vector("list", length(x))
+  for (i in seq_along(x)) {
+    parallel[[i]] <- paste0('"', names(x)[[i]], '":"', x[[i]], '"')
+  }
+  paste0("{", do.call(paste, c(parallel, sep = ",")), "}")
+}
+
+configs <- unlist(lapply(include_list, to_json))
+json <- paste0('{"include":[', paste(configs, collapse = ","), "]}")
+
+if (Sys.getenv("GITHUB_OUTPUT") != "") {
+  writeLines(paste0("matrix=", json), Sys.getenv("GITHUB_OUTPUT"))
+}
+writeLines(json)
diff --git a/.github/workflows/versions-matrix/action.yml b/.github/workflows/versions-matrix/action.yml
new file mode 100644
index 0000000..af7378a
--- /dev/null
+++ b/.github/workflows/versions-matrix/action.yml
@@ -0,0 +1,19 @@
+name: "Actions to compute a matrix with all R and OS versions"
+
+outputs:
+  matrix:
+    description: "Generated matrix"
+    value: ${{ steps.set-matrix.outputs.matrix }}
+
+runs:
+  using: "composite"
+  steps:
+    - name: Install json2yaml
+      run: |
+        sudo npm install -g json2yaml
+      shell: bash
+
+    - id: set-matrix
+      run: |
+        Rscript ./.github/workflows/versions-matrix/action.R
+      shell: bash
diff --git a/DESCRIPTION b/DESCRIPTION
index f61ad5b..ebd1c69 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,21 +1,15 @@
 Package: blob
-Title: A Simple S3 Class for Representing Vectors of Binary Data
-    ('BLOBS')
-Version: 1.2.2
-Authors@R: 
-    c(person(given = "Hadley",
-             family = "Wickham",
-             role = "aut"),
-      person(given = "Kirill",
-             family = "Müller",
-             role = "cre",
-             email = "krlmlr+r@mailbox.org"),
-      person(given = "RStudio",
-             role = c("cph", "fnd")))
-Description: R's raw vector is useful for storing a single
-    binary object.  What if you want to put a vector of them in a data
-    frame? The 'blob' package provides the blob object, a list of raw
-    vectors, suitable for use as a column in data frame.
+Title: A Simple S3 Class for Representing Vectors of Binary Data ('BLOBS')
+Version: 1.2.4.9016
+Authors@R: c(
+    person("Hadley", "Wickham", role = "aut"),
+    person("Kirill", "Müller", , "kirill@cynkra.com", role = "cre"),
+    person("RStudio", role = c("cph", "fnd"))
+  )
+Description: R's raw vector is useful for storing a single binary object.
+    What if you want to put a vector of them in a data frame? The 'blob'
+    package provides the blob object, a list of raw vectors, suitable for
+    use as a column in data frame.
 License: MIT + file LICENSE
 URL: https://blob.tidyverse.org, https://github.com/tidyverse/blob
 BugReports: https://github.com/tidyverse/blob/issues
@@ -32,5 +26,5 @@ Config/autostyle/scope: line_breaks
 Config/autostyle/strict: false
 Config/Needs/website: tidyverse/tidytemplate
 Encoding: UTF-8
-Roxygen: list(markdown = TRUE, roclets = c("collate", "namespace", "rd", "pkgapi::api_roclet"))
-RoxygenNote: 7.0.2
+Roxygen: list(markdown = TRUE)
+RoxygenNote: 7.3.2.9000
diff --git a/NEWS.md b/NEWS.md
index f9054aa..bcb7844 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,4 +1,189 @@
-<!-- NEWS.md is maintained by https://cynkra.github.io/fledge, do not edit -->
+<!-- NEWS.md is maintained by https://fledge.cynkra.com, contributors should not edit this file -->
+
+# blob 1.2.4.9016 (2024-12-09)
+
+## Continuous integration
+
+- Avoid failure in fledge workflow if no changes (#44).
+
+
+# blob 1.2.4.9015 (2024-12-08)
+
+## Continuous integration
+
+- Fetch tags for fledge workflow to avoid unnecessary NEWS entries (#43).
+
+
+# blob 1.2.4.9014 (2024-12-07)
+
+## Continuous integration
+
+- Use larger retry count for lock-threads workflow (#42).
+
+
+# blob 1.2.4.9013 (2024-11-28)
+
+## Continuous integration
+
+- Ignore errors when removing pkg-config on macOS (#41).
+
+
+# blob 1.2.4.9012 (2024-11-27)
+
+## Continuous integration
+
+- Explicit permissions (#40).
+
+
+# blob 1.2.4.9011 (2024-11-26)
+
+## Continuous integration
+
+- Use styler from main branch (#39).
+
+
+# blob 1.2.4.9010 (2024-11-25)
+
+## Continuous integration
+
+- Need to install R on Ubuntu 24.04 (#38).
+
+- Use Ubuntu 24.04 and styler PR (#36).
+
+
+# blob 1.2.4.9009 (2024-11-22)
+
+## Continuous integration
+
+  - Correctly detect branch protection (#35).
+
+
+# blob 1.2.4.9008 (2024-11-18)
+
+## Continuous integration
+
+  - Use stable pak (#34).
+
+
+# blob 1.2.4.9007 (2024-11-11)
+
+## Continuous integration
+
+  - Trigger run (#33).
+    
+      - ci: Trigger run
+    
+      - ci: Latest changes
+
+
+# blob 1.2.4.9006 (2024-10-28)
+
+## Continuous integration
+
+  - Trigger run (#32).
+
+  - Use pkgdown branch (#31).
+    
+      - ci: Use pkgdown branch
+    
+      - ci: Updates from duckdb
+
+
+# blob 1.2.4.9005 (2024-09-15)
+
+## Continuous integration
+
+  - Install via R CMD INSTALL ., not pak (#30).
+    
+      - ci: Install via R CMD INSTALL ., not pak
+    
+      - ci: Bump version of upload-artifact action
+
+
+# blob 1.2.4.9004 (2024-08-31)
+
+## Chore
+
+  - Auto-update from GitHub Actions.
+    
+    Run: https://github.com/tidyverse/blob/actions/runs/10425482785
+
+  - Auto-update from GitHub Actions.
+    
+    Run: https://github.com/tidyverse/blob/actions/runs/10200111385
+
+  - Auto-update from GitHub Actions.
+    
+    Run: https://github.com/tidyverse/blob/actions/runs/9728444615
+
+  - Auto-update from GitHub Actions.
+    
+    Run: https://github.com/tidyverse/blob/actions/runs/9691616480
+
+## Continuous integration
+
+  - Install local package for pkgdown builds.
+
+  - Improve support for protected branches with fledge.
+
+  - Improve support for protected branches, without fledge.
+
+  - Sync with latest developments.
+
+  - Use v2 instead of master.
+
+  - Inline action.
+
+  - Use dev roxygen2 and decor.
+
+  - Fix on Windows, tweak lock workflow.
+
+  - Avoid checking bashisms on Windows.
+
+  - Better commit message.
+
+  - Bump versions, better default, consume custom matrix.
+
+  - Recent updates.
+
+
+# blob 1.2.4.9003 (2023-10-09)
+
+- Internal changes only.
+
+
+# blob 1.2.4.9002 (2023-03-24)
+
+- Merged cran-1.2.4 into main.
+
+
+# blob 1.2.4.9001 (2023-03-23)
+
+- Internal changes only.
+
+
+# blob 1.2.4.9000 (2023-03-17)
+
+## Bug fixes
+
+- Fix argument consistency between S3 generics and methods.
+
+## Chore
+
+- Change maintainer e-mail.
+
+
+# blob 1.2.4 (2023-03-17)
+
+## Bug fixes
+
+- Fix argument consistency between S3 generics and methods.
+
+
+# blob 1.2.3 (2022-04-10)
+
+- `as_blob(NA_character_)` returns a missing `blob` instead of `as_blob("NA")` (#26, @michaelchirico).
+
 
 # blob 1.2.2 (2021-07-23)
 
diff --git a/R/cast.R b/R/cast.R
index 052bd4b..8fe391d 100644
--- a/R/cast.R
+++ b/R/cast.R
@@ -41,4 +41,10 @@ vec_cast.blob.raw <- function(x, to, ...) blob(x)
 
 #' @method vec_cast.blob character
 #' @export
-vec_cast.blob.character <- function(x, to, ...) blob(!!!lapply(x, charToRaw))
+vec_cast.blob.character <- function(x, to, ...) {
+  out <- as_blob(vector("list", length(x)))
+  # charToRaw(NA_character_) == charToRaw("NA"), so only convert non-missing entries
+  non_missing <- !is.na(x)
+  out[non_missing] <- lapply(x[non_missing], charToRaw)
+  out
+}
diff --git a/R/compat-lifecycle.R b/R/compat-lifecycle.R
index 68bb5ab..d1e5b28 100644
--- a/R/compat-lifecycle.R
+++ b/R/compat-lifecycle.R
@@ -1,4 +1,4 @@
-# nocov start - compat-lifecycle (last updated: rlang 0.3.0.9000)
+# nocov start --- compat-lifecycle
 
 # This file serves as a reference for currently unexported rlang
 # lifecycle functions. Please find the most recent version in rlang's
@@ -6,6 +6,25 @@
 # DESCRIPTION field but you don't need to import rlang in your
 # namespace.
 
+# Changelog
+# =========
+#
+# 2021-04-19
+#
+# - Removed `lifecycle()` function. You can now use the following in
+#   your roxygen documentation to inline a badge:
+#
+#    ```
+#    `r lifecycle::badge()`
+#    ```
+#
+#   This is a build-time dependency on lifecycle so there is no need
+#   to add lifecycle to Imports just to use badges. See also
+#   `?usethis::use_lifecycle()` for importing or updating the badge
+#   images in your package.
+#
+# - Soft-namespaced private objects.
+
 
 #' Signal deprecation
 #'
@@ -55,16 +74,33 @@
 #' particularly useful in testthat blocks.
 #'
 #' @noRd
-#' @seealso [lifecycle()]
 NULL
 
-signal_soft_deprecated <- function(msg, id = msg, env = caller_env(2)) {
+signal_soft_deprecated <- function(msg, id = msg, env = rlang::caller_env(2)) {
+  msg <- .rlang_lifecycle_validate_message(msg)
+  stopifnot(
+    rlang::is_string(id),
+    rlang::is_environment(env)
+  )
+
   if (rlang::is_true(rlang::peek_option("lifecycle_disable_warnings"))) {
     return(invisible(NULL))
   }
 
+  env_inherits_global <- function(env) {
+    # `topenv(emptyenv())` returns the global env. Return `FALSE` in
+    # that case to allow passing the empty env when the
+    # soft-deprecation should not be promoted to deprecation based on
+    # the caller environment.
+    if (rlang::is_reference(env, emptyenv())) {
+      return(FALSE)
+    }
+
+    rlang::is_reference(topenv(env), rlang::global_env())
+  }
+
   if (rlang::is_true(rlang::peek_option("lifecycle_verbose_soft_deprecation")) ||
-      rlang::is_reference(topenv(env), rlang::global_env())) {
+    env_inherits_global(env)) {
     warn_deprecated(msg, id)
     return(invisible(NULL))
   }
@@ -73,8 +109,8 @@ signal_soft_deprecated <- function(msg, id = msg, env = caller_env(2)) {
   # testthat clones the namespace
   tested_package <- Sys.getenv("TESTTHAT_PKG")
   if (nzchar(tested_package) &&
-        identical(Sys.getenv("NOT_CRAN"), "true") &&
-        rlang::env_name(topenv(env)) == rlang::env_name(ns_env(tested_package))) {
+    identical(Sys.getenv("NOT_CRAN"), "true") &&
+    rlang::env_name(topenv(env)) == rlang::env_name(rlang::ns_env(tested_package))) {
     warn_deprecated(msg, id)
     return(invisible(NULL))
   }
@@ -83,147 +119,85 @@ signal_soft_deprecated <- function(msg, id = msg, env = caller_env(2)) {
 }
 
 warn_deprecated <- function(msg, id = msg) {
+  msg <- .rlang_lifecycle_validate_message(msg)
+  stopifnot(rlang::is_string(id))
+
   if (rlang::is_true(rlang::peek_option("lifecycle_disable_warnings"))) {
     return(invisible(NULL))
   }
 
-  if (!rlang::is_true(rlang::peek_option("lifecycle_repeat_warnings"))) {
-    if (rlang::env_has(deprecation_env, id)) {
-      return(invisible(NULL))
-    }
-
-    has_colour <- function() rlang::is_installed("crayon") && crayon::has_color()
-    silver <- function(x) if (has_colour()) crayon::silver(x) else x
-
-    msg <- paste0(
-      msg,
-      "\n",
-      silver("This warning is displayed once per session.")
-    )
+  if (!rlang::is_true(rlang::peek_option("lifecycle_repeat_warnings")) &&
+    rlang::env_has(.rlang_lifecycle_deprecation_env, id)) {
+    return(invisible(NULL))
   }
 
-  rlang::env_poke(deprecation_env, id, TRUE)
+  rlang::env_poke(.rlang_lifecycle_deprecation_env, id, TRUE);
+
+  has_colour <- function() rlang::is_installed("crayon") && crayon::has_color()
+  silver <- function(x) if (has_colour()) crayon::silver(x) else x
 
   if (rlang::is_true(rlang::peek_option("lifecycle_warnings_as_errors"))) {
-    signal <- .Defunct
+    .Signal <- stop_defunct
   } else {
-    signal <- .Deprecated
+    .Signal <- .Deprecated
   }
 
-  signal(msg = msg)
+  if (!rlang::is_true(rlang::peek_option("lifecycle_repeat_warnings"))) {
+    msg <- paste0(msg, "\n", silver("This warning is displayed once per session."))
+  }
+
+  .Signal(msg = msg)
 }
-deprecation_env <- new.env(parent = emptyenv())
+.rlang_lifecycle_deprecation_env <- new.env(parent = emptyenv())
 
 stop_defunct <- function(msg) {
-  .Defunct(msg = msg)
+  msg <- .rlang_lifecycle_validate_message(msg)
+  err <- rlang::cnd(
+    c("defunctError", "error", "condition"),
+    old = NULL,
+    new = NULL,
+    package = NULL,
+    message = msg
+  )
+  stop(err)
 }
 
-scoped_lifecycle_silence <- function(frame = rlang::caller_env()) {
-  rlang::scoped_options(.frame = frame,
+local_lifecycle_silence <- function(frame = rlang::caller_env()) {
+  rlang::local_options(.frame = frame,
     lifecycle_disable_warnings = TRUE
   )
 }
 with_lifecycle_silence <- function(expr) {
-  scoped_lifecycle_silence()
+  local_lifecycle_silence()
   expr
 }
 
-scoped_lifecycle_warnings <- function(frame = rlang::caller_env()) {
-  rlang::scoped_options(.frame = frame,
+local_lifecycle_warnings <- function(frame = rlang::caller_env()) {
+  rlang::local_options(.frame = frame,
     lifecycle_disable_warnings = FALSE,
     lifecycle_verbose_soft_deprecation = TRUE,
     lifecycle_repeat_warnings = TRUE
   )
 }
 with_lifecycle_warnings <- function(expr) {
-  scoped_lifecycle_warnings()
+  local_lifecycle_warnings()
   expr
 }
 
-scoped_lifecycle_errors <- function(frame = rlang::caller_env()) {
-  scoped_lifecycle_warnings(frame = frame)
-  rlang::scoped_options(.frame = frame,
+local_lifecycle_errors <- function(frame = rlang::caller_env()) {
+  local_lifecycle_warnings(frame = frame)
+  rlang::local_options(.frame = frame,
     lifecycle_warnings_as_errors = TRUE
   )
 }
 with_lifecycle_errors <- function(expr) {
-  scoped_lifecycle_errors()
+  local_lifecycle_errors()
   expr
 }
 
-
-#' Embed a lifecycle badge in documentation
-#'
-#' @description
-#'
-#' Use `lifecycle()` within a `Sexpr` macro to embed a
-#' [lifecycle](https://www.tidyverse.org/lifecycle/) badge in your
-#' documentation. The badge should appear first in the description:
-#'
-#' ```
-#' \Sexpr[results=rd, stage=render]{mypkg:::lifecycle("questioning")}
-#' ```
-#'
-#' The badge appears as an image in the HTML version of the
-#' documentation. To make them available in your package, visit
-#' <https://github.com/r-lib/rlang/tree/master/man/figures> and copy
-#' all the files starting with `lifecycle-` in your `man/figures/`
-#' folder.
-#'
-#' @param stage A lifecycle stage as a string, one of:
-#'   `"experimental"`, `"maturing"`, `"stable"`, `"questioning"`,
-#'   `"archived"`, `"soft-deprecated"`, `"deprecated"`, `"defunct"`.
-#'
-#' @noRd
-NULL
-
-lifecycle <- function(stage) {
-  url <- paste0("https://www.tidyverse.org/lifecycle/#", stage)
-  img <- lifecycle_img(stage, url)
-
-  sprintf(
-    "\\ifelse{html}{%s}{\\strong{%s}}",
-    img,
-    upcase1(stage)
-  )
+.rlang_lifecycle_validate_message <- function(msg) {
+  stopifnot(rlang::is_character(msg))
+  paste0(msg, collapse = "\n")
 }
 
-lifecycle_img <- function(stage, url) {
-  file <- sprintf("lifecycle-%s.svg", stage)
-  stage_alt <- upcase1(stage)
-
-  switch(stage,
-
-    experimental = ,
-    maturing = ,
-    stable = ,
-    questioning = ,
-    archived =
-      sprintf(
-        "\\out{<a href='%s'><img src='%s' alt='%s lifecycle'></a>}",
-        url,
-        file.path("figures", file),
-        stage_alt
-      )
-   ,
-
-    `soft-deprecated` = ,
-    deprecated = ,
-    defunct =
-      sprintf(
-        "\\figure{%s}{options: alt='%s lifecycle'}",
-        file,
-        stage_alt
-      ),
-
-    rlang::abort(sprintf("Unknown lifecycle stage `%s`", stage))
-
-  )
-}
-upcase1 <- function(x) {
-  substr(x, 1, 1) <- toupper(substr(x, 1, 1))
-  x
-}
-
-
 # nocov end
diff --git a/R/format.R b/R/format.R
index 4989414..84ff7f6 100644
--- a/R/format.R
+++ b/R/format.R
@@ -18,12 +18,12 @@ obj_print_data.blob <- function(x, ...) {
 }
 
 #' @export
-vec_ptype_abbr.blob <- function(x) {
+vec_ptype_abbr.blob <- function(x, ..., prefix_named, suffix_shape) {
   "blob"
 }
 
 #' @export
-vec_ptype_full.blob <- function(x) {
+vec_ptype_full.blob <- function(x, ...) {
   "blob"
 }
 
diff --git a/R/prettyunits.R b/R/prettyunits.R
index e5ed8af..912f24f 100644
--- a/R/prettyunits.R
+++ b/R/prettyunits.R
@@ -13,7 +13,7 @@ compute_bytes <- function(bytes, smallest_unit = "B") {
   bytes <- abs(bytes)
   smallest_idx <- match(smallest_unit, units0)
 
-  limits <- c(1000, 999950 * 1000 ^ (seq_len(length(units0) - 2) - 1))
+  limits <- c(1000, 999950 * 1000^(seq_len(length(units0) - 2) - 1))
   idx <- cut(bytes, c(0, limits, Inf), labels = FALSE, right = FALSE)
   idx <- pmax(idx, smallest_idx)
 
diff --git a/README.Rmd b/README.Rmd
index 4a2d052..e25593e 100644
--- a/README.Rmd
+++ b/README.Rmd
@@ -6,7 +6,7 @@ output: downlit::readme_document
 [![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable)
 [![rcc](https://github.com/tidyverse/blob/workflows/rcc/badge.svg)](https://github.com/tidyverse/blob/actions)
 [![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/blob)](https://cran.r-project.org/package=blob)
-[![Coverage Status](https://codecov.io/gh/tidyverse/blob/branch/main/graph/badge.svg)](https://codecov.io/github/tidyverse/blob?branch=main)
+[![Coverage Status](https://codecov.io/gh/tidyverse/blob/branch/main/graph/badge.svg)](https://app.codecov.io/gh/tidyverse/blob)
 <!-- badges: end -->
 
 <!-- README.md is generated from README.Rmd. Please edit that file -->
diff --git a/README.md b/README.md
index 7f0dcda..c3ed857 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 <!-- badges: start -->
 
-[![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![rcc](https://github.com/tidyverse/blob/workflows/rcc/badge.svg)](https://github.com/tidyverse/blob/actions) [![CRAN\_Status\_Badge](https://www.r-pkg.org/badges/version/blob)](https://cran.r-project.org/package=blob) [![Coverage Status](https://codecov.io/gh/tidyverse/blob/branch/main/graph/badge.svg)](https://codecov.io/github/tidyverse/blob?branch=main)
+[![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) [![rcc](https://github.com/tidyverse/blob/workflows/rcc/badge.svg)](https://github.com/tidyverse/blob/actions) [![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/blob)](https://cran.r-project.org/package=blob) [![Coverage Status](https://codecov.io/gh/tidyverse/blob/branch/main/graph/badge.svg)](https://app.codecov.io/gh/tidyverse/blob)
 
 <!-- badges: end -->
 
@@ -17,36 +17,36 @@ In most cases you will not need to use this package explicitly: it will be used
 ## Installation
 
 <pre class='chroma'>
-<span class='c'># The easiest way to get blob is to install the whole tidyverse:</span>
-<span class='nf'><a href='https://rdrr.io/r/utils/install.packages.html'>install.packages</a></span><span class='o'>(</span><span class='s'>"tidyverse"</span><span class='o'>)</span>
-
-<span class='c'># Alternatively, install just blob:</span>
-<span class='nf'><a href='https://rdrr.io/r/utils/install.packages.html'>install.packages</a></span><span class='o'>(</span><span class='s'>"blob"</span><span class='o'>)</span>
-
-<span class='c'># Or the the development version from GitHub:</span>
-<span class='c'># install.packages("devtools")</span>
-<span class='nf'>devtools</span><span class='nf'>::</span><span class='nf'><a href='https://devtools.r-lib.org//reference/remote-reexports.html'>install_github</a></span><span class='o'>(</span><span class='s'>"tidyverse/blob"</span><span class='o'>)</span></pre>
+<span><span class='c'># The easiest way to get blob is to install the whole tidyverse:</span></span>
+<span><span class='nf'><a href='https://rdrr.io/r/utils/install.packages.html'>install.packages</a></span><span class='o'>(</span><span class='s'>"tidyverse"</span><span class='o'>)</span></span>
+<span></span>
+<span><span class='c'># Alternatively, install just blob:</span></span>
+<span><span class='nf'><a href='https://rdrr.io/r/utils/install.packages.html'>install.packages</a></span><span class='o'>(</span><span class='s'>"blob"</span><span class='o'>)</span></span>
+<span></span>
+<span><span class='c'># Or the the development version from GitHub:</span></span>
+<span><span class='c'># install.packages("devtools")</span></span>
+<span><span class='nf'>devtools</span><span class='nf'>::</span><span class='nf'><a href='https://remotes.r-lib.org/reference/install_github.html'>install_github</a></span><span class='o'>(</span><span class='s'>"tidyverse/blob"</span><span class='o'>)</span></span></pre>
 
 ## Example
 
 To create a blob, use `blob()`, `new_blob()` or `as_blob()`:
 
 <pre class='chroma'>
-<span class='kr'><a href='https://rdrr.io/r/base/library.html'>library</a></span><span class='o'>(</span><span class='nv'><a href='https://blob.tidyverse.org'>blob</a></span><span class='o'>)</span>
-
-<span class='nv'>x1</span> <span class='o'>&lt;-</span> <span class='nf'><a href='https://rdrr.io/r/base/rawConversion.html'>charToRaw</a></span><span class='o'>(</span><span class='s'>"Good morning"</span><span class='o'>)</span>
-<span class='nv'>x2</span> <span class='o'>&lt;-</span> <span class='nf'><a href='https://rdrr.io/r/base/raw.html'>as.raw</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/c.html'>c</a></span><span class='o'>(</span><span class='m'>0x48</span>, <span class='m'>0x65</span>, <span class='m'>0x6c</span>, <span class='m'>0x6c</span>, <span class='m'>0x6f</span><span class='o'>)</span><span class='o'>)</span>
-
-<span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>new_blob</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/list.html'>list</a></span><span class='o'>(</span><span class='nv'>x1</span>, <span class='nv'>x2</span><span class='o'>)</span><span class='o'>)</span>
-<span class='c'>#&gt; &lt;blob[2]&gt;</span>
-<span class='c'>#&gt; [1] blob[12 B] blob[5 B]</span>
-<span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>blob</a></span><span class='o'>(</span><span class='nv'>x1</span>, <span class='nv'>x2</span><span class='o'>)</span>
-<span class='c'>#&gt; &lt;blob[2]&gt;</span>
-<span class='c'>#&gt; [1] blob[12 B] blob[5 B]</span>
-
-<span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>as_blob</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/c.html'>c</a></span><span class='o'>(</span><span class='s'>"Good morning"</span>, <span class='s'>"Good evening"</span><span class='o'>)</span><span class='o'>)</span>
-<span class='c'>#&gt; &lt;blob[2]&gt;</span>
-<span class='c'>#&gt; [1] blob[12 B] blob[12 B]</span></pre>
+<span><span class='kr'><a href='https://rdrr.io/r/base/library.html'>library</a></span><span class='o'>(</span><span class='nv'><a href='https://blob.tidyverse.org'>blob</a></span><span class='o'>)</span></span>
+<span></span>
+<span><span class='nv'>x1</span> <span class='o'>&lt;-</span> <span class='nf'><a href='https://rdrr.io/r/base/rawConversion.html'>charToRaw</a></span><span class='o'>(</span><span class='s'>"Good morning"</span><span class='o'>)</span></span>
+<span><span class='nv'>x2</span> <span class='o'>&lt;-</span> <span class='nf'><a href='https://rdrr.io/r/base/raw.html'>as.raw</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/c.html'>c</a></span><span class='o'>(</span><span class='m'>0x48</span>, <span class='m'>0x65</span>, <span class='m'>0x6c</span>, <span class='m'>0x6c</span>, <span class='m'>0x6f</span><span class='o'>)</span><span class='o'>)</span></span>
+<span></span>
+<span><span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>new_blob</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/list.html'>list</a></span><span class='o'>(</span><span class='nv'>x1</span>, <span class='nv'>x2</span><span class='o'>)</span><span class='o'>)</span></span>
+<span><span class='c'>#&gt; &lt;blob[2]&gt;</span></span>
+<span><span class='c'>#&gt; [1] blob[12 B] blob[5 B]</span></span>
+<span><span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>blob</a></span><span class='o'>(</span><span class='nv'>x1</span>, <span class='nv'>x2</span><span class='o'>)</span></span>
+<span><span class='c'>#&gt; &lt;blob[2]&gt;</span></span>
+<span><span class='c'>#&gt; [1] blob[12 B] blob[5 B]</span></span>
+<span></span>
+<span><span class='nf'><a href='https://blob.tidyverse.org/reference/blob.html'>as_blob</a></span><span class='o'>(</span><span class='nf'><a href='https://rdrr.io/r/base/c.html'>c</a></span><span class='o'>(</span><span class='s'>"Good morning"</span>, <span class='s'>"Good evening"</span><span class='o'>)</span><span class='o'>)</span></span>
+<span><span class='c'>#&gt; &lt;blob[2]&gt;</span></span>
+<span><span class='c'>#&gt; [1] blob[12 B] blob[12 B]</span></span></pre>
 
 ------------------------------------------------------------------------
 
diff --git a/cran-comments.md b/cran-comments.md
index c0f28fe..9dd04d8 100644
--- a/cran-comments.md
+++ b/cran-comments.md
@@ -1,18 +1,6 @@
-blob 1.2.2
-
-## Cran Repository Policy
-
-- [x] Reviewed CRP last edited 2021-04-25.
-
-## R CMD check results
-
-- [x] Checked locally, R 4.1.0
-- [x] Checked on CI system, R 4.1.0
-- [x] Checked on win-builder, R devel
-
-Check the boxes above after successful execution and remove this line. Then run `fledge::release()`.
+blob 1.2.4
 
 ## Current CRAN check results
 
-- [x] Checked on 2021-07-23, problems found: https://cran.r-project.org/web/checks/check_results_blob.html
-- [x] NOTE: r-devel-linux-x86_64-debian-clang, r-devel-linux-x86_64-debian-gcc, r-devel-linux-x86_64-fedora-clang, r-devel-linux-x86_64-fedora-gcc, r-devel-windows-x86_64, r-devel-windows-x86_64-gcc10-UCRT, r-patched-linux-x86_64, r-patched-solaris-x86, r-release-linux-x86_64, r-release-macos-arm64, r-release-macos-x86_64, r-release-windows-ix86+x86_64: Removed LazyData field
+- [x] Checked on 2023-03-17, problems found: https://cran.r-project.org/web/checks/check_results_blob.html
+- [x] WARN: r-devel-linux-x86_64-debian-clang, r-devel-linux-x86_64-debian-gcc, r-devel-linux-x86_64-fedora-clang, r-devel-linux-x86_64-fedora-gcc, r-devel-windows-x86_64: Fixed
diff --git a/man/vec_cast.blob.Rd b/man/vec_cast.blob.Rd
index 67af5fc..9a733c4 100644
--- a/man/vec_cast.blob.Rd
+++ b/man/vec_cast.blob.Rd
@@ -12,8 +12,8 @@
 \item{to}{Type to cast to. If \code{NULL}, \code{x} will be returned as is.}
 
 \item{...}{For \code{vec_cast_common()}, vectors to cast. For
-\code{vec_cast()} and \code{vec_restore()}, these dots are only for future
-extensions and should be empty.}
+\code{vec_cast()}, \code{vec_cast_default()}, and \code{vec_restore()}, these
+dots are only for future extensions and should be empty.}
 }
 \description{
 Double dispatch methods to support \code{\link[vctrs:vec_cast]{vctrs::vec_cast()}}.
diff --git a/man/vec_ptype2.blob.Rd b/man/vec_ptype2.blob.Rd
index dbc328d..ae8b506 100644
--- a/man/vec_ptype2.blob.Rd
+++ b/man/vec_ptype2.blob.Rd
@@ -7,17 +7,11 @@
 \method{vec_ptype2}{blob}(x, y, ..., x_arg = "", y_arg = "")
 }
 \arguments{
-\item{x}{Vector types.}
-
-\item{y}{Vector types.}
+\item{x, y}{Vector types.}
 
 \item{...}{These dots are for future extensions and must be empty.}
 
-\item{x_arg}{Argument names for \code{x} and \code{y}. These are used
-in error messages to inform the user about the locations of
-incompatible types (see \code{\link[vctrs:stop_incompatible_type]{stop_incompatible_type()}}).}
-
-\item{y_arg}{Argument names for \code{x} and \code{y}. These are used
+\item{x_arg, y_arg}{Argument names for \code{x} and \code{y}. These are used
 in error messages to inform the user about the locations of
 incompatible types (see \code{\link[vctrs:stop_incompatible_type]{stop_incompatible_type()}}).}
 }
diff --git a/tests/testthat/helper-deprecate.R b/tests/testthat/helper-deprecate.R
index 8b3a926..c6c5445 100644
--- a/tests/testthat/helper-deprecate.R
+++ b/tests/testthat/helper-deprecate.R
@@ -1 +1 @@
-#scoped_lifecycle_errors(rlang::caller_env(8))
+# scoped_lifecycle_errors(rlang::caller_env(8))
diff --git a/tests/testthat/test-cast.R b/tests/testthat/test-cast.R
index a0daa09..4d6eb89 100644
--- a/tests/testthat/test-cast.R
+++ b/tests/testthat/test-cast.R
@@ -2,7 +2,7 @@ context("test-cast")
 
 test_that("casting with as.blob()", {
   # Deprecated in v1.2.0
-  scoped_lifecycle_silence()
+  local_lifecycle_silence()
 
   expect_identical(as.blob(blob(raw(1))), blob(raw(1)))
   expect_identical(as.blob(list(raw(1))), blob(raw(1)))
@@ -18,3 +18,8 @@ test_that("casting with as_blob()", {
   expect_identical(blob(!!!as.raw(1:3)), blob(as.raw(1), as.raw(2), as.raw(3)))
   expect_identical(as_blob(c("abc", "def")), blob(charToRaw("abc"), charToRaw("def")))
 })
+
+test_that("NA_character_ is converted to missing blob()", {
+  expect_identical(as_blob(NA_character_), blob(NULL))
+  expect_identical(as_blob(c("1", NA_character_)), blob(as.raw(0x031), NULL))
+})
diff --git a/tests/testthat/test-format.R b/tests/testthat/test-format.R
index b4e423b..0f5391c 100644
--- a/tests/testthat/test-format.R
+++ b/tests/testthat/test-format.R
@@ -10,7 +10,7 @@ expect_format_equal <- function(formatted, result) {
 test_that("unequal sizes", {
   skip_on_cran()
 
-  x <- blob(raw(2 ** 2), raw(2 ** 11), raw(2 ** 20))
+  x <- blob(raw(2**2), raw(2**11), raw(2**20))
 
   expect_format_equal(
     format(x),
@@ -34,7 +34,7 @@ test_that("unequal sizes", {
 test_that("similar sizes", {
   skip_on_cran()
 
-  x <- blob(raw(2 ** 10), raw(2 ** 11), raw(2 ** 12))
+  x <- blob(raw(2**10), raw(2**11), raw(2**12))
 
   expect_format_equal(
     format(x),
@@ -58,7 +58,7 @@ test_that("empty", {
 test_that("output", {
   skip_on_cran()
 
-  x <- blob(raw(2 ** 2), raw(2 ** 11), raw(2 ** 20))
+  x <- blob(raw(2**2), raw(2**11), raw(2**20))
 
   expect_known_output(print(x), "blob.txt")
 })
