How to authenticate a GitHub Actions workflow as a GitHub App so it can trigger other workflows?

Question:

By default (when using the default secrets.GITHUB_TOKEN) GitHub Actions workflows can’t trigger other workflows. So for example if a workflow sends a pull request to a repo that has a CI workflow that normally runs the tests on pull requests, the CI workflow won’t run for a pull request that was sent by another workflow.

There are probably lots of other GitHub API actions that a workflow authenticating with the default secrets.GITHUB_TOKEN can’t take either.

How can I authenticate my workflow runs as a GitHub App, so that they can trigger other workfows and take any other actions that I grant the GitHub App permissions for?

Why not just use a personal access token?

The GitHub docs linked above recommend authenticating workflows using a personal access token (PAT) to allow them to trigger other workflows, but PATs have some downsides:

  • You probably don’t want your workflow to authenticate as any human user’s account because any pull requests, issues, etc created by the workflow will appear to have been created by that human rather than appearing to be automated. The PAT would also become a very sensitive secret because it would grant access to all repos that the human user’s account has access to.
  • You could create a machine user account to own the PAT. But if you grant the machine user access to all repos in your organization then the PAT again becomes a very sensitive secret. You can add the machine user as a collaborator on only the individual repos that you need, but this is inconvenient because you’ll always need to add the user to each new repo that you want it to have access to.
  • Classic PATs have only broad-grained permissions. The recently-introduced fine-grained PATs don’t work with GitHub CLI (which is the easiest way to send PRs, open issues, etc from workflows) and there’s no ETA for when support will be added.

GitHub Apps offer the best balance of convenience and security for authenticating workflows: apps can have fine-grained permissions and they can be installed only in individual repos or in all of a user or organization’s repos (including automatically installing the app in new repos when they’re created). Apps also get a nice page where you can type in some docs (example), the app’s avatar and username on PRs, issues, etc link to this page. Apps are also clearly labelled as "bot" on any PRs, issues, etc that they create.

This third-party documentation is a good summary of the different ways of authenticating workflows and their pros and cons.

Asked By: Sean Hammond

||

Answers:

  1. Create a GitHub App in your user or organization account. Take note of your app’s App ID which is shown on your app’s settings page, you’ll need it later.

  2. Grant your app the necessary permissions. To send pull requests an app will probably need:

    1. Read and write access for the Contents permission
    2. Read and write access for the Pull requests permission
    3. Read and write access for the Workflows permission if you intend for it to send pull requests that change workflow files
  3. Generate a private key for your app.

  4. Store a copy of the private key in a GitHub Actions secret named MY_GITHUB_APP_PRIVATE_KEY. This can either be a repo-level secret in the repo that will contain the workflow that you’re going to write, or it can be a user- or organization-level secret in which case the repo that will contain the workflow will need access to the secret.

  5. Install your GitHub app in your repo or user or organization account. Take note of the Installation ID. This is the 8-digit number that is at the end of the URL of the installation’s page in the settings of the repo, user or organization where you installed the app. You’ll need this later.

A workflow run that wants to authenticate as your app needs to get a temporary installation access token each time it runs, and use that token to authenticate itself. This is called authenticating as an installation in GitHub’s docs and they give a code example in Ruby. The steps are:

  1. Generate a JSON Web Token (JWT) with an iat ("issued at" time) 60s in the past, an exp (expiration time) 10m in the future, and your App ID as the iss (issuer), and sign the token using your private key and the RS256 algorithm.
  2. Make an HTTP POST request to https://api.github.com/app/installations/:installation_id/access_tokens (replacing :installation_id with your installation ID) with the signed JWT in an Authorization: Bearer <SIGNED_JWT> header.
  3. The temporary installation access token will be in the GitHub API’s JSON response.

Here’s a Python script that implements this token exchange using PyJWT and requests:

from argparse import ArgumentParser
from datetime import datetime, timedelta, timezone

import jwt
import requests

def get_token(app_id, private_key, installation_id):
    payload = {
        "iat": datetime.now(tz=timezone.utc) - timedelta(seconds=60),
        "exp": datetime.now(tz=timezone.utc) + timedelta(minutes=10),
        "iss": app_id,
    }

    encoded_jwt = jwt.encode(payload, private_key, algorithm="RS256")

    response = requests.post(
        f"https://api.github.com/app/installations/{installation_id}/access_tokens",
        headers={
            "Accept": "application/vnd.github+json",
            "Authorization": f"Bearer {encoded_jwt}",
        },
        timeout=60,
    )

    return response.json()["token"]

def cli():
    parser = ArgumentParser()
    parser.add_argument("--app-id", required=True)
    parser.add_argument("--private-key", required=True)
    parser.add_argument("--installation-id", required=True)

    args = parser.parse_args()

    token = get_token(args.app_id, args.private_key, args.installation_id)

    print(token)

if __name__ == "__main__":
    cli()

https://github.com/hypothesis/gha-token is a version of the above code as an installable Python package. To install it with pipx and get a token:

$ pipx install git+https://github.com/hypothesis/gha-token.git
$ gha-token --app-id $APP_ID --installation-id $INSTALLATION_ID --private-key $PRIVATE_KEY
ghs_xyz***

You can write a workflow that uses gha-token to get a token and authenticate any API requests or GitHub CLI calls made by the workflow. The workflow below will:

  1. Install Python 3.10 and gha-token
  2. Call gha-token to get an installation access token using the App ID, Installation ID, and MY_GITHUB_APP_PRIVATE_KEY GitHub secret that you created earlier
  3. Use the installation access token to authenticate a GitHub CLI gh auth status command

In the workflow below you should replace <APP_ID> with your App ID and replace <INSTALLATION_ID> with your Installation ID:

name: My Workflow
on:
  workflow_dispatch:
jobs:
  my_job:
    name: My Job
    runs-on: ubuntu-latest
    steps:
      - name: Install Python 3.10
        uses: actions/setup-python@v4
        with:
          python-version: "3.10"
      - run: python3.10 -m pip install pipx
      - run: python3.10 -m pipx install "git+https://github.com/hypothesis/gha-token.git"
      - name: Get GitHub token
        id: github_token
        run: echo GITHUB_TOKEN=$(gha-token --app-id <APP_ID> --installation-id <INSTALLATION_ID> --private-key "$PRIVATE_KEY") >> $GITHUB_OUTPUT
        env:
          PRIVATE_KEY: ${{ secrets.MY_GITHUB_APP_PRIVATE_KEY }}
      - run: gh auth status
        env:
          GITHUB_TOKEN: ${{ steps.github_token.outputs.GITHUB_TOKEN }}
Answered By: Sean Hammond