Delete heroku review apps with Github actions
Tags: heroku, github
Published: 2022-09-29 20:27:38

[Heroku review apps](https://devcenter.heroku.com/articles/github-integration-review-apps) are a very important part of our workflow at [Seasoned](http://seasoned.cc/). We use them a lot since we created a way to use them to create [deploy previews](https://dev.to/felipefreitag/how-to-preview-a-spa-api-app-before-merging-a-pr-708) of our SPA + API stack.

We used to create review apps automatically for each PR. But at the time of this writing, Heroku is about to stop having a free tier. So we need to be more conscious about when to create and destroy review apps.

It's possible to create them manually via the Heroku dashboard, so before trying to automate their creation, let's find a way to make sure they're deleted when a PR is closed.

There are actions on the [Github marketplace](https://github.com/marketplace?type=actions&query=heroku+review+app+) that seem to handle the full review app workflow, but all of them do many more things and most are forks of one another. For now, I preferred to handle only the deletion process and to do so manually.

## How-to

We need Heroku's [Platform API](https://devcenter.heroku.com/articles/platform-api-reference#review-app) for this. Two API calls are enough:
- Find the app id from its name
- Delete it from its id

It's worth mentioning we use the predictable url pattern option so we know the app name.

### 1: get the app name
`GET /apps/{app_id_or_name}/review-app`

Full curl command:
```sh
curl -n https://api.heroku.com/apps/$APP_NAME/review-app \
  -H "Accept: application/vnd.heroku+json; version=3" \
  -H "Authorization: Bearer <heroku-api-key")
```

This returns a big JSON. Let's use [jq](https://stedolan.github.io/jq/) to parse it. It's already part of Github runner's [preinstalled software](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#preinstalled-software), so we don't need to install it inside the action.

```sh
curl -n https://api.heroku.com/apps/$APP_NAME/review-app \
  -H "Accept: application/vnd.heroku+json; version=3" \
  -H "Authorization: Bearer <heroku-api-key>" | jq '.id'

"<app-id>"
```

That will give us the app id

### 2: Delete the app

All that's left is to delete it.
`DELETE /review-apps/{review_app_id}`

With curl:
```sh
curl -n -X DELETE https://api.heroku.com/review-apps/<app-id>" \
 -H "Content-Type: application/json" \
 -H "Accept: application/vnd.heroku+json; version=3" \ 
 -H "Authorization: Bearer <heroku-api-key>"
```

## Translating to a Github Action

Now we need to make that work inside a Github Action. We could use the Heroku JS client to do the work, but that would mean creating a full Node Github action and that seems overkill for this use case.

I used variables to make the code easier to read, and a step output to have two well-defined steps instead a big list of commands.
The workflow trigger can be "pull request closed", since that will fire both for merged and closed PRs.

So far we have:

```yml
# .github/workflows/destroy-review-app.yml
name: Destroy review app
on:
  pull_request:
    types: [closed]

env:
  app-name: <predictable-url-pattern>

jobs:
  heroku-review-application:
    name: Destroy Heroku review app
    runs-on: ubuntu-latest
    steps:
      - name: Get review app id
        id: get-id
        run: |
          RESPONSE=$(curl -n https://api.heroku.com/apps/${{ env.app-name }}-pr-${{ github.event.number }}/review-app -H "Accept: application/vnd.heroku+json; version=3" -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}")
          APP_ID=$(echo $RESPONSE | jq -r '.id')
          echo "::set-output name=app-id::$APP_ID"

      - name: Destroy app
        id: destroy-app
        run: |
          APP_ID=${{ steps.get-id.outputs.app-id }}
          echo "Using id ${{ steps.get-id.outputs.app-id }}"
          RESPONSE=$(curl -n -X DELETE https://api.heroku.com/review-apps/"$APP_ID" -H "Content-Type: application/json" -H "Accept: application/vnd.heroku+json; version=3" -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}")
```

Note the `-r` flag on the `jq` command. It strips the double quotes from the value, it makes it cleaner to use later.

This action works for the happy path. Now we have two edge cases to handle.

### Fail on error

In case of an error, it's enough to have a failed action that prints the full response.
The JSON response from the delete endpoing has a `status: "deleting"` property that we can use to check whether it worked.

```sh
STATUS=$(echo "$RESPONSE" | jq -r '.status')
if [ "$STATUS" = "deleting" ]; then
  echo "App deleted"
else
  echo "Error deleting app"
  echo "$RESPONSE"
  exit 1
fi
```

### Exit early if not found

Not all our PRs will have review apps, and we don't want to have failed actions for all the ones that don't! So let's add a condition to exit early if the app doesn't exist.

```sh
if [ "$APP_ID" = "not_found" ]; then
  echo "App not found."
  exit 0
fi
```

## Complete action

Voilà! Here's our complete action.
Features:
- Run when a PR closes
- Delete a review app if it exists
- Exit successfully if it doesn't exist
- Fails if it finds an app but can't delete it

```yml
# .github/workflows/destroy-review-app.yml
name: Destroy review app
on:
  pull_request:
    types: [closed]

env:
  app-name: <predictable-url-pattern>

jobs:
  heroku-review-application:
    name: Destroy Heroku review app
    runs-on: ubuntu-latest
    steps:
      - name: Get review app id
        id: get-id
        run: |
          RESPONSE=$(curl -n https://api.heroku.com/apps/${{ env.app-name }}-pr-${{ github.event.number }}/review-app -H "Accept: application/vnd.heroku+json; version=3" -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}")
          APP_ID=$(echo $RESPONSE | jq -r '.id')
          echo "::set-output name=app-id::$APP_ID"

      - name: Destroy app
        id: destroy-app
        run: |
          APP_ID=${{ steps.get-id.outputs.app-id }}
          if [ "$APP_ID" = "not_found" ]; then
            echo "App not found."
            exit 0
          fi
          echo "Using id ${{ steps.get-id.outputs.app-id }}"
          RESPONSE=$(curl -n -X DELETE https://api.heroku.com/review-apps/"$APP_ID" -H "Content-Type: application/json" -H "Accept: application/vnd.heroku+json; version=3" -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}")
          STATUS=$(echo "$RESPONSE" | jq -r '.status')
          if [ "$STATUS" = "deleting" ]; then
            echo "App deleted"
          else
            echo "Error deleting app"
            echo "$RESPONSE"
            exit 1
          fi
```