CI/CD integration cookbook — Forgejo, GitLab CI, generic shell
Published · Rasid
This cookbook is the third companion piece, alongside the verification walkthrough and the zero-CVE explainer. Three CI runtimes, the same patterns, copy-paste ready: Forgejo Actions, GitLab CI, and a generic shell-based pipeline pattern that drops into Jenkins, CircleCI, Buildkite, or any runner whose primary user interface is “run a bash script.”
Patterns the cookbook covers
Every example below implements the same three policies. If you want only one, take only that section.
- Pin by digest — production pulls never use tags; the deployment manifest carries the SHA-256 digest as the pin.
- Verify the signature in CI — every pull from
images.rasid.ccis verified against the Rasid Sigstore chain before the build proceeds. - Block on fresh advisories — the CI fetches the advisory.json for the pinned digest and refuses to deploy if a high-severity unmitigated advisory has dropped since the image was built.
Forgejo Actions
Forgejo Actions is API-compatible with the upstream Actions runtime, so any community-maintained action that follows the Actions ABI will run. The convention below uses only bash steps to avoid creating an action-dependency tail.
.forgejo/workflows/build.yml:
name: build
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
RASID_IMAGE: images.rasid.cc/wolfi/nginx
RASID_TAG: 1.27.0
RASID_FULCIO: https://fulcio.rasid.cc
RASID_OIDC: https://oidc.rasid.cc
RASID_IDENTITY_REGEX: 'https://build\.rasid\.cc/.+'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install cosign
run: |
curl -sSL -o /usr/local/bin/cosign \
https://sigstore.dev/cosign/releases/latest/cosign-linux-amd64
chmod +x /usr/local/bin/cosign
- name: Resolve digest
id: resolve
run: |
set -euo pipefail
DIGEST=$(crane digest "${RASID_IMAGE}:${RASID_TAG}")
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "Resolved ${RASID_IMAGE}:${RASID_TAG} → ${DIGEST}"
- name: Verify Rasid signature
run: |
set -euo pipefail
cosign verify "${RASID_IMAGE}@${{ steps.resolve.outputs.digest }}" \
--certificate-identity-regexp "${RASID_IDENTITY_REGEX}" \
--certificate-oidc-issuer "${RASID_OIDC}"
- name: Check fresh advisories
run: |
set -euo pipefail
DIGEST_STRIPPED="${${{ steps.resolve.outputs.digest }}#sha256:}"
ADV_URL="https://images.rasid.cc/.well-known/advisory/wolfi/nginx/sha256/${DIGEST_STRIPPED}.json"
curl -sSL "$ADV_URL" -o adv.json
OPEN_HIGH=$(jq '[.advisories[]
| select(.severity == "high" or .severity == "critical")
| select(.disposition // "affected" == "affected")
] | length' adv.json)
if [ "$OPEN_HIGH" -gt 0 ]; then
echo "::error::$OPEN_HIGH unmitigated high/critical advisories against ${DIGEST_STRIPPED}"
jq '.advisories' adv.json
exit 1
fi
- name: Build application image
run: |
set -euo pipefail
docker build \
--build-arg BASE="${RASID_IMAGE}@${{ steps.resolve.outputs.digest }}" \
-t myapp:$(git rev-parse --short HEAD) .
Two notes on the Forgejo flavour:
The $GITHUB_OUTPUT env var is reused by Forgejo Actions for ABI compatibility — that’s not a typo. The runner sets it the same way.
crane is shipped by the go-containerregistry project; install it the same way you’d install cosign (single static binary from the project’s release page). If you prefer the docker CLI: docker buildx imagetools inspect "${RASID_IMAGE}:${RASID_TAG}" --format '{{json .Manifest.Digest}}'.
GitLab CI
.gitlab-ci.yml:
variables:
RASID_IMAGE: images.rasid.cc/wolfi/nginx
RASID_TAG: "1.27.0"
RASID_OIDC: https://oidc.rasid.cc
RASID_IDENTITY_REGEX: 'https://build\.rasid\.cc/.+'
stages:
- verify
- build
verify:rasid-base:
stage: verify
image: docker:24
services:
- docker:24-dind
before_script:
- apk add --no-cache curl jq
- curl -sSL -o /usr/local/bin/cosign
https://sigstore.dev/cosign/releases/latest/cosign-linux-amd64
- chmod +x /usr/local/bin/cosign
script:
- DIGEST=$(crane digest "${RASID_IMAGE}:${RASID_TAG}")
- echo "Resolved digest ${DIGEST}"
- cosign verify "${RASID_IMAGE}@${DIGEST}"
--certificate-identity-regexp "${RASID_IDENTITY_REGEX}"
--certificate-oidc-issuer "${RASID_OIDC}"
- DIGEST_STRIPPED="${DIGEST#sha256:}"
- curl -sSLf
"https://images.rasid.cc/.well-known/advisory/wolfi/nginx/sha256/${DIGEST_STRIPPED}.json"
-o adv.json
- >
OPEN=$(jq '[.advisories[]
| select(.severity == "high" or .severity == "critical")
| select(.disposition // "affected" == "affected")
] | length' adv.json)
- if [ "$OPEN" -gt 0 ]; then
echo "FAIL: $OPEN unmitigated high/critical against ${DIGEST_STRIPPED}";
cat adv.json;
exit 1;
fi
- echo "$DIGEST" > digest.txt
artifacts:
paths: [digest.txt]
expire_in: 1 day
build:app:
stage: build
needs: ["verify:rasid-base"]
image: docker:24
services: [docker:24-dind]
script:
- DIGEST=$(cat digest.txt)
- docker build
--build-arg BASE="${RASID_IMAGE}@${DIGEST}"
-t "$CI_REGISTRY_IMAGE:${CI_COMMIT_SHORT_SHA}" .
- docker push "$CI_REGISTRY_IMAGE:${CI_COMMIT_SHORT_SHA}"
GitLab’s needs: keyword sequences the build behind the verify; if verify:rasid-base fails, build:app does not run. The artefact pattern passes the resolved digest forward without re-resolving — important so that the digest your build pins to is exactly the digest you verified.
Generic shell
For any CI runtime whose interface is “run a script” — Jenkins pipelines, CircleCI, Buildkite, Drone, your in-house build orchestrator, your laptop:
scripts/verify-rasid.sh:
#!/usr/bin/env bash
set -euo pipefail
: "${RASID_IMAGE:?must be set, e.g. images.rasid.cc/wolfi/nginx}"
: "${RASID_TAG:?must be set, e.g. 1.27.0}"
: "${RASID_OIDC:=https://oidc.rasid.cc}"
: "${RASID_IDENTITY_REGEX:=https://build\\.rasid\\.cc/.+}"
require() { command -v "$1" >/dev/null 2>&1 || { echo "missing: $1"; exit 2; }; }
require cosign
require crane
require curl
require jq
DIGEST=$(crane digest "${RASID_IMAGE}:${RASID_TAG}")
echo "==> Resolved ${RASID_IMAGE}:${RASID_TAG} → ${DIGEST}"
echo "==> Verifying signature against Rasid Sigstore chain"
cosign verify "${RASID_IMAGE}@${DIGEST}" \
--certificate-identity-regexp "${RASID_IDENTITY_REGEX}" \
--certificate-oidc-issuer "${RASID_OIDC}"
echo "==> Fetching fresh-advisory snapshot"
RELATIVE_PATH="${RASID_IMAGE#images.rasid.cc/}"
DIGEST_STRIPPED="${DIGEST#sha256:}"
ADV_URL="https://images.rasid.cc/.well-known/advisory/${RELATIVE_PATH}/sha256/${DIGEST_STRIPPED}.json"
ADV=$(mktemp)
curl -sSLf "$ADV_URL" -o "$ADV"
OPEN=$(jq '[.advisories[]
| select(.severity == "high" or .severity == "critical")
| select(.disposition // "affected" == "affected")
] | length' "$ADV")
if [ "$OPEN" -gt 0 ]; then
echo "==> FAIL: $OPEN unmitigated high/critical advisories"
jq '.advisories[] | select(.severity == "high" or .severity == "critical")' "$ADV"
exit 1
fi
echo "==> OK: ${RASID_IMAGE}@${DIGEST}"
echo "$DIGEST"
Used as:
RASID_IMAGE=images.rasid.cc/wolfi/nginx RASID_TAG=1.27.0 \
DIGEST=$(./scripts/verify-rasid.sh)
docker build --build-arg BASE="${RASID_IMAGE}@${DIGEST}" -t myapp:$(git rev-parse --short HEAD) .
The script is intentionally boring. No magic, no service-specific glue, exit code is the contract. It will outlast every CI vendor you adopt.
Patterns worth knowing
Air-gap mode
If your CI runs without outbound internet — common in regulated enterprises — the verification has to happen inside the air-gap perimeter against a Rasid-issued bundle that travelled in with your image set. The enterprise tier ships an offline-mirror bundle (rasid-airgap.tar.zst) that includes images, attestations, signatures, the Fulcio trust bundle, the Rekor public key, and a verifier shim that runs offline. Documentation for the air-gap workflow ships with the bundle.
Multi-image base sets
If your pipeline pulls more than one Rasid image, factor the verify-and-fetch-advisory loop into one step and pass the resolved digests forward as a JSON manifest.
echo '[
{"image":"images.rasid.cc/wolfi/nginx","tag":"1.27.0"},
{"image":"images.rasid.cc/wolfi/node","tag":"22.6.0"},
{"image":"images.rasid.cc/wolfi/postgres","tag":"15.7"}
]' | jq -c '.[]' | while read -r entry; do
RASID_IMAGE=$(echo "$entry" | jq -r .image)
RASID_TAG=$(echo "$entry" | jq -r .tag)
export RASID_IMAGE RASID_TAG
./scripts/verify-rasid.sh
done
Policy admission instead of CI gating
If you operate a policy controller in your cluster (Sigstore policy-controller, Kyverno, OPA Gatekeeper with cosign integration), CI verification becomes belt-and-braces alongside the cluster’s own enforcement. The cluster policy is the floor; CI is the fast-feedback fence. Both are useful; neither is a substitute for the other. The cluster policy stub is in the How to verify a Rasid image post.
Why the cookbook does not include a GitHub Actions example
By owner directive 2026-05-24, Rasid’s published artefacts carry zero github.com surface area. The generic-shell section above runs unmodified on any runner with bash, cosign, crane, curl, and jq — that covers the GitHub Actions case without committing a workflow file that we then have to maintain against an integration we do not host.
If you operate on GitHub-hosted runners, take the generic-shell script verbatim and drop it into a runs: bash step. The Sigstore verification, the digest pinning, and the advisory polling work identically.
What to do when the pipeline fails
Three failures, three responses.
- Signature verification failed. Investigate before bypassing. The most common causes are: pulling from a path that is not
images.rasid.cc(a mirror, a CDN-shim, a cached copy on your internal registry that lost its attestation); cosign version below v2.2; or egress tofulcio.rasid.cc/rekor.rasid.ccblocked by a corporate firewall. Bypassing signature verification turns the whole supply-chain story off; don’t. - Fresh advisory blocks the build. Read the
mitigationandfixed_in_digestfields in the advisory.json. Either pin to the fixed digest (preferred) or apply the mitigation if pinning is blocked. Both paths are auditable; both keep your pipeline green. - Egress blocked entirely. Air-gap mode. Talk to whoever sized your egress budget; if the architecture is permanently air-gapped, the enterprise tier’s offline-mirror is the right shape.
— Rasid