Skip to content

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 an environment.
  • 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

  1. Create .github/workflows/kubelatch.yml in 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
    
  2. Replace url with your kubelatch's URL. It has to be the server's KUBELATCH_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.

  3. If the trust rule sets an environment, uncomment the environment: line and put its name. Without it, the id_token won't carry the claim the trust rule expects, and the exchange answers 403.
  4. Adjust the last step to whatever you need to do on the cluster. Every later step of the job uses the credential: kubectl, helm and any tool that reads KUBECONFIG.

What the action does

  • permissions: id-token: write is required. Without it, the job can't request the id_token.
  • It asks GitHub for an id_token with url as 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. A 201 returns 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 points KUBECONFIG at 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> in kubectl 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 note owner/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 lacks permissions: id-token: write.
  • GitHub answered the OIDC token request with a 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: the id_token doesn't pass verification (signature, issuer, an audience different from KUBELATCH_BASE_URL, expired, or already exchanged before). Check that url is exactly the server's KUBELATCH_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's repository_owner_id, repository_id, ref and environment. Ask an administrator to create or adjust the trust rule with those values, or to remove ref/environment to make it generic.
  • 403 naming the bot: the bot is disabled or has no active permissions on any cluster.
  • 404, or answered 201 without a credential: url is 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 point NODE_EXTRA_CA_CERTS at it in the job's env; 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_token with audience KUBELATCH_URL and masks it in the log.
  • Exchange it: exchanges it at POST /v1/ci/github-actions/token. A 201 returns credential, token, kubeconfig, clusters and expires_at, once. The step masks the token and saves the kubeconfig to $RUNNER_TEMP, never to the runner's ~/.kube/config.