Skip to content

Continuous integration

A CI job is a machine with no human at the keyboard and often no project checkout. notenv runs there with three pieces: the rclone remote credentials for your storage, a machine identity that unlocks the vault without a prompt, and the targeting that points at the right vault and namespace from outside the repo. Here they are together, then each piece on its own.

A complete workflow

.github/workflows/test.yml
# Run tests with secrets pulled from a notenv vault on a cloud remote.
#
# CI needs exactly two kinds of secret, set under Settings > Secrets and variables > Actions:
#   - NOTENV_IDENTITY: a machine slot key. Generate it once on your machine with
#       notenv key add --machine ci
#     and paste the printed AGE-SECRET-KEY... value into the secret.
#   - the credentials for your rclone remote (this example uses Backblaze B2:
#     B2_KEY_ID and B2_APP_KEY).
#
# No passphrase, and no .env file, ever reaches the runner.

name: test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      # 1. Define the rclone remote from the environment. rclone reads
      #    RCLONE_CONFIG_<NAME>_* natively, and notenv shells out to rclone, so
      #    no rclone.conf file is needed. <NAME> here is "notenv".
      RCLONE_CONFIG_NOTENV_TYPE: b2
      RCLONE_CONFIG_NOTENV_ACCOUNT: ${{ secrets.B2_KEY_ID }}
      RCLONE_CONFIG_NOTENV_KEY: ${{ secrets.B2_APP_KEY }}
      RCLONE_CONFIG_NOTENV_HARD_DELETE: "false" # keep B2's object versioning

      # 2. Unlock the vault without a prompt: a machine identity, never a passphrase.
      NOTENV_IDENTITY: ${{ secrets.NOTENV_IDENTITY }}
    steps:
      - uses: actions/checkout@v4

      - name: Install rclone and notenv
        run: |
          sudo apt-get update && sudo apt-get install -y rclone
          pipx install notenv # pipx is preinstalled on ubuntu-latest

      # 3. Write this machine's storage config (which remote, which path). The
      #    committed notenv.toml supplies the namespace, so `run` needs no flags.
      - name: Point notenv at the vault
        run: notenv init --remote notenv --base my-bucket/notenv

      - name: Run tests with secrets injected
        run: notenv run -- npm test
        # A vault failure exits 125, never the child's code, so a flaky-test
        # retry never mistakes a vault problem for a test failure.
.gitlab-ci.yml
# Run tests with secrets pulled from a notenv vault on a cloud remote.
#
# Set these as masked CI/CD variables (Settings > CI/CD > Variables):
#   - NOTENV_IDENTITY: a machine slot key. Generate it once on your machine with
#       notenv key add --machine ci
#     and paste the printed AGE-SECRET-KEY... value into the variable.
#   - B2_KEY_ID and B2_APP_KEY: the credentials for your rclone remote.
#
# No passphrase, and no .env file, ever reaches the runner.

test:
  image: node:22
  variables:
    # Define the rclone remote from the environment. rclone reads
    # RCLONE_CONFIG_<NAME>_* natively, and notenv shells out to rclone.
    RCLONE_CONFIG_NOTENV_TYPE: b2
    RCLONE_CONFIG_NOTENV_ACCOUNT: $B2_KEY_ID
    RCLONE_CONFIG_NOTENV_KEY: $B2_APP_KEY
    RCLONE_CONFIG_NOTENV_HARD_DELETE: "false" # keep B2's object versioning
    # NOTENV_IDENTITY is set as a masked variable and inherited automatically.
  before_script:
    # Install rclone, then the latest notenv release binary (image-agnostic:
    # needs only curl and tar). On a Python image, `pip install notenv` also works.
    - |
      apt-get update && apt-get install -y rclone curl
      ver="$(curl -fsSL https://api.github.com/repos/DvGils/notenv/releases/latest | sed -n 's/.*"tag_name": *"\(.*\)".*/\1/p')"
      curl -fsSL "https://github.com/DvGils/notenv/releases/download/${ver}/notenv_${ver#v}_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin notenv
      notenv init --remote notenv --base my-bucket/notenv
  script:
    - notenv run -- npm test
    # A vault failure exits 125, never the child's code.

The rest of this page explains each piece.

Point notenv at the storage

A fresh runner has no machine config, so two things have to arrive from the job's environment.

The rclone remote is defined with rclone's own RCLONE_CONFIG_<NAME>_* variables. notenv shells out to rclone and inherits the environment, so no rclone.conf file is needed; the remote's secret (a B2 application key, an S3 secret, an SFTP password) is the one credential your CI secret store holds for storage:

export RCLONE_CONFIG_NOTENV_TYPE=b2
export RCLONE_CONFIG_NOTENV_ACCOUNT="$B2_KEY_ID"
export RCLONE_CONFIG_NOTENV_KEY="$B2_APP_KEY"

The notenv storage config (which remote, which path) is written non-interactively with notenv init. In a checkout, the committed notenv.toml then supplies the namespace, so run needs no further flags:

notenv init --remote notenv --base my-bucket/notenv
notenv run -- npm test

Unlock without a prompt

Enroll the job as a machine slot and present its identity via NOTENV_IDENTITY:

notenv key add --machine ci      # on your machine: prints the identity exactly once

Paste the printed AGE-SECRET-KEY... into your CI provider's secret store (the one credential the job needs) and expose it to the job:

export NOTENV_IDENTITY="$CI_SECRET_NOTENV_IDENTITY"   # inline value, or a path the runner wrote
notenv run -- npm test

NOTENV_IDENTITY accepts the identity inline or as a path to a file the runner materialized. notenv itself never stores an identity on disk anywhere.

Why an identity, not a passphrase

Passphrases are for people, identities are for machines. Passphrase prompts read the terminal device directly so they reach a human, not a script; in CI there is no human. The identity's at-rest protection is the platform secret store that already guards your deploy keys.

Pin the vault from outside the repo

--storage NAME selects a configured storage regardless of any project binding, and --namespace NAME addresses a namespace directly when there is no checkout:

export NOTENV_ACCEPT_NAMESPACE=my-service   # CI checkouts are fresh every run; name what this job may use
notenv run --storage prod --namespace my-service -- ./deploy.sh

The first use of a namespace that already holds secrets needs confirmation, and in CI nobody can answer: notenv refuses unless NOTENV_ACCEPT_NAMESPACE names the namespace. The value is exact names, never a blanket yes, so a malicious repository's committed notenv.toml cannot point a shared runner at another project's secrets. See Environment variables.

Read exit codes

notenv run follows docker's convention, so a job can tell a vault failure from a test failure: the child's exit code passes through untouched, while 125 is notenv's own failure, 126 is found-but-cannot-execute, and 127 is not-found. A flaky-test retry never mistakes a vault problem for a code problem. See Commands.

Refuse writes

To make a job read-only, set NOTENV_READONLY=1 (or mark the storage read_only = true). Every mutating command is then refused. This is policy that stops a cooperating job from an accident; for enforced read-only, use a read-only storage credential (for example a Backblaze B2 application key without write). See Environment variables.