CI and deploy
Two moves that carry over to any runner: grant access and read values. You do that with the CLI, with direct API requests, or with the ready-made action — it is a composite action and works the same in GitHub Actions, Gitea Actions and GitVerse Actions.
The action lives at aistastudio/env-action.
Grant access: an enrollment token instead of a permanent secret
The classic "put the client secret into CI secrets" scheme works, but it
has a bad leak cost: a secret from a log or a fork works until revoked.
The alternative is an identity in enrollment mode: the pipeline issues a
one-time enrollment token right before the deploy, and the deployed
service exchanges it for permanent credentials on its first start. After
the exchange the token is useless, and presenting it again raises the
enrollment.reuse-detected alert in the log and via webhook.
The issuance step (requires a user token with the admin role in the
project):
ENROLLMENT_TOKEN=$(curl -s $BASE/services/$SERVICE/identities \
-H "authorization: Bearer $ENVMASTER_TOKEN" -H 'content-type: application/json' \
-d '{"name":"deploy '"$GITHUB_RUN_ID"'","environmentId":"'$ENV'","mode":"enrollment"}' \
| jq -r .enrollmentToken)
The exchange step — on the machine you deploy to:
PUB=$(openssl genpkey -algorithm X25519 | \
openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
curl -s $BASE/machine/enroll -H 'content-type: application/json' \
-d "{\"enrollmentToken\":\"$ENROLLMENT_TOKEN\",\"publicKey\":\"$PUB\"}"
# -> { clientId, clientSecret, accessToken, expiresAt }
The token lives for 10 minutes: issue it in the same job that deploys, not in advance. Full coverage of the mode is on the Services and identities page.
Reading values in a job
For CI itself (not for the deployed service), create a separate identity
with a minimal allowedKeys and, if the runner addresses are known,
allowedCidrs. The credentials go into CI secrets:
envmaster run --project billing-api --env production --mask -- ./deploy.sh
# или, если файл неизбежен:
envmaster export --project billing-api --env production --file deploy.env
run is preferable: values go to the child process and don't stay on
disk, and --mask filters them out of the output. Details — in the
CLI.
Action: values in one step
- uses: aistastudio/env-action@v1
with:
url: ${{ vars.ENVMASTER_URL }}
project: billing-api
environment: production
client-id: ${{ secrets.ENVMASTER_CLIENT_ID }}
client-secret: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
keys: DB_URL,DB_PASSWORD # empty means every key the identity sees
export-to-env: true # put them into the next steps' env
file: "" # or write a dotenv file with mode 0600
Values are masked in the runner log. Turn export-to-env off when the values
are only needed in a file: environment variables are visible through /proc
and are inherited by child processes.
| Input | Default | What it does |
|---|---|---|
url, project, environment | — | where to go and for what |
client-id, client-secret | — | identity credentials from CI secrets |
keys | everything available | narrow the export; it cannot widen rights |
required | — | keys without which the step fails |
required-from-file | — | take that list from the repo's env.schema.yml |
check-empty | false | treat an empty value as missing |
validate | false | check the environment against the schema on the server |
export-to-env | true | put values into the next steps' environment |
file | — | additionally write a dotenv file with mode 0600 |
version | latest | @env-master/sdk version; the check needs 0.1.4+ |
Outputs: keys (the exported key names), count, validated.
Using the values further down the pipeline
Three ways, and choosing between them is choosing where the value ends up.
In the next steps' environment (export-to-env: true, the default). The
shortest path; the value lives until the end of the job and is inherited by
every process those steps start.
- uses: aistastudio/env-action@v1
with:
url: ${{ vars.ENVMASTER_URL }}
project: billing-api
environment: production
client-id: ${{ secrets.ENVMASTER_CLIENT_ID }}
client-secret: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
keys: DATABASE_URL,REDIS_URL
- name: Migrations
run: ./migrate.sh # DATABASE_URL is already in the step's env
- name: The same key as an expression
run: echo "key length ${#DATABASE_URL}"
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
As a file (file:), when the tool reads dotenv itself. The file is written
with mode 0600 and stays in the runner's workspace until the job ends:
- uses: aistastudio/env-action@v1
with:
# ...credentials...
export-to-env: false # do not put them into the environment
file: .env.deploy
- name: Bring the stack up
run: docker compose --env-file .env.deploy up -d
export-to-env: false here is not paranoia: environment variables are visible
through /proc, inherited by child processes and captured in crash dumps. If
a value is needed by one tool that can read a file, let it live only there.
As a single process (envmaster run), when the step is one command. Values
go to the child process and stay out of both files and the job environment:
- run: |
npx envmaster run --project billing-api --env production --mask -- ./deploy.sh
env:
ENVMASTER_URL: ${{ vars.ENVMASTER_URL }}
ENVMASTER_CLIENT_ID: ${{ secrets.ENVMASTER_CLIENT_ID }}
ENVMASTER_CLIENT_SECRET: ${{ secrets.ENVMASTER_CLIENT_SECRET }}
What you may and may not do with a value in a step
Masking works, but it is not armour. The action registers every value as a
runner mask, so printing one turns into ***. But a mask matches whole
strings: echo "${DATABASE_URL:0:8}", base64 and URL-encoding all slip past
it. The rule is simple: you do not print a value, rather than printing it
"because it will be masked".
Do not pass values into an image build as arguments. --build-arg stays in
the layer history and is visible to anyone who pulls the image:
# no
- run: docker build --build-arg DB_PASSWORD=$DB_PASSWORD .
# yes: the secret is visible only during RUN and never reaches a layer
- run: docker build --secret id=db,env=DB_PASSWORD .
Step outputs carry names and a count only, never values:
- uses: aistastudio/env-action@v1
id: secrets
with: { ... }
- run: echo "exported ${{ steps.secrets.outputs.count }}: ${{ steps.secrets.outputs.keys }}"
Do not run the step on pull requests from forks. A pull_request from
someone else's fork must not reach a production environment — gate it:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
One identity per pipeline, with a narrow allowedKeys and, when runner
addresses are known, allowedCidrs too. Then leaked CI credentials do not open
the whole environment, and the access log shows that it was the pipeline
reading, not "someone in production".
Two checks, and they do not replace each other
validate: true compares the environment against the schema stored on the
server — the operator's contract: how they want the environment to look.
required and required-from-file check the list the application brings
from its own repository:
required: DB_URL,DB_PASSWORD,REDIS_URL
# or as a list from the repository — keys with required: true:
# required-from-file: env.schema.yml
The step fails with the list of missing keys. Names and statuses go to the log, values never do. Printing names is safe: they are already spelled out in the workflow itself and in the repository schema.
The action deliberately has no fallback inputs: substituting a default at
build time means shipping an artifact with values nobody in production knows
about. Defaults and fallback .env are a runtime property of the application
— see the SDK.
Completeness check before deploy
env.schema.yml describes which keys are required and in which
environments:
version: 1
keys:
- name: DB_PASSWORD
kind: secret
required: true
environments: [staging, production]
- name: LOG_LEVEL
kind: var
required: false
envmaster validate compares the schema against what is actually in the
environment and stops the pipeline if a key is missing — before runtime,
not after. The check works on names; values are not disclosed.
The application reads the same list — from code, not from the pipeline:
await em.assertRequirements(); // fail at startup if a key is missing
These are two moments of one check: the pipeline catches a gap before the
rollout, the application before it starts serving requests on an incomplete
config. The list comes from the same env.schema.yml, so the two cannot
drift; details in the SDK.
What to show in logs
Nothing. If you really need to confirm a key arrived, print its length or a fingerprint prefix, but not the value — masking protects against accidents, not against deliberate output.