Secrets and Credentials#

Problem#

Workflows need secrets — registry tokens to push container images, forge tokens for API access, user-defined API keys for deployment. Today there is no mechanism to inject secrets into the build environment beyond server-level environment variables (MYCI_FORGE_TOKEN).

This blocks real-world use cases:

  • Pushing a Nix-built container image to a forge registry from a workflow step.

  • Deploying to external services that require authentication.

  • Any workflow that needs credentials the operator has not pre-configured as a server-level env var.

Current State#

  • MYCI_FORGE_TOKEN is a single server-level token, configured in the k8s deployment secret. Used for commit status reporting.

  • No workflow-level secrets context exists.

  • No secret store exists.

Note

Secret masking is planned in sprint 19. Per-user forge tokens are planned in sprint 21.

Three Layers#

Secrets support builds incrementally in three layers. Each is independently useful.

Layer 1: Secret masking#

Prevent known secret values from appearing in log output, JSONL event files, and SSE streams.

  • A SecretMasker sits between the mpsc receiver and the JSONL writer. It replaces known values with *** before events touch disk or broadcast.

  • The ::add-mask:: workflow command lets steps register additional values for masking at runtime.

  • Initial masking list: forge token, OAuth credentials, any value from a future secrets context.

  • File permissions on the data directory: 0700 for directories, 0600 for files — consistent with the existing token store.

Masking is string replacement of known values. It cannot prevent encoding, splitting, or exfiltration of secrets via creative workflows. This is a fundamental limit shared by all CI systems.

Event envelope safety: tokens and secrets must never appear in event envelope fields (RunStarted, JobStarted, etc.) — only in Log event content where masking applies. Envelopes carry metadata (repo, ref, SHA, job/step names) which is non-sensitive.

Layer 2: Per-user forge tokens#

The server uses the authenticated user’s forge token instead of a single global token. This enables:

  • Commit status reporting under the user’s identity.

  • Registry push using the user’s credentials.

  • Repository access scoped to what the user can see.

The user’s forge token is obtained during OAuth login and associated with their session. When a webhook triggers a run, the server resolves the repository owner’s token from the session store.

Open questions:

  • Persistence: session-only (lost on server restart) vs stored (encrypted at rest). Session-only is simpler but requires re-login after restart.

  • Token scope: the OAuth token may not have registry push scope. Users might need to grant additional permissions or provide a separate registry token.

Layer 3: Workflow secrets context#

A secrets expression context, matching the GitHub Actions model:

steps:
  - name: Push image
    run: skopeo copy ... --creds ${{ secrets.REGISTRY_TOKEN }}

Secrets are stored server-side, scoped per repository or organization. They are injected into the run environment at execution time and automatically added to the masking list.

Storage: a simple encrypted key-value store per repository. Could use the existing TokenStore patterns (file-based, permission-checked) initially. A proper secret management backend (Vault, SOPS, k8s secrets) is a future option.

API: POST /api/repos/{repo}/secrets to create/update, DELETE to remove. Secrets are write-only — the API never returns values, only names.

Multi-user: secret access follows repository permissions. A user who can trigger runs on a repository can use its secrets. A user who cannot see the repository cannot enumerate or use its secrets.

Registry Credentials#

Pushing container images to a forge registry is a specific instance of workflow secrets. The workflow step uses skopeo copy or crane push with credentials from the secrets context.

For the common case of pushing to the same forge that triggered the build, the per-user forge token (layer 2) may suffice — if the OAuth scope includes registry write access. For external registries or tokens with specific scopes, workflow secrets (layer 3) are needed.

Example workflow:

jobs:
  build:
    runs-on: [nix]
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: nix build .#server-image
      - name: Push to registry
        run: |
          ./result | skopeo copy \
            docker-archive:/dev/stdin \
            docker://${{ secrets.REGISTRY_HOST }}/myci/server:latest \
            --dest-creds ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}

Incremental Path#

Layer

What it enables

Depends on

Masking

Safe log output

Nothing

Per-user tokens

User-scoped forge access, registry push via forge token

OAuth login (done)

Workflow secrets

Arbitrary secrets in workflows

Secret store, API, multi-user scoping

Each layer is independently useful. Masking is a prerequisite for the others in the sense that without it, injected secrets would leak into logs.