Credentials for GitHub Actions¶
How a GitHub Actions workflow gets a kubelatch credential without storing any secret in the repository.
How it works¶
GitHub Actions can issue an OIDC id_token signed by GitHub, with the repository, the branch, the run_id and who triggered the job inside it. The action picaportelabs/kubelatch-action asks GitHub for it, sends it to kubelatch, and kubelatch exchanges it for a bot credential, complete with its kubeconfig. There's no secret to store in GitHub or to rotate.
The credential lasts 1 hour by default, enough for a job; an administrator can change that with KUBELATCH_CI_TOKEN_TTL. A job that runs longer uses the action again.
sequenceDiagram
participant W as Workflow
participant G as GitHub
participant K as kubelatch
W->>G: the action requests an id_token (audience = url)
G-->>W: signed id_token
W->>K: POST /v1/ci/github-actions/token {token}
K->>K: checks the token and looks up the trust rule
K-->>W: bot credential + kubeconfig (once)
W->>K: kubectl through the proxy
Before you start¶
An administrator has to have prepared this beforehand (CI: trust rules):
- A registered bot, with at least one permission granted on the cluster you need.
- A trust rule that matches your repository (by its numeric ids, not by name) to that bot, optionally restricted to a branch (
ref) or anenvironment. - Your kubelatch's URL, reachable from the runner over https, with a certificate it trusts (a kubelatch on a laptop or behind a private network won't work with GitHub-hosted runners).
The workflow¶
-
Create
.github/workflows/kubelatch.ymlin your repository with this content. It's the example that ships in the kubelatch repository, without the header comments. An administrator can also give you the Workflow snippet, which already carries your kubelatch's URL: they get it filled in for your rule right after creating it (Connect the workflow), or from Example workflow on the CI screen.name: kubectl via kubelatch on: push: branches: [main] workflow_dispatch: permissions: id-token: write # required: lets the job request the OIDC token contents: read jobs: pods: runs-on: ubuntu-latest # environment: prod # uncomment if the trust rule sets an environment steps: - uses: picaportelabs/kubelatch-action@v1 with: url: https://kubelatch.example.com # exactly KUBELATCH_BASE_URL: it is the token's audience - name: kubectl through kubelatch run: | kubectl auth whoami # bot:<name> with the kubelatch:* groups of its grants kubectl get pods -n default -
Replace
urlwith your kubelatch's URL. It has to be the server'sKUBELATCH_BASE_URL, with the same scheme, host, port and path: it's the audience kubelatch requires in the token. A trailing slash doesn't matter. - If the trust rule sets an
environment, uncomment theenvironment:line and put its name. Without it, theid_tokenwon't carry the claim the trust rule expects, and the exchange answers403. - Adjust the last step to whatever you need to do on the cluster. Every later step of the job uses the credential:
kubectl,helmand any tool that readsKUBECONFIG.
What the action does¶
permissions:id-token: writeis required. Without it, the job can't request theid_token.- It asks GitHub for an
id_tokenwithurlas its audience, and masks it in the log. That request to GitHub fails now and then, so the action tries it up to three times. - It exchanges the token at
POST /v1/ci/github-actions/token, once: kubelatch never accepts the same token twice. A201returns the credential and its kubeconfig, once. - It masks the credential before printing anything else, writes the kubeconfig to a directory of its own under
$RUNNER_TEMP, readable by the job's user alone on Linux and macOS runners (never to the runner's~/.kube/config), and pointsKUBECONFIGat it.
Its outputs are kubeconfig (the file's path), expires-at and clusters (the ids of the clusters the credential reaches, as a JSON array). Give the step an id, such as id: kubelatch, to read them as ${{ steps.kubelatch.outputs.expires-at }}.
The action has no dependencies: two short files of JavaScript, src/main.js and src/action.js, which you can read in its repository before you trust it. If your organisation's policy asks for it, pin it by commit (picaportelabs/kubelatch-action@<sha>) instead of @v1.
On a self-hosted runner, the action trusts the system's certificate store, where a private CA usually lives, as curl does, plus the file in NODE_EXTRA_CA_CERTS if the job sets it. It goes through HTTPS_PROXY (kubelatch and GitHub are both https) unless NO_PROXY leaves kubelatch out.
Check that it works¶
Trigger the workflow (workflow_dispatch or a push to the configured branch) and confirm:
- The job prints
bot:<bot-name>inkubectl auth whoami. - The credential shows up in Credentials (for an administrator) with the name
github-actions, marked, for example, CI token · run 1234 · octocat; its details carry the noteowner/repo@refs/heads/main · run <id> · <actor>. - The requests show up in the audit log with that bot as the subject.
Re-running the job requests a new id_token (each token has its own jti, so a captured token can't be used twice).
If something fails¶
The action's error names the cause and links here.
this job cannot ask GitHub for an OIDC token: the workflow or the job lackspermissions: id-token: write.GitHub answered the OIDC token request witha status: GitHub could not issue the token, even after three tries. Run the job again; if it keeps failing, check GitHub's status.url must be https: kubelatch answers with a credential, so the action refuses plain http outside the runner's own machine. Serve kubelatch over https (Install).401 invalid token: theid_tokendoesn't pass verification (signature, issuer, an audience different fromKUBELATCH_BASE_URL, expired, or already exchanged before). Check thaturlis exactly the server'sKUBELATCH_BASE_URL; without the action, also check that you're not reusing a token from a previous job.403: no trust rule matches this workflow …: the message carries the token'srepository_owner_id,repository_id,refandenvironment. Ask an administrator to create or adjust the trust rule with those values, or to removeref/environmentto make it generic.403naming the bot: the bot is disabled or has no active permissions on any cluster.404, oranswered 201 without a credential:urlis not your kubelatch's address.kubelatch answered 429: more than 60 exchanges a minute came from the runner's address. Run the job again in a minute.could not reach kubelatch(without the action,curl: (7)or(35)): missing network reachability over https, or the runner doesn't trust the certificate. With a private CA, add it to the runner's system store or pointNODE_EXTRA_CA_CERTSat it in the job'senv; otherwise it's a deployment problem, not the workflow's: ask an administrator to check the Install guide.
The full list of errors is in Errors.
Without the action¶
If your organisation allows only its own actions, these two steps in bash do the same with curl and jq, which GitHub's hosted runners carry. KUBELATCH_URL is your kubelatch's URL, matching letter for letter the server's KUBELATCH_BASE_URL.
name: kubectl via kubelatch
on:
push:
branches: [main]
workflow_dispatch:
permissions:
id-token: write # required: lets the job request the OIDC token
contents: read
env:
KUBELATCH_URL: https://kubelatch.example.com # exactly KUBELATCH_BASE_URL: it is the token's audience
jobs:
pods:
runs-on: ubuntu-latest
# environment: prod # uncomment if the trust rule sets an environment
steps:
- name: Request the OIDC token for kubelatch
run: |
ID_TOKEN=$(curl -sSf -G \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
--data-urlencode "audience=$KUBELATCH_URL" \
"$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r .value)
echo "::add-mask::$ID_TOKEN"
echo "ID_TOKEN=$ID_TOKEN" >> "$GITHUB_ENV"
- name: Exchange it for a kubelatch credential
run: |
RESPONSE=$(curl -sS -w '\n%{http_code}' -H 'Content-Type: application/json' \
-d "$(jq -n --arg token "$ID_TOKEN" '{token:$token}')" "$KUBELATCH_URL/v1/ci/github-actions/token")
STATUS=$(printf '%s' "$RESPONSE" | tail -n1)
BODY=$(printf '%s' "$RESPONSE" | sed '$d')
if [ "$STATUS" != "201" ]; then
echo "kubelatch answered $STATUS: $(printf '%s' "$BODY" | jq -r '.error // .')" >&2
exit 1
fi
# The token and kubeconfig are shown exactly once: keep them out of the log.
printf '%s' "$BODY" | jq -r .token | xargs -I{} echo "::add-mask::{}"
# Only this job's user may read it; never ~/.kube/config.
(umask 077 && printf '%s' "$BODY" | jq -r .kubeconfig > "$RUNNER_TEMP/kubeconfig")
chmod 600 "$RUNNER_TEMP/kubeconfig"
echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
echo "credential expires at $(printf '%s' "$BODY" | jq -r .expires_at)"
- name: kubectl through kubelatch
run: |
kubectl auth whoami # bot:<name> with the kubelatch:* groups of its grants
kubectl get pods -n default
- Request the OIDC token: asks GitHub for an
id_tokenwith audienceKUBELATCH_URLand masks it in the log. - Exchange it: exchanges it at
POST /v1/ci/github-actions/token. A201returnscredential,token,kubeconfig,clustersandexpires_at, once. The step masks the token and saves the kubeconfig to$RUNNER_TEMP, never to the runner's~/.kube/config.