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
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
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 deployThe 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.
| Input | Required | What it is |
|---|---|---|
audience | yes | Your workspace id, which is also the token's audience. |
run | yes | The command that receives the values. No other step does. |
api-origin | no | Defaults to https://penv.cloud. |
files | no | One 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:
| Channel | Why it is closed |
|---|---|
GITHUB_ENV | A 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 outputs | Read by later steps, and a secret-tainted job output is dropped by the runner rather than masked |
argv | ps x -w reads it across the whole job for as long as the process lives |
GITHUB_WORKSPACE | Uploaded 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
- Open Machine Identities then Connect a Platform and connect the repository.
- Note the workspace id the console shows. It is the audience.
- Add
permissions: id-token: writeto the job. It does not inherit. - Add the install step, then the step that mints the token and runs
penv run. - Push and read the run log. A
401 unauthorizedmeans the trust does not name this subject. - Once the action is published, replace both steps with one reference pinned to a full commit SHA.