GitHub Actions: from build to verified release
This tutorial walks through a full attested CI pipeline using aflock-ai/cilock-action, five steps (lint, SAST, test, build, docker-build) each producing signed in-toto attestations via OIDC. The pattern below is taken directly from Cole's reference implementation at github.com/testifysec/dropbox-clone.
What you'll buildโ
A pipeline where every step is wrapped by cilock-action and produces a signed attestation. All signing is keyless (Fulcio + GitHub OIDC), all attestations are timestamped (Sigstore TSA), and all evidence is uploaded to Archivista using OIDC for auth, no static API keys.
Prerequisitesโ
- A GitHub repo (this tutorial assumes a Go project, but any language works)
- Permission to add workflow files
- Optional: an Archivista instance + Fulcio reachable (the cilock-action defaults derive from
platform-url)
Step 1: Set the right permissionsโ
Cilock-action needs id-token: write to request the OIDC token used by Fulcio (signing) and Archivista (upload). It needs contents: read for checkout. Nothing else.
permissions:
id-token: write
contents: read
This is the same minimum set Cole uses in cilock-action-oidc.yaml.
Step 2: The five-step attested pipelineโ
name: cilock-action OIDC attestations
on:
workflow_dispatch:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
STAGING_URL: https://platform.aws-sandbox-staging.testifysec.dev
jobs:
attest:
name: Attested CI Pipeline
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: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
# 1. Lint + secret scan
- name: lint + secrets
uses: aflock-ai/cilock-action@v1.0.1
with:
step: lint
command: echo "lint passed"
attestations: environment git github secretscan
platform-url: ${{ env.STAGING_URL }}
# 2. SAST: gosec writes SARIF, captured as a product
- name: sast
uses: aflock-ai/cilock-action@v1.0.1
with:
step: sast
command: bash -c "gosec -fmt=sarif -out=gosec-results.sarif ./... || true"
attestations: environment git github sarif
platform-url: ${{ env.STAGING_URL }}
cilock-args: --attestor-product-include-glob "*.sarif"
# 3. Tests
- name: test
uses: aflock-ai/cilock-action@v1.0.1
with:
step: test
command: go test -count=1 ./...
attestations: environment git github
platform-url: ${{ env.STAGING_URL }}
# 4. Build + SBOM in one step (cilock observes both)
- 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
platform-url: ${{ env.STAGING_URL }}
cilock-args: --attestor-product-include-glob "bin/*"
# 5. Container build
- name: docker-build
uses: aflock-ai/cilock-action@v1.0.1
with:
step: docker-build
command: docker buildx build --metadata-file docker-metadata.json -t myapp:test --load .
attestations: environment git github docker
platform-url: ${{ env.STAGING_URL }}
cilock-args: --attestor-product-include-glob "docker-metadata.json"
Why each step uses the attestor mix it doesโ
| Step | Extra attestor | Why |
|---|---|---|
lint | secretscan | Cheap to run on a no-op command, catches credentials accidentally echoed during real lint output. |
sast | sarif | The output is a SARIF file; the SARIF attestor parses it into structured findings inside the attestation. |
test | (none) | Test runs primarily need command-run + git + CI context. |
build | sbom | Build produces the binary that's also the SBOM target, one cilock invocation captures both. |
docker-build | docker | The docker attestor parses the buildx metadata file and records image digests. |
environment, git, and github are passed to every step, this gives you the source commit, runner identity, and CI context on every attestation, so verification policy can match identity claims per step.
What gets producedโ
Each step produces a signed DSSE envelope containing an in-toto Collection. With enable-archivista: true (the cilock-action default), each envelope is also pushed to Archivista using a fresh OIDC token. The attestation_file and git_oid action outputs let downstream steps reference the evidence:
- name: docker-build
id: docker
uses: aflock-ai/cilock-action@v1.0.1
with:
step: docker-build
# ...
- name: Print evidence GitOID
run: echo "Evidence stored at ${{ steps.docker.outputs.git_oid }}"
Adding a verification gateโ
To make this enforce policy (not just observe), add a separate job that runs cilock verify against a signed policy after all attested steps complete. See Verify in a release gate for the gate pattern.
Going furtherโ
- Need the raw CLI? Cole's
test-staging-cilock.yamlshows the same five-step pipeline usingcilock rundirectly instead of the action, useful for understanding what the action does under the hood. - Two-pipeline architecture. The repo also splits CI (
ci.yaml, PR-triggered,contents: read) from CD (deploy.yaml, push-triggered, full AWS credentials via OIDC federation). This is the "two pipelines, two trust boundaries" pattern; cilock is the proof that the boundary holds. - Defending against real attacks. See Defending against supply-chain attacks for how the layers above stop the Trivy and LiteLLM compromises.