Connect your CI
Your pipeline proves who it is with a token its runner mints, then runs one step with a credential that lives fifteen minutes.
Your CI logs in like a person does, with no key to steal.
There is no long-lived key in your repository settings. Your runner asks its own platform for a signed token. penv.cloud checks that token against a trust you set up once and hands back a credential that lives fifteen minutes.
Set up the trust
Open Machine Identities then Connect a Platform in the console. Pick your platform and answer the fields that identify the workload. The console creates the identity and the trust, then prints the snippet with your workspace id already in it.
Three things to know about the trust:
- The audience every token must carry 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.
Ask for this audience and no other. A token requested for two audiences fails every exchange.
Run one step with the values
The credential goes in PENV_TOKEN, which takes precedence over the keychain and is how CI and
servers carry one. penv run then reads the environment that credential is bound to.
permissions:
id-token: write # without this, no token is issued at all
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 writes to $HOME/.penv/bin and edits no startup file, so the $GITHUB_PATH line is
what puts penv in reach of the steps that follow. Both curl calls read their answer with jq,
so a runner without it needs it installed alongside penv.
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 platform that needs a file on disk instead, PENV_TOKEN="$C" penv pull writes a plain .env
in the build step. See Deployment for which of the two fits your host.
The published action
GitHub Actions has a second path that does the same work: an action you reference in your workflow. It reads no repository secrets and mints the token per run, then revokes the credential when the job ends whether the job passed or failed. Its exchange is a different one, and the credential it hands back lives five minutes, because it is spent inside a single step.
| 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. The value is written to a 0600 file under $RUNNER_TEMP, NAME is set to that path, and the file is removed before the step ends. |
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.
What this does not promise
A malicious step in the same job can reach what that job holds. Co-location is disclosure, and no masking changes that: the payload behind CVE-2025-30066 read the runner's own memory and lifted its secret table past the log masker.
What bounds a credential here is its lifetime: fifteen minutes from the exchange above, five from the action's. Revocation at the end of a job is best effort, since a force-cancel or a killed runner runs nothing afterward. A job that restores a cache should not be carrying one at all.