A release promotion gate driven by cilock evidence
This tutorial wires a real release-promotion gate: a build pipeline produces signed attestations, and a separate promotion workflow refuses to deploy until cilock verify proves the build met policy. It's the operational answer to "how do we make sure no one ships a release that skipped the SBOM step?"
For the broader operational guide on gate design (soft-fail vs. fail-closed, where to put the gate, recording the verification result), see Verify in a release gate.
What you'll buildโ
Two workflows, one shared evidence store, one signed policy.
Step 1: The signed policyโ
Write policy.json declaring what every promoted release must satisfy. This example requires the build step to have a signed SBOM, signed by your team's GitHub Actions OIDC identity, with the build coming from main:
{
"expires": "2030-12-31T23:59:59Z",
"steps": {
"build": {
"name": "build",
"attestations": [
{ "type": "https://aflock.ai/attestations/material/v0.1" },
{ "type": "https://aflock.ai/attestations/command-run/v0.1" },
{ "type": "https://aflock.ai/attestations/product/v0.1" },
{ "type": "https://cyclonedx.org/bom" },
{
"type": "https://aflock.ai/attestations/github/v0.1",
"regopolicies": [
{
"name": "must come from main",
"module": "<base64-encoded module below>"
}
]
}
],
"functionaries": [
{
"type": "root",
"certConstraint": {
"commonname": "*",
"dnsnames": ["*"],
"emails": ["*"],
"organizations": ["*"],
"uris": ["https://github.com/example/myapp/.github/workflows/build.yml@refs/heads/main"],
"roots": ["sigstore-fulcio"]
}
}
]
}
},
"roots": {
"sigstore-fulcio": {
"certificate": "<base64 PEM of the Fulcio root>"
}
}
}
The Rego module enforces "must come from main":
package github.ref
import rego.v1
deny contains msg if {
not endswith(input.ref, "/main")
msg := sprintf("build did not come from main: %s", [input.ref])
}
Sign the policy once with cilock sign so the verifier can validate the policy itself hasn't been tampered with:
cilock sign \
--signer-file-key-path policy-signing.key \
-f policy.json \
-o policy-signed.json
Commit policy-signed.json and policy-pubkey.pem to the repo (or a release-engineering repo). The private signing key stays in offline storage, it's only used when the policy itself changes.
Step 2: The build workflow (produces evidence)โ
.github/workflows/build.yml:
name: build
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Install syft
run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
- name: build + sbom
uses: aflock-ai/cilock-action@v1.0.1
with:
step: build
command: bash -c "CGO_ENABLED=0 go build -o bin/myapp ./cmd/myapp && syft bin/myapp -o cyclonedx-json=bin/bom.cdx.json"
attestations: environment git github sbom
cilock-args: --attestor-product-include-glob "bin/*"
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
Each merge to main produces a signed SBOM + build attestation, uploaded to Archivista by default.
Step 3: The promote workflow (gates on policy)โ
.github/workflows/promote.yml:
name: promote to prod
on:
workflow_dispatch:
inputs:
sha:
description: "Commit SHA of the build to promote"
required: true
mode:
description: "Verification mode"
type: choice
options: [soft-fail, fail-closed]
default: soft-fail
permissions:
id-token: write
contents: read
jobs:
verify:
runs-on: ubuntu-latest
outputs:
passed: ${{ steps.gate.outputs.passed }}
steps:
- uses: actions/checkout@v4
- name: Install cilock
run: |
VERSION=v1.0.1
curl -sSfL "https://github.com/aflock-ai/rookery/releases/download/${VERSION}/cilock-${VERSION#v}-linux-amd64.tar.gz" \
-o cilock.tar.gz
tar xzf cilock.tar.gz
sudo install -m 0755 cilock /usr/local/bin/cilock
- name: Fetch attestations from Archivista
env:
ARCHIVISTA_URL: https://archivista.example.com
run: |
# Find every attestation collection for this commit's build subject.
# Replace this with whatever your Archivista client uses; the GraphQL
# query is `dssesBySubjectDigest` or equivalent.
curl -s "$ARCHIVISTA_URL/by-commit/${{ inputs.sha }}" \
-o build.attestation.json
- name: Run cilock verify
id: gate
continue-on-error: ${{ inputs.mode == 'soft-fail' }}
run: |
if cilock verify \
--policy ./policy-signed.json \
--publickey ./policy-pubkey.pem \
--attestations build.attestation.json \
--artifactfile bin/myapp ; then
echo "passed=true" >> "$GITHUB_OUTPUT"
echo "โ
policy passed"
else
echo "passed=false" >> "$GITHUB_OUTPUT"
echo "โ policy failed"
exit 1
fi
deploy:
needs: verify
if: needs.verify.outputs.passed == 'true'
runs-on: ubuntu-latest
environment: prod
steps:
- run: |
echo "Deploying ${{ inputs.sha }} to prod..."
# ...your existing deploy steps...
Two important shapes:
- Verify is its own job. The deploy job has
needs: verifyandif: needs.verify.outputs.passed == 'true'. If verify fails, deploy doesn't run. - Soft-fail is selectable per run.
continue-on-error: ${{ inputs.mode == 'soft-fail' }}lets the verify job keep running even whencilock verifyreturns non-zero, but thepassedoutput flips tofalseand the deploy job is skipped.
The soft-fail to fail-closed rolloutโ
Don't enable fail-closed on day one. The recommended rollout:
| Week | Mode | What you learn |
|---|---|---|
| 1โ2 | soft-fail, deploy unconditionally | Which builds would have been blocked. Audit the failures, are they real policy violations or is the policy too strict? |
| 3 | soft-fail, deploy gated | Now the gate actually blocks, but operators can override by re-running with stricter inputs. |
| 4+ | fail-closed only | Policy violations stop deploys with no override. |
Cilock makes the early-week iteration cheap because the same evidence and policy are used in both modes, only the workflow's continue-on-error and if: change.
What gets verifiedโ
cilock verify runs the five-step verification process:
- Each collection's signature is valid against the policy's
publickeys/roots. - The signer matches a trusted functionary for the
buildstep. - The timestamp (if any) was issued by a trusted TSA at a time the cert was valid.
- Materials/products are consistent across steps in
artifactsFrom. - Every embedded Rego policy passes, including the "must come from main" rule above.
If any of these fail, the build doesn't promote.
Going furtherโ
- Multiple steps in the policy. The example above only requires
build. Real policies usually requirebuild+sast+test+package. Each step has its own functionaries and Rego rules. - Recording the verification itself. Wrap the
cilock verifystep in anothercilock run --step verify-promoteso the verification itself becomes a signed attestation. Useful for audit chains. - Admission controllers instead of CI gates. The same policy can be evaluated by a Kubernetes admission controller (e.g. Sigstore policy-controller adapted to in-toto). The verify job above is the CI-side equivalent.
For the operational guide that goes deeper on these decisions, see Verify in a release gate.