Heroku review apps are a very important part of our workflow at Seasoned. We use them a lot since we created a way to use them to create deploy previews 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 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 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:
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 to parse it. It's already part of Github runner's preinstalled software, so we don't need to install it inside the action.
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:
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:
# .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.
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.
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
# .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