CI and automation
Install and enrol Sealr non-interactively, wrap the steps that touch production, and fail the job on the right exit code.
CI is where an agent's work actually reaches production. The laptop has a read replica and a scratch AWS account; the pipeline has the deploy role, the migration credentials and the push token. It is also the one environment you fully control: no human decides at 2 am to run the command outside the wrapper.
That combination — real credentials, no improvisation — makes CI the first place to turn
--enforce on, and usually the last place you should leave in observe mode.
Install the binary
Releases are published as archives on GitHub, with a SHA256SUMS file and a build
provenance attestation. Verify both before you put the binary in a job's PATH.
VERSION=0.1.0
TARGET=x86_64-unknown-linux-gnu
ASSET="sealr-${VERSION}-${TARGET}.tar.gz"
gh release download "v${VERSION}" -R sealr-labs/sealr -p "$ASSET" -p SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
gh attestation verify "$ASSET" -R sealr-labs/sealr
tar xzf "$ASSET"The archive contains two binaries: sealr (the recorder) and sealr-verify (the
standalone bundle verifier).
Published Linux builds target glibc (x86_64-unknown-linux-gnu). There is no musl
target, so the binary will not run on an Alpine-based image — use a Debian or Ubuntu
base, or build from source in the container.
Enrol without a human
sealr enroll needs an enrollment token. Tokens are single-use and expire after 24
hours: the Console burns the token atomically at enrollment, so a second job using the
same secret fails with enrollment token already used. A token pasted once into a CI
secret will work for exactly one run.
Three ways to live with that, in order of how much they ask of you:
No Console, no token. The job records locally and exports the evidence as a build artifact.
sealr init # offline mode, local trust chain
sealr run --enforce --resource db:prod-main -- psql "$DATABASE_URL" -c "$SQL"
sealr export -o "run-${GITHUB_RUN_ID}.seal"Enforcement still works: with no policy bundle, each guard's own conservative default
decides, so an unbounded DELETE is still blocked. What you give up is central policy,
approvals, and a fleet view — and the bundle only exists if the job uploads it.
Labels are how per-team policy rules find this recorder. The Console stores the labels
attached to the token; the --label flags set the labels used locally when guards
evaluate. Pass the same list to both so the two views agree.
GitHub Actions
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
SEALR_VERSION: 0.1.0
# Keep the recorder home inside the workspace so every step shares it.
SEALR_HOME: ${{ github.workspace }}/.sealr
steps:
- uses: actions/checkout@v4
- name: Install sealr
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TARGET=x86_64-unknown-linux-gnu
ASSET="sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
gh release download "v${SEALR_VERSION}" -R sealr-labs/sealr -p "$ASSET" -p SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
gh attestation verify "$ASSET" -R sealr-labs/sealr
tar xzf "$ASSET"
echo "${PWD}/sealr-${SEALR_VERSION}-${TARGET}" >> "$GITHUB_PATH"
- name: Enrol the recorder
env:
SEALR_CONSOLE_API: ${{ secrets.SEALR_CONSOLE_API }}
SEALR_ADMIN_KEY: ${{ secrets.SEALR_ADMIN_API_KEY }}
SEALR_ENDPOINT: ${{ secrets.SEALR_GRPC_ENDPOINT }}
run: |
set -euo pipefail
TOKEN=$(curl -fsS -X POST "$SEALR_CONSOLE_API/v1/recorders/enrollment-tokens" \
-H "authorization: Bearer $SEALR_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{"labels":["env:prod","ci:github-actions"]}' | jq -r .token)
sealr enroll --endpoint "$SEALR_ENDPOINT" --token "$TOKEN" \
--label env:prod --label ci:github-actions
sealr doctor
- name: Terraform plan
run: terraform plan -out=tfplan
- name: Terraform apply
run: |
sealr run --enforce --resource cloud:aws-prod -- terraform apply -auto-approve tfplan
- name: Database migration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
sealr run --enforce --resource db:prod-main -- \
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-c "$(cat migrations/0042_backfill_accounts.sql)"
- name: Keep the evidence
if: always()
run: sealr export -o "run-${GITHUB_RUN_ID}.seal"
- uses: actions/upload-artifact@v4
if: always()
with:
name: sealr-evidence
path: run-${{ github.run_id }}.sealTwo details in that workflow decide whether the guards actually see anything:
- The migration is passed with
-c, not-f. The SQL guard reads the statement text frompsql -c,--commandor--command=.psql -f migration.sqlis recorded as a generic exec — argv, exit code, duration, output digests — with no SQL parsing at all. A file with several statements passed via-cparses as a multi-statement operation (SQL_MULTI_STATEMENT) and takes the highest severity among them. terraform applyis guarded on argv, not on the plan. The Terraform guard analyzesterraform show -jsonoutput, but the wrapper hands it the command line. State subcommands (state rm,import,taint,force-unlock,apply -replace) are recognized and require approval; an ordinary apply yieldsTF_HCL_UNANALYZED(WARN). Plan-level gating from a wrapped command is not implemented yet — see agent surfaces for what you can do with the plan JSON today.
GitLab CI
deploy:
stage: deploy
image: debian:bookworm-slim # glibc, not Alpine
variables:
SEALR_VERSION: "0.1.0"
SEALR_HOME: "$CI_PROJECT_DIR/.sealr"
before_script:
- apt-get update && apt-get install -y --no-install-recommends ca-certificates curl jq
- |
set -euo pipefail
TARGET=x86_64-unknown-linux-gnu
BASE="https://github.com/sealr-labs/sealr/releases/download/v${SEALR_VERSION}"
curl -fsSLO "${BASE}/sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
curl -fsSLO "${BASE}/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS
tar xzf "sealr-${SEALR_VERSION}-${TARGET}.tar.gz"
install "sealr-${SEALR_VERSION}-${TARGET}/sealr" /usr/local/bin/sealr
- |
TOKEN=$(curl -fsS -X POST "$SEALR_CONSOLE_API/v1/recorders/enrollment-tokens" \
-H "authorization: Bearer $SEALR_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{"labels":["env:prod","ci:gitlab"]}' | jq -r .token)
sealr enroll --endpoint "$SEALR_ENDPOINT" --token "$TOKEN" --label env:prod --label ci:gitlab
script:
- sealr run --enforce --resource repo:acme/api -- git push origin "$CI_COMMIT_REF_NAME"
artifacts:
when: always
paths:
- "run.seal"
after_script:
- sealr export -o run.sealSEALR_CONSOLE_API, SEALR_ADMIN_KEY and SEALR_ENDPOINT are masked, protected CI/CD
variables. gh attestation verify is not available here, so the checksum check is the
integrity control; if you want provenance verification, run gh from a container that has
it or verify the archive once and mirror it to your own registry.
Containers
For agents that run inside a container — a task runner, a self-hosted agent image — bake
the binary in and make sealr run the entrypoint.
FROM debian:bookworm-slim
ARG SEALR_VERSION=0.1.0
ARG TARGET=x86_64-unknown-linux-gnu
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
BASE="https://github.com/sealr-labs/sealr/releases/download/v${SEALR_VERSION}"; \
curl -fsSLO "${BASE}/sealr-${SEALR_VERSION}-${TARGET}.tar.gz"; \
curl -fsSLO "${BASE}/SHA256SUMS"; \
sha256sum --ignore-missing -c SHA256SUMS; \
tar xzf "sealr-${SEALR_VERSION}-${TARGET}.tar.gz"; \
install "sealr-${SEALR_VERSION}-${TARGET}/sealr" /usr/local/bin/sealr; \
install "sealr-${SEALR_VERSION}-${TARGET}/sealr-verify" /usr/local/bin/sealr-verify; \
rm -rf "sealr-${SEALR_VERSION}-${TARGET}"*
# The recorder home must be a volume, or the spool dies with the container.
ENV SEALR_HOME=/var/lib/sealr
VOLUME ["/var/lib/sealr"]
ENTRYPOINT ["sealr", "run", "--enforce", "--resource", "db:prod-main", "--"]docker run --rm -v sealr-home:/var/lib/sealr acme/agent-runner \
psql "$DATABASE_URL" -c "UPDATE accounts SET tier = 'pro' WHERE id = 42"Mount the recorder home, and initialize it once against that volume — sealr init for
offline mode, or sealr enroll for connected mode. Without it the entrypoint exits 30
with recorder is not initialized. Records are written to the local spool and synced
afterwards, so a container that discards /var/lib/sealr on exit discards any record that
had not yet reached the Console.
Exit codes
The job must fail on a block, and it will only do that if you do not swallow the code.
| Code | Meaning |
|---|---|
0 | the wrapped command succeeded |
20 | blocked — policy BLOCK, an approval denied, or an approval that could not be obtained |
127 | the wrapped command could not be spawned |
30 | the recorder itself failed (not initialized, unreadable key, sealr doctor unhealthy) |
| other | the wrapped command's own exit code, passed straight through |
sealr verify uses its own scale: 0 pass, 10 pass with findings, 11 fail. Use it in
a later job to check an exported bundle.
A blocked command never runs, so the exit code is the only signal — there is no partial effect to detect. Two ways to lose it:
# Wrong: the pipeline reports tee's status, not sealr's.
sealr run --enforce -- terraform apply | tee apply.log
# Right.
set -o pipefail
sealr run --enforce -- terraform apply | tee apply.logA command killed by a signal reports 1, because the exit status is normalized to a
single byte.
Approvals in a pipeline
In enforce mode, a REQUIRE_APPROVAL verdict holds the command and asks the Console for a
human decision. The wait is bounded at 120 seconds. If no one decides in that window,
or the Console is unreachable, the resource binding's on_approval_unavailable decides —
the default is to block, so the job exits 20.
This is short for a human in a different timezone. Treat approvals in CI as a gate for operations someone is already watching, and use BLOCK for the ones nobody should be asked about at 2 am.