pythongithubgithub-actionsgithub-apigithub-cli

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


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:

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.

I don't want to use a third-party GitHub Action

There are guides out there on the internet that will tell you how to authenticate a workflow as an app but they all tell you to use third-party actions (from the marketplace) to do the necessary token exchange with the GitHub API. I don't want to do this because it requires sending my app's private key to a third-party action. I'd rather write (or copy-paste) my own code to do the token exchange.


Solution

  • Links:

    To create a GitHub App and a workflow that authenticates as that app and sends PRs that can trigger other workflows:

    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 (you'll need to install pyjwt with the cryptography dependency: pip install pyjwt[crypto]).

    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.11 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. Make an automated change and commit it
    4. Use the access token to authenticate git push and gh pr create (GitHub CLI) commands to push a branch and open a PR

    Copy the workflow below to a file named .github/workflows/send-pr.yml in your repo. Replace <APP_ID> with your App ID and replace <INSTALLATION_ID> with your Installation ID:

    name: Send a Pull Request
    on:
      workflow_dispatch:
    jobs:
      my_job:
        name: Send a Pull Request
        runs-on: ubuntu-latest
        steps:
          - name: Install Python
            uses: actions/setup-python@v4
            with:
              python-version: "3.11"
          - name: Install gha-token
            run: python3.11 -m pip 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 }}
          - name: Checkout repo
            uses: actions/checkout@v3
            with:
              token: ${{ steps.github_token.outputs.GITHUB_TOKEN }}
          - name: Make some automated changes
            run: echo "Automated changes" >> README.md
          - name: Configure git
            run: |
              git config --global user.name "send-pr.yml workflow"
              git config --global user.email "<>"
          - name: Switch to a branch
            run: git switch --force-create my-branch main
          - name: Commit the changes
            run: git commit README.md -m "Automated commit"
          - name: Push branch
            run: git push --force origin my-branch:my-branch
          - name: Open PR
            run: gh pr create --fill
            env:
              GITHUB_TOKEN: ${{ steps.github_token.outputs.GITHUB_TOKEN }}