Docs
Guidesince cloud@2026-09-10

GitHub Actions

Your workflow proves who it is with a token the runner mints, then runs one step with a credential that expires in minutes.

Not verified yet

This page is an outline. Nothing here has been confirmed against source, so do not follow it as product behavior.

Your CI logs in like a person does, with no key to steal. There is no long-lived value in your repository settings. The runner asks GitHub for a signed token, Penv Cloud checks it against a trust you set up once, and hands back a credential that expires in minutes.

Set up the trust once

Open Machine Identities then Connect a Platform in the console and pick GitHub Actions. GitHub gets its own dialog and no form: the subject is read from the GitHub API rather than assembled from anything you type. Two subject formats are live, and repositories created or renamed since July 2026 embed numeric ids, so a typed subject would be a plausible string that silently never matches.

Three things hold for every trust:

  • The audience is your workspace id, so a token minted for one workspace cannot authenticate at another.
  • Subjects are matched exactly. There are no wildcards.
  • The trust stores no secret. It is a statement that a token from one issuer, addressed to this workspace, carrying one exact subject, is a given identity.

Path one: run penv in the workflow

.github/workflows/deploy.yml
permissions:
  id-token: write          # nothing is issued without this, and it does not inherit

steps:
  - name: Install penv
    run: |
      curl -fsSL https://penv.cloud/install | sh
      echo "$HOME/.penv/bin" >> "$GITHUB_PATH"

  - name: Deploy
    run: |
      T=$(curl -sS \
        -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=YOUR_WORKSPACE_ID" | jq -r .value)
      C=$(curl -sS "https://penv.cloud/api/v1/auth/oidc" \
        -H 'content-type: application/json' \
        -d "{\"token\":\"$T\"}" | jq -r .credential)
      PENV_TOKEN="$C" penv run -- npm run deploy

The installer puts one static binary in $HOME/.penv/bin and edits no startup file, which is why the second line is what puts penv on PATH for the steps that follow. Both curl calls read their answer with jq, so a runner without it needs it installed first.

PENV_TOKEN takes precedence over the keychain, and a runner has no keychain anyway, so every run is online and nothing is cached. The credential the exchange returns lasts fifteen minutes, capped by the trust's own expiry.

Both tokens stay inside that one step and that one process. A token that reaches a step output or the job environment is a token every later step and every debug log can read.

For a host that needs a file rather than a process, PENV_TOKEN="$C" penv pull writes a plain .env in the build step.

Path two: the published action

The action has no uses: coordinate yet, so this section describes its inputs and its behavior and prints no reference line. Until it is published, use path one. This page stays needs-source until the coordinate exists.

The action does the same work without installing penv. It is a JavaScript action running on node24, with a main step and a post step that runs on always(), so a failed build gives the credential back the same way a passing one does. It reads no repository secrets and mints its token per run.

InputRequiredWhat it is
audienceyesYour workspace id, which is also the token's audience.
runyesThe command that receives the values. No other step does.
api-originnoDefaults to https://penv.cloud.
filesnoOne NAME=path/name per line, for a tool that cannot read a variable.

A files entry writes the value to a 0600 file under $RUNNER_TEMP, sets NAME to that path, and removes the file before the step ends, including when the command failed or threw. Use it only where a file is genuinely required.

The credential this path mints is bound to the run: the repository, the workflow ref, the workflow ref of the executing job, the run id, the run attempt, and the actor for audit alone. It lasts five minutes, which is shorter than the fifteen the plain exchange gives, because it is spent inside one step.

pull_request_target is refused outright. That trigger runs with the base repository's permissions against untrusted pull request code and is the one pull request event exempt from GitHub's fork permission downgrade. pull_request and workflow_run are allowed, because a fork cannot mint a token at all and a workflow_run definition comes from the default branch.

The action runs on Linux and macOS runners. It refuses Windows, where neither 0600 nor bash holds.

Reference the action by its full commit SHA. A tag can be moved to point at other code, which is how CVE-2025-30066 put a credential stealer into thousands of pipelines in March 2025.

Where the values go, and where they never go

Values reach your command through the child process environment and nowhere else. Every other channel is closed deliberately:

ChannelWhy it is closed
GITHUB_ENVA documented code execution primitive through LD_PRELOAD and NODE_OPTIONS, readable by every later step
Job-wide env:The same reach, more quietly
Step or job outputsRead by later steps, and a secret-tainted job output is dropped by the runner rather than masked
argvps x -w reads it across the whole job for as long as the process lives
GITHUB_WORKSPACEUploaded by the next artifact step and saved by the next cache step, by people who never knew it was there

What this does not promise

Four things are stated plainly, because your threat model depends on them.

Co-location in a job is disclosure. Against a malicious step in the same job, masking and step scoping are both irrelevant. CVE-2025-30066's payload read Runner.Worker process memory, lifted the runner's own secret table, and double-base64'd it past the log masker. This is not a gap being closed.

Revocation at the end of a job is best effort. A force-cancel through the REST API skips even always(), a killed runner runs no post:, and nothing survives SIGKILL. What bounds a delivered credential is its short lifetime.

Non-ephemeral self-hosted runners cannot be made safe. State persists between jobs, and --ephemeral only de-registers the runner.

A job that restores a cache should not carry a delivered value. Cache poisoning is a time-shifting primitive: the attacker's code runs in your job without them ever being in it.

Masking in the log is a courtesy. The runner covers base64 at all three alignments plus URL, JSON, XML and command-line encodings, and it covers neither hex nor case changes nor anything derived.

Do it in order

  1. Open Machine Identities then Connect a Platform and connect the repository.
  2. Note the workspace id the console shows. It is the audience.
  3. Add permissions: id-token: write to the job. It does not inherit.
  4. Add the install step, then the step that mints the token and runs penv run.
  5. Push and read the run log. A 401 unauthorized means the trust does not name this subject.
  6. Once the action is published, replace both steps with one reference pinned to a full commit SHA.

Machine identities