A release promotion gate driven by CI/lock 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?"
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.3" },
{ "type": "https://aflock.ai/attestations/command-run/v0.2" },
{ "type": "https://aflock.ai/attestations/product/v0.3" },
{ "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". The github-action attestor records the workflow run's ref indirectly via the OIDC token claims captured by the sibling github attestor — the runnable shape against a CI/lock attestation's predicate looks for the verified JWT claim:
package github.ref
import rego.v1
# Cilock's github attestor exposes the verified Actions OIDC token's
# claims under `input.jwt.claims`. The `ref` claim is the workflow's
# git ref (e.g. "refs/heads/main"). Read it with a default so a missing
# claim denies too.
ref := object.get(input, ["jwt", "claims", "ref"], "")
deny contains msg if {
ref != "refs/heads/main"
msg := sprintf("build did not come from main: %q", [ref])
}Do not write not endswith(input.jwt.claims.ref, "/main"). OPA reads input.jwt.claims.ref before it applies the not, so when the claim is missing the rule body stops there and the rule never denies. cilock policy validate warns about that shape, and verification logs the same warning; neither refuses the policy, so fix it when you see the warning. The exact comparison also closes a second gap: endswith(ref, "/main") accepts a branch named feature/main.
Bind this Rego module to the github attestor's predicate type (https://aflock.ai/attestations/github/v0.1) in the policy's steps[].attestations[].regopolicies block (as shown in the JSON above) so it evaluates against the right attestation in the collection.
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.jsonCommit 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:
# syft v1.54.1 needs Go 1.26.8 or later to install.
go-version: "1.26"
# A pinned syft, installed through the Go module proxy and checksum
# database rather than a script fetched from a moving branch.
- name: Install syft
run: go install github.com/anchore/syft/cmd/[email protected]
# The plain CLI, outside the checkout so the download is not a material.
- name: Install cilock
working-directory: ${{ runner.temp }}
run: |
VERSION=v4.6.2
ARCHIVE="cilock-${VERSION#v}-linux-amd64.tar.gz"
curl -fsSLO "https://cilock.dev/dl/${VERSION}/${ARCHIVE}"
curl -fsSLO "https://cilock.dev/dl/${VERSION}/${ARCHIVE}.sha256"
# Check only the archive's line of the sidecar; a mismatch stops the job.
grep " ${ARCHIVE}\$" "${ARCHIVE}.sha256" > "${ARCHIVE}.line"
sha256sum --strict -c "${ARCHIVE}.line"
tar xzf "${ARCHIVE}" cilock
sudo install -m 0755 cilock /usr/local/bin/cilock
# One cilock step builds the binary and writes its SBOM. cilock verify
# checks each policy step against the collection of the same name only,
# so the SBOM the policy requires on `build` must be emitted by the
# `build` step itself: the sbom attestor wraps bin/bom.cdx.json, a
# product of this run.
- name: build
env:
CGO_ENABLED: "0"
CILOCK_PLATFORM_URL: ${{ vars.CILOCK_PLATFORM_URL }} # your administrator-approved origin
run: |
cilock run --step build --platform-url "$CILOCK_PLATFORM_URL" \
-a environment,git,github,sbom --attestor-product-include-glob "bin/*" \
-o "$RUNNER_TEMP/build.attestation.json" \
-- sh -c 'go build -o bin/myapp ./cmd/myapp && syft bin/myapp -o cyclonedx-json=bin/bom.cdx.json'
- 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 one signed build attestation that carries the SBOM, uploaded to the Archivista derived from --platform-url. The steps run the plain CLI rather than cilock-action v1.0.4, whose evidence records command-run/v0.1 and cannot pass the command-run/v0.2 requirement in the policy above (what v1.0.4 evidence cannot do). An empty --platform-url turns the platform off, so an unset CILOCK_PLATFORM_URL cannot send the evidence to the hosted platform by accident.
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=v4.6.2
ARCHIVE="cilock-${VERSION#v}-linux-amd64.tar.gz"
curl -fsSLO "https://cilock.dev/dl/${VERSION}/${ARCHIVE}"
curl -fsSLO "https://cilock.dev/dl/${VERSION}/${ARCHIVE}.sha256"
# Check only the archive's line of the sidecar; a mismatch stops the job.
grep " ${ARCHIVE}\$" "${ARCHIVE}.sha256" > "${ARCHIVE}.line"
sha256sum --strict -c "${ARCHIVE}.line"
tar xzf "${ARCHIVE}" cilock
sudo install -m 0755 cilock /usr/local/bin/cilock
- name: Fetch attestations from Archivista
env:
ARCHIVISTA_URL: https://archivista.example.com
SHA: ${{ inputs.sha }}
run: |
# Archivista exposes a GraphQL endpoint (/v1/query) for searching
# by subject digest, and /v1/download/<gitoid> to fetch a specific
# DSSE envelope. The shape below queries by the source commit SHA
# recorded by the `git` attestor as a subject, a lookup key to
# FIND the attestations, not what `cilock verify` itself anchors
# its policy match on (see the note below the next step).
# Replace with your client of choice (archivistactl, the Aflock
# platform client, etc).
#
# The search is a GraphQL connection: it returns one page of edges
# and pageInfo.hasNextPage. Download every edge of every page; a
# commit can have several envelopes (re-runs, other steps), and
# verifying against the first one alone checks a partial set.
QUERY='query($sha: String!, $after: Cursor) { dsses(first: 100, after: $after, where: {hasStatementWith: {hasSubjectsWith: {hasSubjectDigestsWith: {value: $sha}}}}) { edges { node { gitoidSha256 } } pageInfo { hasNextPage endCursor } } }'
#
# Evidence goes into a directory this run creates empty, never
# one in the checkout: a stale envelope already on disk would
# otherwise be verified as if this search had returned it.
EVIDENCE=$(mktemp -d "$RUNNER_TEMP/evidence.XXXXXX")
mkdir "$EVIDENCE/envelopes"
echo "EVIDENCE=$EVIDENCE" >> "$GITHUB_ENV"
AFTER=null
FETCHED=0
while :; do
jq -n --arg q "$QUERY" --arg sha "$SHA" --argjson after "$AFTER" \
'{query: $q, variables: {sha: $sha, after: $after}}' > "$EVIDENCE/query.json"
curl -sSf -H 'Content-Type: application/json' --data @"$EVIDENCE/query.json" \
"$ARCHIVISTA_URL/v1/query" -o "$EVIDENCE/page.json"
# GraphQL reports errors with HTTP 200: stop on one rather than
# read it as an empty page.
jq -e '(.errors // []) == [] and (.data.dsses | type) == "object"' "$EVIDENCE/page.json" > /dev/null
for GITOID in $(jq -r '.data.dsses.edges[].node.gitoidSha256' "$EVIDENCE/page.json"); do
curl -sSfL "$ARCHIVISTA_URL/v1/download/$GITOID" -o "$EVIDENCE/envelopes/$GITOID.json"
FETCHED=$((FETCHED + 1))
done
[ "$(jq -r '.data.dsses.pageInfo.hasNextPage' "$EVIDENCE/page.json")" = true ] || break
AFTER=$(jq '.data.dsses.pageInfo.endCursor' "$EVIDENCE/page.json")
done
# A search that found nothing is no evidence: stop here.
[ "$FETCHED" -gt 0 ]
# Pull the product-tree sha256 root out of every fetched `build`
# collection whose git attestation records the requested commit,
# so `cilock verify` anchors the policy match on a
# collision-resistant digest, not the commit's sha1. No build
# evidence for the commit stops the job here.
jq -r --arg sha "$SHA" '.payload | @base64d | fromjson
| select(.predicate.name == "build")
| select(any(.predicate.attestations[];
.type == "https://aflock.ai/attestations/git/v0.1" and .attestation.commithash == $sha))
| .subject[] | select(.name == "https://aflock.ai/attestations/product/v0.3/tree:products")
| "sha256:" + .digest.sha256' "$EVIDENCE"/envelopes/*.json | sort -u > "$EVIDENCE/subjects.txt"
test -s "$EVIDENCE/subjects.txt"
- name: Run cilock verify
id: gate
continue-on-error: ${{ inputs.mode == 'soft-fail' }}
run: |
# Anchoring on --subjects "sha1:${{ inputs.sha }}" (the commit
# hash) was rejected: sha1-commit anchoring is vulnerable to a
# chosen-prefix collision (CVE-2026-22703) — an attacker can craft
# an artifact that shares a trusted build's commit-sha1 subject,
# so a signature vouching for the real build would also validate
# the attacker's substitute with no key of their own. sha256
# doesn't have a known practical collision attack, which is why
# the digests above (not the commit hash) are what gate the policy.
ARGS=()
for f in "$EVIDENCE"/envelopes/*.json; do ARGS+=(--attestations "$f"); done
while read -r s; do ARGS+=(--subjects "$s"); done < "$EVIDENCE/subjects.txt"
if cilock verify \
--policy ./policy-signed.json \
--publickey ./policy-pubkey.pem \
"${ARGS[@]}" ; 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. |
CI/lock 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.
Reference generated from the product documentation. Match commands and support details to your installed release.