Don't deploy what the pipeline didn't build.
Oath stops an image from reaching production unless your own pipeline built it. Every CI build records commit → digest → time in Solana, as a standard Memo transaction signed by the CI key. Before deploy, oath verify checks the image digest against those records and blocks the deploy if there is none.
- Standard Memo program, no custom smart contract
- CLI and Kubernetes admission webhook
- devnet by default, mainnet supported
How it works
- 01git commitsource revision
- 02CI buildimage digest
- 03Solana Memo txsigned by the CI key
- 04verify gateoath verify · admission webhook
exit 0 verified, the deploy proceeds
exit 1 / denied no record, the deploy is stopped
Record format
One Memo per build, fields separated by a vertical bar:
pod1|ghcr.io/me/app|3f9a1c0d2b7e|sha256:<64 hex>|2026-10-12T21:04:11Z
version | image name (the record is bound to it) | first 12 characters of the commit | image digest | ISO time
Live verifier
Runs in your browser: it queries the Solana RPC directly with the same logic as the CLI.
Integrate
Three steps: create a CI key, anchor after the build, verify before the deploy.
Add the secret SOLANA_SECRET_KEY and the variable OATH_SIGNER (the public key) to the repository. For devnet SOL use faucet.solana.com.
# once: create the CI key (writes .oath/ci-key.json, funds it, prints what to add)
npx --yes --allow-git=all github:Tymofii-thc/oath init
# in your workflow, after the build: anchor the digest
- uses: Tymofii-thc/oath@main
with:
image: ghcr.io/${{ github.repository }}
digest: ${{ steps.build.outputs.digest }}
secret-key: ${{ secrets.SOLANA_SECRET_KEY }}
# before the deploy: pulls the image and fails the job if there is no record
- uses: Tymofii-thc/oath/verify@main
with:
signer: ${{ vars.OATH_SIGNER }}
image: ghcr.io/${{ github.repository }}:latestNo dependencies. Exit codes: 0 verified, 1 not verified, 2 error (network, arguments).
node bin/oath.mjs init # create and fund a CI key, print the next steps
node bin/oath.mjs verify --signer <pubkey> --digest sha256:…
node bin/oath.mjs verify --signer <pubkey> --image ghcr.io/me/app:latest
node bin/oath.mjs verify --signer <pubkey> --container prod # the image running NOW
node bin/oath.mjs audit --signer <pubkey> # every Pod running in the current cluster
# several signers (key rotation) and a maximum record age
node bin/oath.mjs verify --signer <a>,<b> --max-age 30 --image ghcr.io/me/app:latest
# a second signer must also have recorded it, plus a Sigstore build attestation
node bin/oath.mjs verify --signer <ci>,<approver> --quorum 2 --attest me/app --image ghcr.io/me/app:latest
# mainnet
node bin/oath.mjs verify --signer <pubkey> --network mainnet --image ghcr.io/me/app:latestThe webhook checks every new Pod, so kubectl apply cannot bypass the pipeline. It fails closed: if the registry, the RPC or the webhook is unreachable, new Pods are not admitted. Start with OATH_MODE=audit.
# installs the webhook into the namespace oath-system (generates TLS)
OATH_SIGNER=<public key> OATH_NETWORK=devnet deploy/install.sh
# first rollout: allow everything, only warn
OATH_MODE=audit OATH_SIGNER=<public key> deploy/install.sh
# a Pod from an image the pipeline did not build:
kubectl run t --image=nginx
# → denied: no pipeline build anchored by the trusted signer(s)
# private registries
OATH_REGISTRY_AUTH='{"ghcr.io":{"username":"u","password":"token"}}'Signer records
Pipeline records anchored by the signer above, newest first.
| Time | Commit | Image | Digest | Transaction |
|---|
Threat model
| Scenario | Result | Why |
|---|---|---|
| Image swapped in the registry under the same tag | Blocked | The pulled digest has no record from a trusted signer. |
| Manual docker push or kubectl apply, bypassing CI | Blocked | oath verify exits with 1; the admission webhook denies the Pod. |
| Registry token leaked, CI signing key safe | Blocked | The attacker can push an image but cannot anchor its digest. |
| Record anchored for a different image name | Blocked | Records are bound to the image name (disable with --no-bind). |
| Old but legitimate digest replayed | Limited | Use --max-age to reject old records; without it the digest passes. |
| CI key compromised, then revoked | Mitigated | Remove the key from the signer list: its records stop counting at once. Records anchored before you noticed are not detected. |
| Registry, RPC or webhook unreachable | Fail closed | New Pods are not admitted, running Pods are untouched. The CLI exits with 2. |
Not covered
- Stolen CI signing key. Whoever holds SOLANA_SECRET_KEY can anchor a forged digest. Rotation, revocation, --max-age and image binding limit the damage. Hardware keys and keyless signing are planned, not implemented.
- Malicious code built honestly. The chain proves that the signer recorded a digest at a given time, not that the build was honest or safe. That needs SLSA build attestations. The optional Trivy scan only checks fixable CRITICAL vulnerabilities.
- Mainnet in CI. Mainnet is supported, but CI has not been run on live mainnet yet. devnet is the default and has no retention guarantees.
- Availability and existing Pods. Registry or RPC outages block new Pods. Pods created before the webhook was installed are not checked; use oath verify --container for them.