Skip to main content

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:

  1. Verify is its own job. The deploy job has needs: verify and if: needs.verify.outputs.passed == 'true'. If verify fails, deploy doesn't run.
  2. Soft-fail is selectable per run. continue-on-error: ${{ inputs.mode == 'soft-fail' }} lets the verify job keep running even when cilock verify returns non-zero, but the passed output flips to false and the deploy job is skipped.

The soft-fail to fail-closed rolloutโ€‹

Don't enable fail-closed on day one. The recommended rollout:

WeekModeWhat you learn
1โ€“2soft-fail, deploy unconditionallyWhich builds would have been blocked. Audit the failures, are they real policy violations or is the policy too strict?
3soft-fail, deploy gatedNow the gate actually blocks, but operators can override by re-running with stricter inputs.
4+fail-closed onlyPolicy 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:

  1. Each collection's signature is valid against the policy's publickeys / roots.
  2. The signer matches a trusted functionary for the build step.
  3. The timestamp (if any) was issued by a trusted TSA at a time the cert was valid.
  4. Materials/products are consistent across steps in artifactsFrom.
  5. 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 require build + sast + test + package. Each step has its own functionaries and Rego rules.
  • Recording the verification itself. Wrap the cilock verify step in another cilock run --step verify-promote so 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.