Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

On this page

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.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:
          # 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:

  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.

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:

  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.

Reference generated from the product documentation. Match commands and support details to your installed release.