Mints GitHub App installation access tokens and writes one file per account.
It does that and nothing else. It does not configure git, Composer, Go modules or anything else — the caller applies the token wherever it needs it. Earlier versions of this script grew per-language outputs and were forked as a result.
Why an App token rather than a personal access token: GitHub Apps are exempt from SAML SSO enforcement, while personal access tokens, OAuth grants and user SSH keys are not. Under an enterprise with SAML enforced, a PAT stops working the moment its SSO authorisation lapses, and takes every build with it.
ghcr.io/o8bit/gh-app-token
The image and this repository are both public — no authentication is needed to pull the image or to use the action. That is deliberate: this tool produces GitHub credentials, so it must not require one to fetch.
The image takes no arguments. Everything is configured by environment
variables, and the entrypoint is the tool itself, so a bare docker run is the
whole invocation:
docker run --rm \
-v "$PWD/tokens:/tokens" \
-e GITHUB_APP_PRIVATE_KEY="$(cat app.private-key.pem)" \
-e GITHUB_APP_ID=123456 \
-e GITHUB_APP_ORGS=YOUR_ORG \
-e GITHUB_APP_TOKEN_DIR=/tokens \
ghcr.io/o8bit/gh-app-token:1That writes ./tokens/YOUR_ORG.token.
| Variable | Required | Meaning |
|---|---|---|
GITHUB_APP_PRIVATE_KEY |
yes | The App private key, as a raw PEM or base64-encoded PEM |
GITHUB_APP_ID |
yes | The App's id. Not a secret |
GITHUB_APP_ORGS |
yes | Comma-separated account names, e.g. org-a,org-b. Whitespace after commas is fine |
GITHUB_APP_TOKEN_DIR |
yes | Directory for the token files. Created at mode 0700 if absent |
GITHUB_APP_PERMISSIONS |
no | JSON permission set to request. Default {"contents":"read"} |
Base64 is accepted for the key because some CI systems mangle multi-line variable values. Either form works; the tool detects which it was given.
One file per account, at $GITHUB_APP_TOKEN_DIR/<account>.token, mode 0600,
containing only the token. Files are created and permission-set before the
token is written, so they are never briefly world-readable with a secret in them.
On success it prints one line per account to stdout:
gh-app-token: YOUR_ORG: installation 12345678, expires 2026-08-07T12:34:56Z, token written to /tokens/YOUR_ORG.token
The token itself is never printed — not on success, not in any error, not in any diagnostic. Don't add one.
Exits 0 on success, 1 on any failure, 130 if interrupted. Every failure
prints a gh-app-token: prefixed message to stderr naming the account it
concerns. Failures stop the run immediately — if the first of three accounts
fails, the other two are not minted.
Common ones:
| Message | Cause |
|---|---|
... is not a valid RSA private key |
key missing, truncated, or not RSA |
app N is not installed on 'X' (tried both org and user) |
the App isn't installed on that account |
token mint for 'X' failed (HTTP 422) |
you asked for a permission the installation doesn't grant |
the installation for 'X' has no accessible repositories |
installed, but with access to nothing |
The App must be installed on every account you name in GITHUB_APP_ORGS,
and must be granted the permissions you request. Installation is per account: an
App installs at most once per org or user, so the account name identifies the
installation unambiguously.
You do not supply installation ids. The tool resolves each name via
GET /orgs/{name}/installation, falling back to GET /users/{name}/installation
so Apps installed on personal accounts work too. Names are used rather than ids
because reinstalling an App changes its installation id but not the account name
— ids silently break every consumer, names don't.
GITHUB_APP_PERMISSIONS narrows the minted token to the subset you actually
need, and applies to every account in the list:
-e GITHUB_APP_PERMISSIONS='{"contents":"read"}' # default
-e GITHUB_APP_PERMISSIONS='{"contents":"read","pull_requests":"read"}'
-e GITHUB_APP_PERMISSIONS='{"actions":"write"}' # dispatch a workflowYou can only narrow, never widen. Requesting a permission the installation was not granted fails with HTTP 422 — it does not quietly return a lesser token. If you need different permissions per account, run the tool twice.
A token is scoped to one installation, so private repositories in three orgs need three tokens:
-e GITHUB_APP_ORGS='org-a,org-b,org-c'writes org-a.token, org-b.token and org-c.token.
availableSecrets:
secretManager:
- versionName: projects/YOUR_PROJECT/secrets/YOUR_SECRET/versions/latest
env: 'GITHUB_APP_PRIVATE_KEY'
steps:
- id: 'gh-token'
name: 'ghcr.io/o8bit/gh-app-token:1'
env:
- 'GITHUB_APP_ID=123456'
- 'GITHUB_APP_ORGS=YOUR_ORG'
- 'GITHUB_APP_TOKEN_DIR=/workspace/.gh-tokens'
secretEnv: [ 'GITHUB_APP_PRIVATE_KEY' ]
waitFor: [ '-' ]Then consume it in a later step. Note the $$ — Cloud Build runs its own
substitution pass before the shell, so a single $ is consumed there and you
would silently get an empty value:
- id: 'use-it'
name: 'alpine'
entrypoint: 'sh'
args:
- '-c'
- |
GITHUB_TOKEN="$$(cat /workspace/.gh-tokens/YOUR_ORG.token)"
export GITHUB_TOKEN
# ...Each Cloud Build step is a fresh container and only /workspace persists
between them, which is why the token directory goes there. The corollary: a
token in /workspace is readable by every later step in the build. Where a
token is only needed inside one step, mint it in that step and write it
somewhere that does not outlive it.
Use it as a composite action rather than the image:
- uses: o8bit/gh-app-token@v1
id: gh-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
orgs: org-a,org-b
- name: Fetch private modules
env:
TOKEN_DIR: ${{ steps.gh-token.outputs.token-dir }}
run: |
git config --global \
"url.https://x-access-token:$(cat "$TOKEN_DIR/org-a.token")@github.com/org-a/.insteadOf" \
"https://github.com/org-a/"app-id takes the App's id or its client id — the value is only the JWT
issuer, and GitHub accepts either. permissions and token-dir are optional;
token-dir defaults to a directory under RUNNER_TEMP, outside the workspace.
The action runs the script directly rather than pulling the image. Runners
already have curl, jq, openssl and base64, so there is nothing to
install, nothing to pull, and no container writing root-owned 0600 files that
the runner user then cannot read.
Every minted token is masked with ::add-mask:: before the action returns.
Action outputs are masked for you; a token read out of a file is not.
actions/create-github-app-token
is the obvious alternative, and is simpler if you need one org. It mints per
owner, so three orgs means three invocations and three separate rewrites — that
case is what this action is for.
Applying a token is the caller's job — this tool only mints. Which method you want depends on how many accounts you need and what is consuming the token.
| Consumer | One account | Several accounts |
|---|---|---|
| git, Go modules, submodules | .netrc |
insteadOf per account |
| npm, dependency from a git URL | .netrc |
insteadOf per account |
| Composer | auth.json http-basic |
auth.json plus git insteadOf |
| npm, package from GitHub Packages | .npmrc _authToken |
one token per registry host |
The split is not per tool, it is per fetch path. Anything that resolves through
git — Go modules, npm dependencies given as github:org/repo or a git+https
URL, submodules — inherits whatever git is configured with and needs no
mechanism of its own. Anything with its own registry client — Composer, or npm
against GitHub Packages — authenticates from its own file and ignores git's
configuration entirely.
Keeps the token out of git config entirely, so it cannot reach a log through
git config --list, a printed remote, or a gitconfig kept as a build artefact:
: >~/.netrc && chmod 600 ~/.netrc # create and restrict, then write
printf 'machine github.com login x-access-token password %s\n' \
"$(cat /tokens/org-a.token)" >~/.netrcWrite it, do not append: a second >> leaves two machine github.com lines and
the stale one wins, so a build silently keeps using an expired token.
If your manifests use git@github.com: URLs, you still need a rewrite — but it
carries no secret, so it is safe to log:
git config --global url."https://github.com/org-a/".insteadOf "git@github.com:org-a/".netrc matches on host, and every account is github.com, so it can hold
only one token. Installation tokens are per account, so anything spanning
accounts has to use a rewrite:
for org in org-a org-b; do
git config --global \
"url.https://x-access-token:$(cat /tokens/$org.token)@github.com/$org/.insteadOf" \
"https://github.com/$org/"
doneUse x-access-token:<token>. GitHub rejects <token>:x-oauth-basic for
installation tokens with "Password authentication is not supported for Git
operations" — which matters because that is the form some tools build
internally.
Composer does not read .netrc. Its HTTP client authenticates from
auth.json (or COMPOSER_AUTH) only, so netrc is not an option here even for a
single account — without auth.json Composer hits the GitHub API
unauthenticated and falls back to cloning.
Use http-basic, not github-oauth:
{"http-basic":{"github.com":{"username":"x-access-token","password":"TOKEN"}}}Composer validates github-oauth values against {^[.A-Za-z0-9_]+$}, which
rejects hyphens — and installation tokens contain them. Worse, the exception
prints the rejected token into the build log. http-basic has no such
validation and works on every Composer version.
auth.json also holds one credential per host, so it serves one account. A
project with private dependencies in several accounts needs git insteadOf
rules alongside it.
Whichever method you pick, the token is an argument to the command that writes
it. Any shell tracing — set -x, or a CI that echoes commands before running
them — prints that command, token included. Turn tracing off around the step
rather than assuming the method protects you.
curl does not read .netrc unless you pass --netrc, so API calls need the
token given to them explicitly. gh reads GITHUB_TOKEN from the environment
and ignores both methods.
Do not assume a token's length, character set or structure anywhere — not in a validation regex, not in a log-scrubbing pattern, not in a database column width.
GitHub issues installation tokens in two forms and is
rolling between them:
a short opaque string with no dots, and a longer ghs_-prefixed JWT of ~520
characters containing dots and often hyphens. Which one you get can vary between
calls. GitHub's own recommended matcher is ghs_[A-Za-z0-9\.\-_]{36,}.
Tokens expire one hour after minting.
sh gh-app-token-test.shThe suite ships inside the image, so you can run it against the exact artefact you pull:
docker run --rm --entrypoint sh ghcr.io/o8bit/gh-app-token:1 \
/usr/local/bin/gh-app-token-test.shIt is offline — no network, no credentials — and covers base64url encoding, key handling in both accepted forms, JWT construction and signature, account-list parsing and output paths.
Releases are semver, cut by pushing a vX.Y.Z git tag. Each release publishes
four tags:
| Tag | Moves? | Use when |
|---|---|---|
1.2.3 |
never | you want the exact image, forever |
1.2 |
picks up patches | the usual choice |
1 |
picks up features and patches, never breaking changes | you want fixes without touching config |
latest |
anything, including breaking changes | never, in a build |
Breaking changes to the environment interface or output layout go in a new
major: 2.0.0, reachable as :2. A consumer pinned to :1 will not be moved
onto it.
Pushes to main and pull requests build and test but publish nothing, so a
release stays a deliberate act. Testing and publishing are separate jobs: only
the publish job, which runs on a version tag, holds the credentials that can push
an image or move a tag.
A release also moves the v<major> git tag, because composite-Action
consumers pin a git ref (@v1) rather than a docker tag. Docker 1.2.3 and git
v1.2.3 are immutable; docker 1, 1.2, latest and git v1 all move.