Wire Versus into your delivery pipelines two ways — trigger and track GitHub Actions, GitLab CI, and Jenkins pipelines from the platform, and call the validate/plan/apply Operation API from your pipelines with scoped integration tokens for network-as-code changes.
Versus connects to your delivery pipelines in both directions. It can reach out to trigger and track a build in Jenkins, GitLab CI, or GitHub Actions (Azure DevOps is supported too), and it exposes an Operation API your pipeline can call in — to validate, plan, and apply network changes against your device estate as part of a normal merge-and-deploy flow.
That combination is what makes network-as-code practical: your intent lives in a git repo, your pipeline runs the same validate → plan → apply verbs an operator would run by hand, and every run is a durable, auditable record on the platform.
Everything below lives under the API base /api/cicd/.
A connection is provider-agnostic — a provider_type, a base_url, and an encrypted auth_credential blob — and each connection declares what it can do:
| Direction | What it means | Who initiates |
|---|---|---|
| Outbound trigger | Versus starts a pipeline run and polls it to completion | Versus → your CI server |
| Inbound callback | Your pipeline (or a webhook) reports run state back to Versus | Your CI server → Versus |
| Both | A pipeline is triggered by Versus and reports back | Bidirectional |
The direction is set per pipeline (outbound_trigger, inbound_callback, or both). Underneath, every provider driver implements one small contract — test_connection, trigger_run, get_run_status, and optionally cancel_run, verify_webhook, and parse_webhook — so the platform treats all four providers the same way and normalizes their state into one vocabulary.
| Provider | provider_type | Credential shape | Native webhooks | Cancel | Log streaming |
|---|---|---|---|---|---|
| Jenkins | jenkins | {username, token} | No (callback/poll) | Yes | Yes |
| GitLab CI | gitlab | {token} (+ trigger_token) | Yes | Yes | No |
| GitHub Actions | github_actions | {token} | Yes | Yes | No |
| Azure DevOps | azure_devops | {token} | — | — | — |
Credentials are held in an encrypted field and stored per connection; they are never returned by the API (the serializer marks auth_credential write-only). You can optionally include a webhook_secret in the same blob to authenticate inbound callbacks. Set verify_tls: false on a connection only for a lab server with a self-signed certificate.
The three main providers map a Versus pipeline to a provider-native object through external_ref, and each injects a VERSUS_IDEMPOTENCY_KEY so a retried trigger doesn't double-run:
external_ref: {owner, repo, workflow, ref}. Versus calls the workflow_dispatch endpoint (POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches) with your inputs, then resolves the resulting run id. Auth is a bearer token against api.github.com with API version 2022-11-28.external_ref: {project_id, ref}. Versus calls the pipeline trigger endpoint (POST /projects/{id}/trigger/pipeline) using a trigger token, and passes your parameters as VERSUS_-prefixed pipeline variables.external_ref: {job_path}. A path like folder/name becomes /job/folder/job/name/; Versus posts to build or buildWithParameters, fetches a CSRF crumb when the server requires one, then follows the queue item to a concrete build number.Register the connection, register a pipeline on it, then trigger:
POST /api/cicd/pipelines/{id}/trigger/
Authorization: Bearer <session or token>
Content-Type: application/json
{
"params": { "environment": "staging", "image_tag": "1.4.2" },
"idempotency_key": "release-1.4.2" // optional
}
201 Created
{
"id": 87,
"pipeline_name": "edge-deploy",
"external_run_ref": "1299834771",
"normalized_state": "queued",
"trigger_source": "manual",
"run_context": { "queue_url": "https://…", "extra": { … } }
}
params are merged over the pipeline's default_params. If you pass an idempotency_key that already has a run, Versus returns the existing run instead of starting a second one. After the trigger, a background poller watches the run on a backoff schedule (5s, 10s, 15s, 30s, 60s, then 120s, up to one hour) and updates its state until it reaches a terminal value.
Every provider's own status vocabulary is mapped to one set of states, so a dashboard or a workflow step reads the same field regardless of provider:
queued · running · success · failed · canceled · unstable · timeout
For example GitHub's conclusion: cancelled and Jenkins' ABORTED both normalize to canceled; Jenkins' UNSTABLE maps to unstable. Poll a run with GET /api/cicd/runs/{id}/, or list and filter with GET /api/cicd/runs/?pipeline={id}&normalized_state=running. Cancel with POST /api/cicd/runs/{id}/cancel/. For Jenkins, GET /api/cicd/runs/{id}/logs/ returns the console text once a build number is known.
For providers with native webhooks, point the provider at:
POST /api/cicd/webhook/{connection_id}/
Versus verifies the signature before trusting the payload:
X-Hub-Signature-256.X-Gitlab-Token header matched against your configured secret.X-Versus-Signature (HMAC-SHA256), since Jenkins has no native per-run webhook.Every inbound request is recorded as a WebhookEvent with signature_valid, the raw headers/body, and the normalized event — so even a rejected or malformed callback leaves an audit trail. If a webhook_secret is configured and the signature fails, the request is rejected with 401. A valid event is parsed, matched to its CICDRun by external id, and the run's state is advanced.
The other direction is the interesting one for network-as-code. Your pipeline calls Versus to enact change against your devices, using a scoped integration token rather than a user login:
POST /api/cicd/op/{verb}/
Authorization: Bearer vst_<token>
The verbs are validate, plan, apply, backup, and verify, and they follow a plan/apply model that will feel familiar from infrastructure-as-code tools.
Every verb returns an exit_code your pipeline can branch on, so a script step maps cleanly to a job's pass/fail:
| Code | Meaning |
|---|---|
0 | Success, no changes |
2 | Success, changes applied |
3 | Plan has changes (dry run) |
10 | Blocked by a gate/policy |
20 | Validation error in the intent |
30 | Execution error |
40 | Auth / scope rejected |
HTTP status is 200 for 0/2/3 and 400 otherwise, so a naive HTTP check and a precise exit-code check both work.
// 1. Validate the intent document (structure only, no device I/O)
POST /api/cicd/op/validate/
{ "intent": { "objects": [ { "type": "virtual_server", "name": "vs_app", "devices": [12] } ] } }
→ { "exit_code": 0, "valid": true, "errors": [], "warnings": [], "object_count": 1 }
// 2. Plan against live state → creates a ChangeSet
POST /api/cicd/op/plan/
{ "intent": { … }, "environment": "prod", "device_ids": [12, 13] }
→ { "exit_code": 3, "plan_id": 41, "state_hash": "9f2c…", "risk_class": "medium",
"has_changes": true, "summary": { "total": 4, "add": 1, "modify": 3, "delete": 0 } }
// 3. Apply a plan by id (idempotent)
POST /api/cicd/op/apply/
{ "plan_id": 41, "idempotency_key": "run-8842", "partial_failure_policy": "halt" }
→ { "exit_code": 2, "apply_job_id": 55, "state": "succeeded",
"rollback_backup_ref": "backup-pre-apply-55-…", "per_device_status": { … } }
A few properties worth knowing, because they are what make this safe to run unattended:
plan records a SHA-256 state_hash of the live device state at plan time and stores the diff as a ChangeSet that expires after 24 hours. At apply, Versus re-reads live state and compares hashes; if anything drifted since the plan, it returns STALE_PLAN and refuses to apply. You re-plan, review, and apply again.idempotency_key; a replay with the same key returns the original ApplyJob instead of executing twice. partial_failure_policy is halt (stop on first device error) or continue (attempt all, report per device).rollback_backup_ref on the job, so POST /api/cicd/apply-jobs/{id}/rollback/ has something to restore to.risk_class (low → medium → high → critical) — which downstream promotion gates can act on.Create a token from the platform; the raw secret is shown once at creation and only its SHA-256 hash and a short prefix are stored:
POST /api/cicd/tokens/
{ "name": "gitlab-prod-deploy",
"allowed_verbs": ["validate", "plan", "apply"],
"scope_constraint": { "environment": "prod", "device_ids": [12, 13] },
"expires_at": "2026-12-31T00:00:00Z" }
→ { "id": 9, "prefix": "vst_ab12cd", "token": "vst_…", "status": "active" }
Tokens present as Authorization: Bearer vst_…. Each carries an allowed-verb list (empty means all verbs) and a scope constraint, so a pipeline token can be limited to, say, plan and apply in staging only. A verb the token isn't authorized for returns exit_code 40. Revoke instantly with POST /api/cicd/tokens/{id}/revoke/; set expires_at for automatic expiry. Store the token itself in your CI system's secret store — treat it like any other deployment credential and see the Secret Vault guide for how Versus protects credentials on its side.
For teams that want the repo to be the source of truth, a GitOps binding ties a repo@ref (and a path) to an environment and a device scope, with an apply policy:
plan_only — advisory diffs, never applies.manual_apply — plan on a pull request, apply on approval.auto_apply — apply on merge.A binding can enforce_mode (revert out-of-band drift) or stay observe-only, and auto_rollback can undo a failed apply. You can ask a binding for its current drift on demand with POST /api/cicd/gitops-bindings/{id}/drift_check/, which runs a plan and returns the diff.
Promotion gates sit in front of an apply or an environment promotion. A gate is one of approval, change_window, throughline_verify, or risk_threshold, and it resolves to pending → passed / failed / waived. An operator clears one with POST /api/cicd/promotion-gates/{id}/approve/ or records an exception with .../waive/ (reason required) — the actor and timestamp are captured either way. This is how a risk_class: critical plan can be forced to stop for human sign-off before it reaches production devices.
The Operation API is deliberately small — plan/apply against device state. When your change is a multi-step workflow (pre-checks, staged apply, verify, back-out) rather than a single diff, trigger a Versus workflow from the same pipeline instead: see Running Workflows via the API, which supports section-by-section execution and approvals. Many teams use both — a workflow trigger for orchestrated upgrades, the Operation API for declarative config reconciliation.
Either way, the change reaches your devices over the outbound-only collector plane — Versus never needs an inbound path into your network — and every run, plan, apply, and gate decision is recorded. See Platform Architecture for how the pieces fit.
A typical merge-to-prod pipeline stage:
POST /api/cicd/op/plan/ with the repo's intent → get plan_id, risk_class, and a diff to post back as a PR comment.POST /api/cicd/op/apply/ with that plan_id and an idempotency_key → Versus re-checks the state hash, backs up, and applies.POST /api/cicd/op/verify/ to confirm end-to-end health after the change.POST /api/cicd/apply-jobs/{id}/rollback/.Because the ChangeSets, ApplyJobs, and gate decisions are ordinary platform records, an operator can watch and intervene in the UI while the pipeline runs — the API and the console act on the same objects.