View as Markdown

OIDC Authentication for GitHub Actions

Let GitHub Actions jobs reach Mergify with the ID token GitHub signs for them instead of a MERGIFY_TOKEN application key, under trust policies you write.


A GitHub Actions job can reach Mergify without MERGIFY_TOKEN. It asks GitHub for an OpenID Connect (OIDC) ID token and trades it with Mergify for a token that works for one hour, on that job’s repository only. Your trust policies decide which jobs get one. This page takes a workflow that stores a ci application key in MERGIFY_TOKEN to one that stores nothing.

A ci key never expires, works on every repository of your organization, and sits in repository secrets, where it can leak from a build log or a workflow change. A token from an ID token covers one repository for an hour, and Mergify reads the job’s branch from what GitHub signed rather than from what the job reports.

When a Mergify client finds no MERGIFY_TOKEN in a job granted id-token: write, it exchanges the job’s ID token:

  1. The client asks GitHub for an ID token meant for the Mergify API, https://api.mergify.com. GitHub signs into it what the job is: its repository, workflow, event, ref, commit and environment.

  2. Mergify checks the signature, that the token was meant for it, and that its GitHub App is installed on the repository. Then it looks for a trust policy of your organization that matches the job.

  3. When one matches, Mergify answers with a token that does what a ci key does, on that repository only: upload test results and scopes, read the test runs recorded for a commit and the quarantine list, and read the flaky-detection and test-selection data a test run fetches. Any other request is refused. The client renews the token before its hour is up.

  4. When none matches, the exchange is refused and the client prints why in the job log. The job then runs without a token: the test framework plugins run the tests with no quarantine and upload nothing, mergify ci junit-process fails its step, and mergify ci scopes-send warns and sends no scopes.

Job GitHub Actions job with id-token: write GitHub GitHub signs an ID token: repository, workflow, event, environment Job->GitHub  asks for an ID token Mergify Mergify checks the signature, the audience and the repository GitHub->Mergify  the client sends it Policy Does a trust policy match the job? Mergify->Policy Token Token for this repository, one hour Policy->Token yes Refused Refused: nothing uploaded Policy->Refused no
A job trades its GitHub ID token for a Mergify token only when one of your trust policies matches the job: green is a token, red a refusal

A MERGIFY_TOKEN that is set always wins: the client exchanges only when the variable is unset or empty. Until you remove the key from a job, nothing changes for it, so you can write your trust policies first.

A pull_request job’s results are filed under the ref GitHub signed for it, its pull request’s merge ref refs/pull/<number>/merge, and still count for that pull request.

  • A client that exchanges. The latest releases of the test framework plugins (pytest, RSpec, Minitest, Vitest, Playwright, Jest, Mocha), of the Mergify CLI (mergify ci junit-process and mergify ci scopes-send), and of mergifyio/gha-mergify-ci.

  • GitHub Actions. Buildkite, Jenkins and CircleCI jobs keep using a ci key.

Move your workflows off MERGIFY_TOKEN

Section titled Move your workflows off MERGIFY_TOKEN

Open Settings → Developer → OIDC on the dashboard. GitHub organization Owners and users holding the CI Admin delegated role can write policies.

Click Trust CI on Every Repository to create a policy named CI on every repository: every repository, any workflow, the CI events, any environment. Click Write a Policy instead to start narrower. Trust policies below describes each line.

Until your organization has a policy, every exchange is refused.

Let the job request an ID token

Section titled Let the job request an ID token

Grant id-token: write to the job, or to the whole workflow:

permissions:
id-token: write # lets the job ask GitHub for an ID token
contents: read # actions/checkout needs it

A permissions block sets every permission it does not list to none, so list the ones the job’s other steps need as well. mergifyio/gha-mergify-ci and the test framework plugins need nothing more.

A reusable workflow gets at most what its caller grants: give id-token: write to the calling job. A job of the called workflow that declares its own permissions must list id-token: write too.

Remove MERGIFY_TOKEN from the step’s env, the token input from mergifyio/gha-mergify-ci, and --token from mergify commands. A job that runs pytest then needs nothing else:

jobs:
test:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
# Add your setup steps here (Python, Node.js, etc.)
- name: Run tests
run: pytest

The upload step of mergifyio/gha-mergify-ci keeps its other inputs:

- name: Mergify CI Upload
# A pull request from a fork gets no ID token: skip the upload there
if: (success() || failure()) && !github.event.pull_request.head.repo.fork
uses: mergifyio/gha-mergify-ci@v30
with:
report_path: junit.xml
test_step_outcome: ${{ steps.tests.outcome }}

Here tests is the id of your test step. The fork condition replaces the HAS_MERGIFY_TOKEN guard of Pull requests without the secret: once the secret is deleted, that guard skips every upload. If your test step has continue-on-error: true, fail the job when the tests failed and nothing was uploaded:

- name: Fail when tests failed and no report was uploaded
if: steps.tests.outcome == 'failure' && github.event.pull_request.head.repo.fork
run: exit 1

A line that still reads ${{ secrets.MERGIFY_TOKEN }} once you delete the secret gives an empty value, and an empty MERGIFY_TOKEN counts as unset. Stop passing the secret to reusable workflows too: they need id-token: write from their caller, not the key.

The Exchanges tab of the OIDC settings lists every exchange for 90 days: the repository, the workflow, the event and ref, and the policy that allowed it or why it was refused. On an exchange refused because no trust policy matched, Allow Jobs Like This opens a new policy filled in with that job’s repository, workflow and event. Nothing is saved until you save it.

Exchanges lists the jobs refused because no trust policy matched, because Mergify’s GitHub App cannot access the repository, or because the token was minted for another Mergify. A token Mergify cannot verify is not listed: read the job’s log.

Workflows run for Dependabot pull requests get an ID token like the repository’s other runs, so they no longer need MERGIFY_TOKEN in your Dependabot secrets either.

Once your repositories show only allowed exchanges, delete the MERGIFY_TOKEN secrets, then the ci keys under Settings → Developer → Application Keys. Keep one for the jobs that cannot use OIDC. A key you do not delete keeps working.

A policy reads as a sentence: “Jobs from these repositories running these workflows on these events in these environments.” A job gets a token when one policy matches it, and a policy matches when all four lines do. Policies have no order and none denies: a narrow policy cannot hold back what a broad one allows.

  • Repositories: all repositories, the default, new ones included as Mergify’s GitHub App gets access to them; all except the ones you pick; or only the ones you pick.

  • Events: the CI events, the default (push, pull_request, merge_group, schedule and workflow_dispatch); the events you pick; or any event.

  • Workflows: any workflow, the default; or the workflow files of the job’s own repository, such as .github/workflows/ci.yml, and reusable workflows, optionally pinned to a ref.

  • Environments: any environment, or none, the default; or the environments you name, which a job in no environment does not match.

Keep these in mind while writing one:

  • pull_request_target, workflow_run and issue_comment are not CI events. They run with the repository’s identity on behalf of people who cannot push to it: add them only if those workflows never run code they were sent.

  • A workflow file of the job’s own repository names the workflow the run started from. A reusable workflow it calls, even one of the same repository (uses: ./.github/workflows/...), is added as a reusable workflow.

  • A workflow file of the job’s own repository matches at any ref, so anyone who can push a branch can edit it. Only a reusable workflow pinned to a protected ref, or an environment whose protection rules GitHub checks before the job starts, keeps them from doing so.

  • A private repository can send write tokens to workflows run for pull requests from forks. Those runs then get an ID token, and a policy that allows pull_request gives the fork’s code a token for your repository.

  • An organization can have up to 100 policies.

  • Deleting a policy does not revoke the tokens it already allowed: they expire within the hour.

  • Pull requests from forks. GitHub gives their runs no ID token, as it gives them no secrets on a public repository, so they report nothing either way (see Pull requests without the secret). If your organization sends secrets to workflows run for pull requests from forks, keep the key for those runs only:

    env:
      MERGIFY_TOKEN: ${{ github.event.pull_request.head.repo.fork && secrets.MERGIFY_TOKEN || '' }}
    

    Every other run gets an empty value and exchanges its ID token. Drop the fork condition from the upload step of mergifyio/gha-mergify-ci then, so that fork runs upload with the key.

  • pull_request_target, workflow_run and issue_comment jobs that send scopes. These jobs run on the base branch, and a token from their ID token reports scopes only for the commit the job runs on, never for the pull request’s head. Mergify refuses the upload, and mergify ci scopes-send fails the step. Give those jobs a ci key.

  • Jobs outside GitHub Actions. Buildkite, Jenkins and CircleCI jobs use a ci key.

tox, docker run and Docker Compose start your tests without the job’s environment. Pass ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN through with the variables the plugin reads: GitHub sets them in a job granted id-token: write, and without them the plugin has no ID token to exchange.

An on-premise Mergify install connected to GitHub Enterprise Server accepts the ID tokens of that server’s GitHub Actions, issued by https://<your-github-host>/_services/token, and none from github.com. The clients request the ID token for the Mergify API URL the job reaches, so set MERGIFY_API_URL (the mergify_api_url input of mergifyio/gha-mergify-ci) to your install’s URL, as you do with a key.

On github.com, Mergify also accepts the issuer GitHub Enterprise Cloud lets an enterprise switch its Actions to, https://token.actions.githubusercontent.com/<enterprise>. Nothing to configure.

When Mergify refuses an exchange, its reason appears in the job log. A test framework plugin prints it at the end of the run:

Mergify won't upload this run's test results: MERGIFY_TOKEN is not set, and the
job's ID token could not be exchanged for a Mergify token.
Error: Mergify refused the job's ID token: <reason>

The Mergify CLI, which mergifyio/gha-mergify-ci runs, ends its no Mergify credential found error with the same reason; mergify ci scopes-send prints it as a warning. What to do depends on how the reason starts:

  • <organization> has no trust policy: write a trust policy.

  • No trust policy of <organization> matches this job: the reason names the job’s workflow, event and environment, and Exchanges shows which line of each policy refused it. Widen a policy, or use Allow Jobs Like This.

  • Mergify's GitHub App has no access to <repository>: add the repository to the app’s installation in your GitHub organization settings.

  • The ID token was issued for another audience: the job requested its ID token for another Mergify. Set MERGIFY_API_URL to the audience the reason names.

  • This Mergify accepts the ID tokens of GitHub Actions jobs from <issuer> only: the job reached a Mergify that serves another GitHub. Check MERGIFY_API_URL.

MERGIFY_TOKEN is not set, with nothing about an ID token. The client did not try an exchange:

  • The job lacks id-token: write. For a reusable workflow, check the calling job.

  • The run is for a pull request from a fork.

  • The tests run in a container that was not passed ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN.

  • The plugin or CLI is not on its latest release.

The CLI prints no Mergify credential found instead. In a job with no ID token, the scopes upload of mergifyio/gha-mergify-ci warns Mergify token is not set, scopes will not be sent to Mergify API.

Mergify could not exchange the job's ID token. Mergify could not be reached, or could not fetch GitHub’s signing keys. The tests run, but this run’s results are not uploaded and no test is quarantined in it. Run the job again.

could not get the job's ID token: HTTP <status>. GitHub did not sign an ID token for the job, with the same effect. Run the job again.

This Mergify CI token was issued to a job that runs on <ref>. A token from a job’s ID token tried to send scopes for a commit that job does not test. See Jobs that keep a key.

Was this page helpful?