Gate a release in CI
This page reflects our reading of Cyber Resilience Act, Annex I Part I(2)(a) as of . It is not legal advice. Review process.
After you push an SBOM, the release gate tells your pipeline whether that version ships
with a known exploitable vulnerability. Poll it, fail the job on blocked, and carry on
on pass.
What the gate checks, and what it does not
It blocks a version when a component in its active SBOM matches an advisory that is in CISA's KEV catalogue or the EUVD exploited list, or is a tier A match, and nothing clears it: no VEX statement, no fixed version recorded in your own advisory, no triage decision of not applicable.
That is a signal, not a conformity verdict. A pass does not mean the product meets
Annex I, and a blocked is not a reportable event. Recording that you are releasing
anyway, with a reason, is done in the app on the product page. The API only reads; the
acknowledgement then shows as lastDecision (result, time, and whether a person or a key
made it), with neither the reason nor a name.
Before you start
- The Growth or Partner plan, for the API key. The gate on the product page is on every plan; only reading it from a pipeline needs a key.
- The owner or an admin role to create the key. Recording a release decision in the app
needs a member, admin or owner role (
products.write). - An API key scoped
products:read, and a second one scopedsboms:write(or one key with both) to push the SBOM first. See Push SBOMs from CI. - The product's ID, from the product page or
GET /api/v1/products.
The answers to handle
| Field | Meaning | What the job does |
|---|---|---|
result: "pending" | Matching has not yet run on the newest SBOM. | Wait and ask again. Never treat as a pass. |
result: "blocked" | A known exploitable vulnerability is not cleared. | Fail. |
result: "pass" | None found. | Continue. |
versionSupported: false | The version is no longer supported, and matching skips it, so the result may be stale. | Warn. |
latestUploadFailed: true | The newest SBOM did not parse; the result is about the one before it. | Warn. |
URL-encode the version
A release label can contain /, + or :. Pass it as ?version= with every character
encoded, as in the examples below. The path form
(/api/v1/products/{id}/versions/{version}/gate) also works, but a proxy may refuse an
encoded slash in a path, so the query form is the one to use.
GitHub Actions
Add this after the upload step. It strips a leading v from the tag (v1.4.0 is
checked as 1.4.0), so the label must match what the upload step sent; change
${VERSION#v} in both places if yours differs.
- name: Release gate
env:
DECLARA_API_KEY: ${{ secrets.DECLARA_API_KEY }}
PRODUCT_ID: your-product-id
VERSION: ${{ github.ref_name }}
run: |
version="$(jq -rn --arg v "${VERSION#v}" '$v|@uri')"
for attempt in $(seq 1 20); do
body="$(curl -sS --fail-with-body \
"https://your-declara-host/api/v1/products/$PRODUCT_ID/gate?version=$version" \
-H "Authorization: Bearer $DECLARA_API_KEY")"
result="$(jq -r .result <<<"$body")"
[ "$result" != "pending" ] && break
[ "$attempt" -lt 20 ] || break
echo "Matching has not caught up yet (attempt $attempt of 20)."
sleep 30
done
[ "$(jq -r .versionSupported <<<"$body")" = "false" ] &&
echo "::warning::This version is not supported, so the gate may be stale."
[ "$(jq -r .latestUploadFailed <<<"$body")" = "true" ] &&
echo "::warning::The newest SBOM failed to parse; this is the SBOM before it."
case "$result" in
pass) echo "Release gate: pass." ;;
blocked)
jq -r '.blockers[] | "::error::\(.advisoryId) in \(.purl) (\(.signal))"' <<<"$body"
exit 1 ;;
*) echo "::error::No answer after 10 minutes (result: $result)."; exit 1 ;;
esac
Any other CI: a shell script
#!/bin/sh
# Usage: DECLARA_HOST=... DECLARA_API_KEY=... PRODUCT_ID=... ./gate.sh 1.4.0
set -eu
version="$(jq -rn --arg v "$1" '$v|@uri')"
url="$DECLARA_HOST/api/v1/products/$PRODUCT_ID/gate?version=$version"
attempt=1
while :; do
body="$(curl -sS --fail-with-body "$url" -H "Authorization: Bearer $DECLARA_API_KEY")"
result="$(printf '%s' "$body" | jq -r .result)"
[ "$result" = "pending" ] || break
[ "$attempt" -lt 20 ] || { echo "Still pending after 20 attempts." >&2; exit 1; }
attempt=$((attempt + 1))
sleep 30
done
[ "$(printf '%s' "$body" | jq -r .versionSupported)" = "false" ] &&
echo "warning: version not supported; the gate may be stale." >&2
[ "$(printf '%s' "$body" | jq -r .latestUploadFailed)" = "true" ] &&
echo "warning: newest SBOM failed to parse; this is the one before it." >&2
case "$result" in
pass) echo "Release gate: pass." ;;
blocked)
printf '%s' "$body" | jq -r '.blockers[] | "blocked: \(.advisoryId) in \(.purl) (\(.signal))"' >&2
exit 1 ;;
*) echo "Unexpected result: $result" >&2; exit 1 ;;
esac
The loop waits up to about ten minutes (20 attempts, 30 seconds apart). If your matching usually takes longer, raise the count.
A 404 means the product or version is not in this workspace; push the SBOM first, since
a version is created by its first upload. A 400 means the version label is malformed.
See the API reference for the full response.