env-master
Documentation sections

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.

InputDefaultWhat it does
url, project, environment—where to go and for what
client-id, client-secret—identity credentials from CI secrets
keyseverything availablenarrow 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-emptyfalsetreat an empty value as missing
validatefalsecheck the environment against the schema on the server
export-to-envtrueput values into the next steps' environment
file—additionally write a dotenv file with mode 0600
versionlatest@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.