Create GitHub App Token Buildkite Plugin
A Buildkite plugin that creates GitHub App installation access tokens for authenticating with GitHub APIs in your Buildkite pipelines.
This plugin is inspired by and provides similar functionality to the actions/create-github-app-token GitHub Action.
Example
Generate a GitHub App token and use it to authenticate with GitHub:
steps:
- label: "Generate GitHub App Token"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
command: |
# Token is now available in GITHUB_TOKEN environment variable
curl -H "Authorization: Bearer \$GITHUB_TOKEN" https://api.github.com/user
Configuration
Required
app-id (string)
The GitHub App ID or a reference to an environment variable containing the App ID. You can find the App ID in your GitHub App’s settings page.
- Direct value:
"123456" - Environment variable reference:
"$$GITHUB_APP_ID"(recommended for security)
Note: Use
$$to prevent Buildkite from interpolating the variable. The plugin will handle the environment variable expansion internally.
private-key (string)
The GitHub App’s private key in PEM format or a reference to an environment variable containing the private key. This should be stored securely using Buildkite secrets.
- Environment variable reference:
"$$GITHUB_APP_PRIVATE_KEY"(recommended) - Direct value: Not recommended for security reasons
Note: Use
$$to prevent Buildkite from interpolating the variable. The plugin will handle the environment variable expansion internally.
Optional
owner (string)
Repository owner. If not specified, defaults to the current repository owner extracted from BUILDKITE_REPO.
Example: "octocat"
repositories (string)
Comma or newline-separated list of repository names to scope the token access. If not specified, the token will have access to all repositories the GitHub App is installed on.
Example: "repo1,repo2" or:
repositories: |
repo1
repo2
skip-token-revoke (string)
Skip automatic token revocation during cleanup. Set to "true" to skip revocation.
Default: "false"
github-api-url (string)
GitHub REST API URL. Useful for GitHub Enterprise Server instances.
Default: "https://api.github.com"
Example: "https://github.example.com/api/v3"
output-variable (string)
The name of the environment variable to store the generated token. This is a Buildkite-specific parameter.
Default: GITHUB_TOKEN
Example: "CUSTOM_TOKEN_VAR"
max-attempts (integer)
How many times each GitHub API request is attempted before the step fails. Only transient failures are retried; see Retries.
Default: 5
max-retry-wait (integer)
The most seconds one request may spend waiting between attempts. A retry that
would take the total past this limit, such as a long Retry-After, fails the
step instead.
Default: 120
Permission parameters
Each permission-* option sets one permission on the minted token, to read or
write (a few, such as permission-workflows, only accept write). The option
name maps to the API permission of the same name with dashes replaced by
underscores, so permission-pull-requests requests pull_requests.
The list mirrors the inputs of
actions/create-github-app-token,
which generates it from GitHub’s OpenAPI description of the
create installation access token
endpoint.
When no permission-* option is set, the token inherits every permission the app
installation has. Setting even one narrows the token to exactly what you list, so
list everything the step needs.
[!IMPORTANT]
permission-actionsandpermission-workflowsare different permissions.actionscovers workflow runs and artifacts;workflowsis what lets a token create or update files under.github/workflows/. A bot that opens pull requests touching workflow files — Renovate, for instance — needspermission-workflows: write, and GitHub rejects the push with arefusing to allow a GitHub App to create or update workflowerror without it.
Repository permissions
permission-actionspermission-administrationpermission-artifact-metadatapermission-attestationspermission-checkspermission-codespacespermission-contentspermission-dependabot-secretspermission-deploymentspermission-discussionspermission-environmentspermission-issuespermission-merge-queuespermission-metadatapermission-packagespermission-pagespermission-pull-requestspermission-repository-custom-propertiespermission-repository-hookspermission-repository-projectspermission-secret-scanning-alertspermission-secretspermission-security-eventspermission-single-filepermission-statusespermission-vulnerability-alertspermission-workflows
Organization and user permissions
permission-custom-properties-for-organizationspermission-email-addressespermission-enterprise-custom-properties-for-organizationspermission-followerspermission-git-ssh-keyspermission-gpg-keyspermission-interaction-limitspermission-memberspermission-organization-administrationpermission-organization-announcement-bannerspermission-organization-copilot-seat-managementpermission-organization-custom-org-rolespermission-organization-custom-propertiespermission-organization-custom-rolespermission-organization-eventspermission-organization-hookspermission-organization-packagespermission-organization-personal-access-token-requestspermission-organization-personal-access-tokenspermission-organization-planpermission-organization-projectspermission-organization-secretspermission-organization-self-hosted-runnerspermission-organization-user-blockingpermission-profilepermission-starringpermission-team-discussions
Deprecated
permission-organization-members— this plugin’s original name forpermission-members. Still honoured, and still mapped to themembersAPI permission, but it logs a warning; preferpermission-members.
Usage Examples
Basic Usage
steps:
- label: "Use GitHub App Token"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
command: |
# Use the token to make authenticated requests
gh api user
With Permissions and Repository Scope
steps:
- label: "Scoped GitHub App Token"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
repositories: "repo1,repo2"
permission-contents: "read"
permission-issues: "write"
permission-pull-requests: "write"
command: |
# Token has limited scope and permissions
gh api repos/owner/repo1/contents/README.md
With Organization Members Permission
steps:
- label: "Organization Members Access"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
permission-members: "read"
command: |
# Token can access organization member information
gh api orgs/myorg/members
For a Bot That Updates Workflow Files
Editing .github/workflows/** needs permission-workflows, which is separate
from permission-actions:
steps:
- label: ":robot_face: renovate"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$RENOVATE_APP_ID"
private-key: "$$RENOVATE_APP_PRIVATE_KEY"
permission-contents: "write"
permission-pull-requests: "write"
permission-issues: "write"
permission-workflows: "write"
- altana-ai/renovate#v0.1.1: ~
GitHub Enterprise Server
steps:
- label: "Enterprise GitHub App Token"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
github-api-url: "https://github.company.com/api/v3"
command: |
# Works with GitHub Enterprise Server
gh api user
Skip Token Revocation
steps:
- label: "Long-lived Token"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
skip-token-revoke: "true"
command: |
# Token won't be revoked automatically
echo "Token will remain active until expiration"
Custom Output Variable
steps:
- label: "Custom Token Variable"
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
output-variable: "MY_GITHUB_TOKEN"
command: |
# Token is available in MY_GITHUB_TOKEN
curl -H "Authorization: Bearer \$MY_GITHUB_TOKEN" https://api.github.com/repos/owner/repo
Using Buildkite Secrets (Recommended)
Map Buildkite secrets
to the environment variables this plugin reads with the secrets step attribute.
It needs agent 3.106.0 or later, secrets are scoped to the agent’s cluster, and
their values are redacted from build logs automatically:
steps:
- label: "Generate Token with Secrets"
secrets:
GITHUB_APP_ID: my_github_app_id
GITHUB_APP_PRIVATE_KEY: my_github_app_private_key
plugins:
- altana-ai/create-github-app-token#v0.6.0:
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
command: |
gh api user
The $$ stops Buildkite interpolating the variable when the pipeline is
uploaded, so the plugin resolves it on the agent, where the secret exists.
Restricting Who Can Read the Private Key
The private key is long-lived, and anything that can read it can mint tokens for every repository the app is installed on. A Buildkite secret is readable by every agent in its cluster by default, so attach an access policy to it — the cluster’s secret → Access tab → Restrict access to agents matching a policy:
# Only this pipeline, and only when Buildkite itself started the build.
- pipeline_id:
- "01234567-89ab-cdef-0123-456789abcdef"
build_source:
- "schedule"
- "ui"
Claims within a rule are ANDed, values within a claim are ORed, rules are ORed,
and access is denied when nothing matches. Prefer the first-party claims
(pipeline_id, build_source, cluster_queue_id), which Buildkite generates;
the third-party ones (pipeline_slug, build_branch, build_creator) are
convenient but derive from user- or tool-supplied values, so they make a weaker
boundary.
Restricting build_source is what stops a pull request against the pipeline’s own
repository from reading the key — an untrusted branch cannot mint a token even if
it rewrites the pipeline.
Security Features
- Token Redaction: The generated token is automatically added to Buildkite’s CLI redactor to prevent accidental logging
- Automatic Token Revocation: Tokens are automatically revoked after command execution (unless
skip-token-revokeis set totrue) - Environment Cleanup: Tokens are removed from the environment after command execution
- Temporary Key Storage: Private keys are stored in temporary files that are immediately cleaned up
- Scoped Permissions: Use permission parameters to limit token access to only what’s needed
- Repository Scoping: Use the
repositoriesparameter to limit token access to specific repositories
How It Works
- Environment Variable Expansion: Resolves environment variable references (e.g.,
$GITHUB_APP_ID) to their actual values - JWT Generation: Creates a JSON Web Token (JWT) using your GitHub App ID and private key
- Installation Discovery: Finds the GitHub App installation for the current repository
- Token Generation: Uses the JWT to request an installation access token from GitHub’s API
- Security Setup: Adds the token to Buildkite’s redactor and exports it as an environment variable
- Cleanup: Removes the token from the environment after command execution
Retries
The installation lookup and the token request each retry transient failures:
- curl transport errors: exits 5, 6, 7, 16, 18, 28, 35, 52, 55, 56 and 92, which
cover DNS, connection, HTTP/2, partial transfers, timeouts, the TLS handshake
and dropped connections. Some of these, such as an unresolvable host (6), a
refused connection (7) or a failed TLS handshake (35), can also mean a
misconfigured
github-api-urlor proxy, which no retry fixes. They are retried anyway, because DNS failures and refused connections are common during GitHub incidents. Other exits, such as a malformed URL (3) or a certificate that fails verification (60), are configuration errors and fail at once. - HTTP 429 and 5xx
- HTTP 403 when it is a rate limit: a
Retry-Afterheader,x-ratelimit-remaining: 0, or a body that names a secondary rate limit (or, in older GitHub and GHES wording, an abuse detection mechanism)
When a status arrives before the connection fails, the status decides: a 404 followed by a timeout is treated as a 404 and fails at once.
The wait before the next attempt is chosen in this order:
- the server’s
Retry-After - the time until
x-ratelimit-reset, when a rate-limit response says the limit is exhausted (a 5xx carrying these headers gets ordinary backoff) - 60s, for a rate limit that gives neither, as GitHub asks, doubled for each consecutive rate limit (60s, 120s, …)
- exponential backoff with jitter: 2-3s, 4-7s, 8-15s, then 16-30s for every later attempt
A wait is never shorter than that attempt’s backoff step (2s, 4s, 8s, then 16s),
so a Retry-After: 0 or a reset already in the past still backs off. The first
three get up to 25% extra jitter, and at least 1s, so parallel jobs told to wait
the same time do not all retry at once. The budget below is checked against the
wait before jitter, and jitter never takes the total past it, so a hint that
fits the budget is always honoured.
max-retry-wait caps the total wait for one request. The default of 120s fits
the whole backoff (at most 55s over five attempts), and also fits one
secondary-rate-limit wait (at most 75s) after the backoff that comes before it,
so with the defaults a request waits at most two minutes. A second consecutive
secondary rate limit (120-150s) does not fit and fails the step. Each attempt
beyond five can add up to 30s, so raise max-retry-wait along with
max-attempts.
Every request, including the revocation in post-command, has a 10s connect
timeout and a 30s overall timeout. The JWT is signed once and signed again only
when less than 2 minutes of its 10-minute lifetime remain, so no attempt sends
an expired one.
Anything else, such as 401, 404 or 422, fails at once. Each retried attempt
logs its attempt number, the HTTP status or curl exit code (with any status
received before the connection failed), and the response body. The final
failure is logged once, with its body. Anything token-shaped in a logged body
("token": "...", gho_, ghp_, ghr_, ghs_, ghu_ and github_pat_
values) is masked, and the JWT and the minted token are never logged. After a
transport error the body is not logged at all, because a token response cut off
mid-transfer may still hold the token. A token from any 2xx response is
accepted and added to the redactor before anything else is logged.
[!NOTE] Creating a token is not idempotent. When the token request fails after GitHub has minted a token, either with a 5xx or with a connection dropped mid-response (logged as, for example,
curl exit 56 after HTTP 201), the retry mints a second one. The first token is never used or revoked, even when part of it arrived in the cut-off response. It keeps the scope the step requested and expires within an hour.
Environment Variable Expansion
The plugin supports referencing environment variables using the $VARIABLE_NAME syntax for the app-id and private-key parameters. This allows you to keep sensitive values out of your pipeline configuration:
# In your pipeline YAML (secure - no secrets in config)
app-id: "$$GITHUB_APP_ID"
private-key: "$$GITHUB_APP_PRIVATE_KEY"
The plugin will automatically expand $GITHUB_APP_ID to the value of the GITHUB_APP_ID environment variable. Use the double-dollar ($$) syntax in Buildkite to prevent Buildkite from interpolating the variables before the plugin receives them.
Requirements
openssl- Used for JWT signature generationcurl- Used for GitHub API requestsjq- Used for JSON parsingbuildkite-agent- Required for token redaction- Repository must have the GitHub App installed
Troubleshooting
Error: Unable to parse repository from BUILDKITE_REPO
This error occurs when the plugin cannot determine the GitHub repository from the BUILDKITE_REPO environment variable. Ensure your repository URL is in one of these formats:
git@github.com:owner/repo.githttps://github.com/owner/repo.git
Error: GitHub App is not installed
If you see the error “GitHub App is not installed on owner/repository”, the plugin has detected that your GitHub App is not installed on the target repository or organization. Follow the provided instructions:
- Go to your GitHub App settings
- Click ‘Install App’ in the left sidebar
- Select the organization/account
- Choose to install on selected repositories or all repositories
This error replaces the generic “Failed to get installation ID” message and provides clear next steps.
Error: Failed to generate installation token
The message names the HTTP status, or the curl exit code when the connection
failed, with any status received before it (for example curl exit 18 after HTTP 503); the attempt log above it shows each retried failure. A transient failure that outlasted the
retries reads still failing after N attempts. Otherwise this typically
indicates:
- Invalid private key format
- Clock skew (JWT timestamps are invalid)
- Insufficient GitHub App permissions
Development
To contribute to this plugin:
- Clone the repository
- Make your changes
- Run the tests (below) and, for agent-side behaviour, a Buildkite pipeline
- Submit a pull request
Tests
The hooks are covered by bats tests
that stub curl and buildkite-agent, so they never reach the GitHub API:
bats tests
tests/revocation.bats drives pre-command and post-command the way
buildkite-agent does — the hook body is sourced, then the next hook runs as a
child process — because only exported variables survive that boundary. Any
hand-off between the two hooks has to be tested through it to be meaningful.
shellcheck hooks/* tests/test_helper.bash tests/stubs/* must also be clean.
Both run in CI on every pull request.