Skip to main content
Menu

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.cc is 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.

  1. 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 to fulcio.rasid.cc / rekor.rasid.cc blocked by a corporate firewall. Bypassing signature verification turns the whole supply-chain story off; don’t.
  2. Fresh advisory blocks the build. Read the mitigation and fixed_in_digest fields 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.
  3. 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