Scan in CI/CD
Run an OSPulse scan as part of your pipeline, wait for it, read the policy violations it produced, and fail the build when they cross a threshold you set. This guide gives one gate script and four complete, copy-pasteable pipeline configurations that call it.
The CLI is the shorter path
Most teams should use the OSPulse CLI, which wraps everything below in a single command and handles polling, retries and exit codes for you — see CLI. Use the raw API approach on this page when you need custom thresholds, a runner image you cannot add tools to, or logic the CLI does not cover.
How the gate works
There is no special "CI endpoint". A pipeline gate is four ordinary API calls:
POST /api/v1/repositories/{repositoryId}/scan— queue a scan, get back ascanId.GET /api/v1/scans/{scanId}— poll until the scan leavesQueued/Running.GET /api/v1/policy-violations— read what the scan flagged.- Compare against your threshold, and
exit 1if it is breached.
The scan is evaluated against the policies attached to that repository. The pipeline does not decide what counts as a violation — policies do. Your pipeline decides only how many, and how severe, is too many.
Scans are repository-scoped, not commit-scoped
OSPulse scans the repository as OSPulse knows it, from the manifests on the branch it tracks. It is not a per-commit diff. Use this gate on your default branch and on release pipelines; on a short-lived feature branch it tells you about the branch you merged from, not the change in front of you.
Store the token as a CI secret
The gate needs a token that can trigger a scan and read scans and policy violations. Create a dedicated one per pipeline, with an expiry, so you can revoke it without disrupting anyone — a CI token that never expires is a credential you will forget you issued.
curl -X POST https://api.ospulse.app/api/v1/api-tokens \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "ci-checkout-api", "expiresAtUtc": "2027-07-28T00:00:00Z" }'The value is returned once. Store it immediately as a GitHub Actions repository secret, a
masked and protected GitLab CI/CD variable, or an Azure Pipelines secret variable — named
OSPULSE_TOKEN in each case.
The gate script
One script, no dependencies beyond curl and jq, used unchanged by every pipeline below.
It polls with a hard timeout, filters violations to the repository being built, and returns
a distinct exit code per failure mode so the pipeline can react intelligently.
#!/usr/bin/env bash
# OSPulse CI gate. Requires: curl, jq, $OSPULSE_TOKEN, $OSPULSE_REPOSITORY_ID
set -uo pipefail
API="${OSPULSE_API:-https://api.ospulse.app/api/v1}"
REPO_ID="${OSPULSE_REPOSITORY_ID:?set OSPULSE_REPOSITORY_ID}"
FAIL_ON="${OSPULSE_FAIL_ON:-Critical}" # Critical | High | Medium | Low
MAX_VIOLATIONS="${OSPULSE_MAX_VIOLATIONS:-0}" # allowed at or above FAIL_ON
TIMEOUT_SECONDS="${OSPULSE_TIMEOUT:-900}"
POLL_SECONDS="${OSPULSE_POLL_INTERVAL:-10}"
WARN_ONLY="${OSPULSE_WARN_ONLY:-false}"
auth=(-H "Authorization: Bearer ${OSPULSE_TOKEN:?set OSPULSE_TOKEN}")
api() { curl -fsS --retry 3 --retry-connrefused --max-time 60 "${auth[@]}" "$@"; }
# --- 1. queue the scan -------------------------------------------------------
queued=$(api -X POST "$API/repositories/$REPO_ID/scan") || {
echo "::error:: could not queue scan"; exit 3; }
scan_id=$(jq -r '.scanId' <<<"$queued")
echo "queued scan $scan_id"
# --- 2. poll until it leaves a running state ---------------------------------
deadline=$(( SECONDS + TIMEOUT_SECONDS ))
state="Queued"
while [[ "$state" == "Queued" || "$state" == "Running" ]]; do
if (( SECONDS >= deadline )); then
echo "::error:: scan $scan_id still $state after ${TIMEOUT_SECONDS}s"
exit 4
fi
sleep "$POLL_SECONDS"
detail=$(api "$API/scans/$scan_id") || continue
state=$(jq -r '.state' <<<"$detail")
printf 'scan %s: %s (%ss elapsed)\n' "$scan_id" "$state" "$SECONDS"
done
if [[ "$state" != "Completed" ]]; then
echo "::error:: scan finished as $state: $(jq -r '.failureReason // "no reason given"' <<<"$detail")"
exit 4
fi
echo "scored $(jq -r '.packagesScored' <<<"$detail") packages"
# --- 3. read violations for this repository ----------------------------------
violations=$(api "$API/policy-violations") || { echo "::error:: could not read violations"; exit 3; }
rank() { case "$1" in Critical) echo 4;; High) echo 3;; Medium) echo 2;; *) echo 1;; esac; }
floor=$(rank "$FAIL_ON")
blocking=$(jq --arg repo "$REPO_ID" --argjson floor "$floor" '
[ .items[]
| select(.repositoryId == $repo)
| select(.status != "Resolved" and .status != "Excepted")
| . + { rank: ({Critical:4, High:3, Medium:2}[.severity] // 1) }
| select(.rank >= $floor) ]' <<<"$violations")
count=$(jq 'length' <<<"$blocking")
jq -r '.[] | " \(.severity)\t\(.violationId)\tdetected \(.detectedAtUtc)"' <<<"$blocking"
echo "$count violation(s) at or above $FAIL_ON (allowed: $MAX_VIOLATIONS)"
# --- 4. decide ---------------------------------------------------------------
if (( count > MAX_VIOLATIONS )); then
if [[ "$WARN_ONLY" == "true" ]]; then
echo "::warning:: gate would have failed — running in warn mode"
exit 0
fi
exit 1
fi
echo "OSPulse gate passed"| Exit code | Meaning | Suggested pipeline behaviour |
|---|---|---|
0 | Gate passed, or warn mode | Continue |
1 | Threshold breached | Fail the build |
3 | API or auth error | Fail loudly — do not treat as a pass |
4 | Scan failed or timed out | Retry once, then fail |
Separating "risk found" from "OSPulse unreachable" matters. A gate that silently passes when the API is down is not a gate.
Roll it out in warn mode first
Never turn a new gate straight to blocking. You do not yet know how many violations your repository already carries, and a first run that fails every build teaches your team to route around the gate.
Run it non-blocking for a week
Set OSPULSE_WARN_ONLY=true. The script still reports everything it would have blocked, but
always exits 0. Read the output and count what you are actually carrying.
Fix or except the existing backlog
Anything you accept for now should be an explicit, expiring exception rather than something
tolerated by a loose threshold — the gate script already skips Excepted violations:
curl -X POST https://api.ospulse.app/api/v1/policy-violations/9a3c.../exception \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "Replacing transitive dep in Q4; compensating WAF rule in place",
"expiresAtUtc": "2026-10-31T00:00:00Z",
"evidenceAttachmentBlobUri": null
}'Enforce at Critical, then tighten
Drop OSPULSE_WARN_ONLY and keep OSPULSE_FAIL_ON=Critical with
OSPULSE_MAX_VIOLATIONS=0 — that blocks the cases nobody argues about. Move to High once
the Critical gate has been green for a few weeks. Tightening a quiet gate is easy;
loosening a noisy one after everyone has learned to ignore it is not.
Pipeline configurations
Each of these checks out the repository, makes OSPULSE_TOKEN available, and runs the gate
script. Store OSPULSE_REPOSITORY_ID as a plain variable — it is not a secret. Find it with
GET /api/v1/repositories (see Import a repository).
# .github/workflows/ospulse-gate.yml
name: OSPulse dependency gate
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
OSPULSE_TOKEN: ${{ secrets.OSPULSE_TOKEN }}
OSPULSE_REPOSITORY_ID: ${{ vars.OSPULSE_REPOSITORY_ID }}
OSPULSE_FAIL_ON: Critical
OSPULSE_MAX_VIOLATIONS: '0'
# Flip to 'false' once the backlog is clear.
OSPULSE_WARN_ONLY: 'true'
steps:
- uses: actions/checkout@v4
- name: Run OSPulse gate
run: |
chmod +x scripts/ospulse-gate.sh
./scripts/ospulse-gate.sh | tee ospulse-gate.log
- name: Upload gate log
if: always()
uses: actions/upload-artifact@v4
with:
name: ospulse-gate-log
path: ospulse-gate.logThe API calls in detail
Queue a scan
curl -X POST https://api.ospulse.app/api/v1/repositories/b28f4d51-77c0-4f2a-9d16-3ea5c7180b44/scan \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"scanId": "1f7b3d90-42ac-4d7e-9b55-c0a91e2f6d38",
"repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
"state": "Queued",
"queuedAtUtc": "2026-07-28T10:31:12Z"
}A 400 here usually means the repository has no scannable manifest; a 404 means the id
does not belong to your tenant.
Poll for completion
GET /api/v1/scans/{scanId} returns the scan's current state. Poll on a fixed interval
rather than as fast as you can — the gate script's default of ten seconds is a sensible
balance.
{
"scanId": "1f7b3d90-42ac-4d7e-9b55-c0a91e2f6d38",
"repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
"repositoryFullName": "acme-engineering/checkout-api",
"state": "Completed",
"trigger": "Api",
"queuedAtUtc": "2026-07-28T10:31:12Z",
"startedAtUtc": "2026-07-28T10:31:20Z",
"completedAtUtc": "2026-07-28T10:33:04Z",
"packagesScored": 612,
"resultSummary": "612 packages scored, 3 policy violations raised",
"failureReason": null
}If a scan stalls, GET /api/v1/scans/{scanId}/logs returns its audit trail, and
POST /api/v1/scans/{scanId}/cancel stops it.
Read the violations
GET /api/v1/policy-violations returns the tenant-wide violation list:
{
"items": [
{
"violationId": "9a3c51e8-0d47-4b2a-8f16-24e7c9a0b153",
"policyId": "3d90f2c1-6b84-4a15-9e0d-77bc31a5e402",
"repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
"severity": "Critical",
"status": "Open",
"detectedAtUtc": "2026-07-28T10:33:01Z"
}
],
"totalEstimate": 3
}The list is tenant-wide, so filter to your repositoryId in the client — that is exactly
what the jq expression in the gate script does. For the evidence behind a single
violation, including recommendedAction, fetch
GET /api/v1/policy-violations/{violationId}.
For a one-line risk snapshot in the build log — dependency count, overall health score and
a breakdown by risk level — add a call to
GET /api/v1/repositories/{repositoryId}/risk-summary. It gives reviewers context without
them opening the app.
Common pitfalls
Treating an API failure as a pass. set -e plus an unchecked curl will happily exit
0 on an empty response. Keep the distinct exit codes, and never wrap the gate in
|| true.
Polling without a timeout. A scan that never terminates will hold a runner until your CI
platform kills the job — often at a much longer timeout than you expected, on a paid
minute. The gate script's OSPULSE_TIMEOUT bounds it.
Gating on feature branches. Because scans are repository-scoped, a feature-branch gate reports the state of the tracked branch and produces confusing red builds. Gate the default branch and release pipelines.
Setting OSPULSE_MAX_VIOLATIONS above zero permanently. A non-zero allowance hides new
violations behind old ones — three permitted violations means your fourth, brand new
Critical is the one that fails the build, weeks late. Use expiring exceptions instead.
One shared token across every pipeline. Rotation then means coordinating every team at
once. One token per pipeline, each with an expiry. Keep the gate output as a build artifact
so the correlationId on any error response is still there when
support needs it days later.
