Skip to content

CI/CD — build pipelines, sealed

A build needs the key. Two separate questions decide which key, and where it lives.

Compiling means reading the source, and the source is encrypted. Something has to decrypt it. Configuring CodeSeal for CI is answering two questions — and they are independent, which is why there are eight combinations and not four.

  • What is the decryption material?

    This is the encryption mode, chosen at sgit init, and it is about your team rather than your pipeline. Every file gets its own random key either way; what differs is how that key is wrapped. passphrase wraps it with one shared secret stretched through scrypt. recipient wraps it to every developer's public key, so each person unwraps with a private key only they hold.

  • Who holds it, who builds, who starts it?

    This is the CI mode. secrets keeps the material in GitHub Secrets and builds on GitHub. The other three hand custody to a Portal you run, and differ in what happens next: portal-key still builds on GitHub and is still triggered by GitHub, portal-dispatch builds on GitHub but is triggered by the Portal, and portal-build does the build on the Portal itself.

CI/CD ready

Build pipelines, sealed.

Four CI modes, trading runner trust for isolation. Pick the one that matches how much you trust the build environment — and how much operational overhead you are willing to pay for not trusting it.

Mode 1 · secrets

Direct secrets

Simplest. The runner receives key material from GitHub Actions Secrets and decrypts directly. Right for solo projects, and for any team already trusting Actions with secrets.

.github/workflows/codeseal-secrets.yml

- uses: actions/checkout@v4
- run: |
    curl -fsSL .../install.sh | SGIT_ONLY=1 sh
- name: Build securely
  env:
    SGIT_KEY: ${{ secrets.SGIT_KEY }}
  run: sgit run-secure -- ./build.sh

Mode 2 · portal-key

Portal-delivered key

The runner trades a bootstrap secret for a one-shot token, fetches a wrapped key, and unwraps it locally. Key material exists on the runner for under a second.

.github/workflows/codeseal-portal-key.yml

- run: sgit ci request-token > .ci-token
- run: sgit ci runner-key > runner.wrapped.age
- run: sgit ci unwrap-key -i wrapper.age \
         -I runner.wrapped.age -o runner.key
# key gone BEFORE build
- run: rm -f runner.wrapped.age wrapper.age
- run: sgit run-secure -- ./build.sh

Mode 3 · portal-build

Portal-side build

GitHub never sees plaintext. Portal workers clone, decrypt in an isolated sandbox, build, scrub, and hand back artefacts. Strongest isolation; needs a Portal you run.

.github/workflows/codeseal-portal-build.yml

- name: Trigger Portal build
  run: |
    sgit build trigger \
      --commit-sha ${{ github.sha }} \
      --repository-id ${{ vars.CODESEAL_REPO_ID }} \
      > build.json
- name: Wait for terminal state
  run: sgit build status --build-id $ID --wait

Mode 4 · portal-dispatch

Portal-dispatched build

People decrypt locally with their own keys; builds run on Actions. The Portal calls GitHub, never the reverse — so your Portal can sit behind a home router and GitHub never learns where it is.

.github/workflows/codeseal-portal-dispatch.yml

on:
  workflow_dispatch:
    inputs:
      encrypted_token: { required: true }
steps:
  - run: echo "::add-mask::$ENCRYPTED_TOKEN"
  - run: sgit ci unwrap-dispatch-token
  - run: sgit run-secure -- ./build.sh

You never write these by hand. The snippets show what runs on the runner so that you can audit it — but sgit init generates the whole workflow for whichever --ci-mode you chose, plus a build.sh stub where your build steps go. Developers and CI engineers only ever edit build.sh; regenerate the workflow at any time with sgit workflows.

Two independent choices — not one

Every encryption mode works with every CI mode. The CI mode decides where the key material lives and how it reaches the runner; the encryption mode decides what that key material is. Eight combinations, all supported.

EncryptionCI mode1 · secrets2 · portal-key3 · portal-build4 · portal-dispatch
passphraseOne shared secret, stretched with scrypt.SGIT_KEY stored in GitHub Secrets.Portal delivers the passphrase, wrapped to the GitHub wrapper key.Portal decrypts server-side with the stored passphrase.The dispatch token carries the passphrase.
recipientEvery developer has their own age keypair.The runner's own private key, in GitHub Secrets as CI_RUNNER_PRIVATE_KEY_B64.Portal delivers the wrapped CI runner key.Portal decrypts server-side with its recipient keys.The dispatch token carries the CI runner key.

In recipient mode the CI runner is just another recipient. Its public key sits in .sgit/recipients.yaml with role ci, and every file's key is wrapped to it in the stanza exactly as it is to a teammate's. Only the private half is a secret — and which column you are in decides where that private half lives. That is why the two axes are genuinely independent rather than approximately so.

Direct secrets — everything happens on the runner

No Portal involved. GitHub injects the shared passphrase as an environment variable; the runner decrypts, builds, and scrubs.

  1. actions/checkout

    The first step clones the repository onto the runner. The clone holds the encrypted blobs under .sgit/store/, the stanzas and the manifest — and zero plaintext, because plaintext was never committed.

  2. install sgit (SGIT_ONLY=1)

    install.sh with SGIT_ONLY=1 downloads just the sgit binary from the GitHub release and verifies its SHA-256. No $CODESEAL home, no deploy assets — CI needs none of it.

  3. inject SGIT_KEY

    GitHub decrypts the SGIT_KEY repository secret and injects it into the job as an environment variable. It never appears in logs; GitHub masks it.

  4. run-secure — decrypt

    sgit run-secure reads SGIT_KEY from the environment, derives the decryption key with scrypt, and decrypts every manifest-listed blob into the workspace.

  5. ./build.sh

    Your build script runs with the plaintext present. This is the window in which the runner can see your sensitive files, and it is the Mode 1 trade-off stated plainly.

  6. sgit clean — always

    Whether the build passed or failed, run-secure deletes every manifest-listed plaintext file and preserves build.sh's exit code. Plaintext existed on the runner only between steps four and six.

sequenceDiagram
    autonumber
    participant R as GitHub Repo<br/>ciphertext at rest
    participant C as CI Runner<br/>ubuntu-latest
    participant S as GitHub Secrets<br/>encrypted store

    C->>R: actions/checkout
    C->>C: install sgit (SGIT_ONLY=1)
    S->>C: inject SGIT_KEY
    C->>C: run-secure: decrypt
    C->>C: ./build.sh
    C->>C: sgit clean (always)

Nothing leaves the runner. The trade is that the runner sees everything, for the length of one build.

Portal-delivered key — short-lived, single-use, double-locked

The runner never holds a long-lived key. It trades a bootstrap secret for a sixty-second single-use token, redeems that for a wrapped runner key, and unwraps it locally — the Portal cannot read the key it stores.

  1. POST bootstrap secret

    The workflow reads CODESEAL_BOOTSTRAP_SECRET — a GitHub Actions secret — and posts it to the Portal's /api/ci/token with the repository id. It was provisioned once, at sgit init time.

  2. verify bootstrap hash

    The Portal hashes what was presented and compares it to the hash stored in Postgres. Only the hash is ever stored, so a database leak does not reveal the secret.

  3. store token hash, TTL 60s

    On a match the Portal mints a random single-use token and stores its hash in Redis with a sixty-second expiry. An attacker replaying a token from a log has a one-minute, one-shot window at most.

  4. single-use token

    The response body carries the token back. This is the only time it crosses the wire in the clear, inside TLS.

  5. redeem — Bearer token

    The runner calls /api/ci/runner-key with the token in an Authorization: Bearer header.

  6. consume token — atomic

    The Portal compares and deletes in one operation, so two simultaneous requests carrying the same token cannot both succeed. Single-use is enforced, not trusted.

  7. load wrapped runner key

    The Portal loads the runner's private key from Postgres — stored encrypted to the GitHub wrapper public key, which the Portal does not hold. A fully compromised Portal database yields ciphertext.

  8. wrapped key

    The response carries the still-encrypted key. Anyone intercepting it, or dumping the database, holds something only the GitHub wrapper private key opens.

  9. unwrap, then delete both

    The runner unwraps with GITHUB_WRAPPER_PRIVATE_KEY — the second half of the double lock — and immediately deletes both key files, before build.sh runs.

  10. run-secure, build, clean

    The same finale as Mode 1: decrypt, build, scrub, whatever the outcome.

sequenceDiagram
    autonumber
    participant C as CI Runner<br/>GitHub Actions
    participant P as CodeSeal Portal<br/>API
    participant R as Redis<br/>ephemeral, TTL
    participant D as Postgres<br/>durable

    C->>P: POST bootstrap secret
    P->>D: verify bootstrap hash
    P->>R: store token hash, TTL 60s
    P->>C: single-use token
    C->>P: redeem — Bearer token
    P->>R: consume token (atomic)
    P->>D: load wrapped runner key
    P->>C: wrapped key
    C->>C: unwrap, then DELETE keys
    C->>C: run-secure, build, clean

Two secrets, held by two parties, and neither is enough alone. The Portal stores a key it cannot read.

Portal-side build — GitHub never sees plaintext

The strongest isolation. GitHub asks for a build and receives a status; decryption and compilation happen on a Portal worker, in a workspace that is scrubbed afterwards.

  1. POST /api/build {sha}

    The workflow sends the commit SHA, authenticated with a repository-scoped service token. That is the entirety of GitHub's contribution — no key material is ever present on the runner.

  2. enqueue build job

    The Portal records the build in Postgres and enqueues it on a Redis-backed queue. A per-repository lock stops two builds of the same repo racing.

  3. clone and decrypt

    A worker clones at that exact SHA into a fresh workspace and decrypts using the repository's admin key — which an admin unlocked in the Portal UI. The unlocked key lives in Redis with a TTL, never on disk.

  4. delete keys, then build

    Key material is removed from the workspace before build.sh starts, so your script runs with plaintext source and zero keys. Logs are scrubbed for secret-shaped strings before they are persisted.

  5. upload artefact

    On success the worker uploads the build output — the compiled artefact, not the source — and receives a pointer.

  6. status and pointer

    The worker reports the terminal state and the pointer, writes audit entries for every step, and scrubs the workspace. Plaintext existed inside the worker, for one build.

  7. poll /api/build/<id>

    Meanwhile the workflow has been polling — sgit build status --wait does this for you.

  8. succeeded + artefact URL

    The poll returns the terminal state and the pointer, and the GitHub check turns green or red. GitHub saw one SHA out and one status back.

sequenceDiagram
    autonumber
    participant C as CI Runner<br/>GitHub Actions
    participant P as CodeSeal Portal<br/>API
    participant W as Build Worker<br/>isolated workspace
    participant A as Artifact Store<br/>S3 / MinIO

    C->>P: POST /api/build {sha}
    P->>W: enqueue build job
    W->>W: clone + decrypt (admin key)
    W->>W: delete keys, run build.sh
    W->>A: upload artefact
    W->>P: status + artefact pointer
    C->>P: poll /api/build/<id>
    P->>C: succeeded + artefact URL

GitHub is reduced to a trigger and a checkmark. It never holds a key and never sees a plaintext file.

Portal-dispatched build — GitHub never learns where your Portal is

The trigger direction is reversed: the Portal calls GitHub, never the other way. Your Portal can run on a laptop behind a home router, because the workflow holds no Portal URL and no Portal credentials.

  1. git push — ciphertext

    sgit push encrypts anything stale, runs the doctor safety checks, and delegates to git push. What lands on GitHub is ciphertext at the protected paths, as always.

  2. auto-dispatch {sha}

    With auto_on_push set in the manifest, sgit push also notifies the Portal with the commit SHA. Note the direction: the developer's machine talks to the Portal — the workflow never does.

  3. mint and encrypt a token

    The Portal mints a short-lived token bound to that one commit and encrypts it to the dispatch public key in the manifest. Only the holder of the matching private key — the runner — can open it.

  4. workflow_dispatch + token

    The Portal calls GitHub's workflow_dispatch API with the encrypted token as an input. This is the property the whole mode exists for: the Portal opens the connection, so GitHub needs no URL and no credentials, and could not reach the Portal if it wanted to.

  5. start workflow

    GitHub schedules a run on a fresh runner with the token as an input. The first step is echo "::add-mask::$ENCRYPTED_TOKEN", so it is redacted from every later log line.

  6. unwrap-dispatch-token

    The runner installs sgit and combines the encrypted input with DISPATCH_PRIVATE_KEY to recover the per-build decryption material. Both are read from the environment, never from argv.

  7. run-secure, build, clean

    The standard finale. The dispatch token was valid for this one commit and is now spent.

sequenceDiagram
    autonumber
    participant D as Developer<br/>sgit push
    participant P as CodeSeal Portal<br/>anywhere, even a laptop
    participant G as GitHub<br/>repo + Actions API
    participant C as CI Runner<br/>GitHub Actions

    D->>G: git push (ciphertext)
    D->>P: auto-dispatch {sha}
    P->>P: mint + encrypt token
    P->>G: workflow_dispatch + token
    G->>C: start workflow
    C->>C: unwrap-dispatch-token
    C->>C: run-secure, build, clean

The only mode where your Portal needs no reachable address. GitHub is told when to build and never told by what.