Space Vatican

Ramblings of a curious coder

Updating Dependabot PRs With Tapioca

I’ve been using sorbet recently on a project. If you haven’t come across it before, sorbet is a type checker for ruby that allows you to gradually add type annotations to your codebase (sigs in sorbet terminology) and then provide feedback (either on the command-line or in your editor) when things don’t check out.

As well as the sigs you add to your source, sorbet supports rbi files for describing your classes. In particular these are key for describing your dependencies. The recommended way is to use tapioca, a tool released by Shopify, for generating rbis. One of the things tapioca will do is autogenerate rbis for all of the gems you use. These autogenerated ones aren’t super smart (effectively just method name + number of arguments), but a lot better than nothing (there are also handwritten ones known as annotations - this is a small and nascent collection compared to typescript’s definitelytyped).

Of course, as your change or update your gems, the corresponding rbis need to be updated. If you run tapioca gem --verify then it will tell you whether there are any out of date gem rbis. We’ve added this to our CI to check that they’re always in sync. So far, so good! However we also use dependabot to manage our dependencies, so now every dependabot gem update PR would fail on CI, until someone came along and ran tapioca gem. This doesn’t take long (I wrote a shell alias to run bundler, run tapioca and then commit and push the results) but it’s an extra manual step and dependabot is all about removing manual steps.

In theory dependabot could do this itself, but it’s not on their roadmap. You can however write a github action to automate this. Here’s the github action in its full glory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: Dependabot Tapioca updates
on:
  pull_request:
    types: [opened, synchronize]

permissions:
  contents: write
  pull-requests: read
jobs:
  build:
    if: ${{ github.actor == 'dependabot[bot]' }}
    runs-on: ubuntu-latest
    steps:
      - name: Fetch Dependabot metadata
        id: dependabot-metadata
        uses: dependabot/fetch-metadata@v1
      - uses: actions/checkout@v3
        if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - uses: ruby/setup-ruby@v1
        if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
        with:
          ruby-version: '3.1.2'
          bundler-cache: true
      - name: Tapioca update
        if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
        run: bin/tapioca gem
      - name: git update
        if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
        run: |
          git config --global user.name 'some user'
          git config --global user.email 'some-user@example.com'
          git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY
          git add sorbet/rbi/gems
          git commit -m "updated tapioca definitions" sorbet/rbi/gems
          git push -u origin HEAD:${{ github.event.pull_request.head.ref }}

Let’s break it down. The first section is just when the action should run

1
2
3
on:
  pull_request:
    types: [opened, synchronize]

This says that the action should runs when a pull request is opened or synchronized (this covers things like when you ask dependabot to rebase the PR).

The next section is about the permissions required:

1
2
3
permissions:
  contents: write
  pull-requests: read

Actions triggered by dependabot have different permissions than normal actions. In this case we need:

  • permission to write the updated rbis to the repository (contents: write)
  • permission to read PRs (pull-requests: read)

Skipping irrelevant PRs

We don’t want this to run on non dependabot PRs or on other people’s commits to those PRs. At best this is just a waste of github action minutes. The first thing we do is the check right at the top of the job whether the actor is dependabot:

1
  if: ${{ github.actor == 'dependabot[bot]' }}

Then, as the first step in the action we use the dependabot/fetch-metadata action to fetch futher information:

1
2
3
- name: Fetch Dependabot metadata
  id: dependabot-metadata
  uses: dependabot/fetch-metadata@v1

This action also validates more thoroughly that the pull request is from dependabot, specificlly that the first commit is signed by dependabot. Other than authenticity, the main bit of metadata we’re interested in is the ecosystem of the package(s) that have been updated - for example there’s no point continuing if this is a yarn package update.

All the subsequent steps have

if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}

so they won’t run if the dependabot PR wasn’t for a gem update (you could split this into 2 jobs to eliminate the repetition but that seems a little overkill.

Checking out the app

1
2
3
4
- uses: actions/checkout@v3
  if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
  with:
    ref: ${{ github.event.pull_request.head.sha }}

By default the checkout action checks out a merge commit of the pull request into the target branch, but since we want to update the pull request we checkout the pull request’s head instead, using the sha in the event metadata.

Setup the app

1
2
3
4
5
- uses: ruby/setup-ruby@v1
  if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
  with:
    ruby-version: '3.1.2'
    bundler-cache: true

This just installs the requested version of ruby and runs bundler. If you have private dependencies that require github secrets, note that they have to be stored as dependabot secrets - since this workflow is triggered by dependabot, it plays by the same rules and does not have access to “normal” github secrets.

Run Tapioca

1
2
3
- name: Tapioca update
  if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
  run: bin/tapioca gem

This is the easy bit! We just let tapioca do its thing.

Push to github

1
2
3
4
5
6
7
8
9
- name: git update
  if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'bundler' }}
  run: |
    git config --global user.name 'some user'
    git config --global user.email 'some-user@example.com'
    git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY
    git add sorbet/rbi/gems
    git commit -m "updated tapioca definitions" sorbet/rbi/gems
    git push -u origin HEAD:${{ github.event.pull_request.head.ref }}

To be able to push out changes we first need to tell git who we are with git config and where to push the changes with git remote set-url. This uses the https transport because we can use the github token as the password. After committing the changes all we need to do is push.

Just running git push isn’t enough here, because we’re on a detached head: the checkout action has done the equivalent of git checkout abcdef so we need to tell git which remote branch to push to. Happily this is in the event payload as ${{ github.event.pull_request.head.ref }} .

And there we are - back to gem updates being a one click option (once you’re happy with the changes made of course). It is a tiny bit wasteful that CI will run twice, once on dependabot’s original commit and again on the commit updating the rbis, but at least that’s only wasting elapsed time, not your time!