dystopic docs is in beta — content is actively being added.
dystopic
Reference

Integrate into CI/CD

The full CI/CD surface — connecting a repo on the platform, zero-yaml CI resolution, the pipelines-regression GitHub Action's trigger modes, merge-base derivation, fork/draft skips, read-once secret bundles, the Dystopic App and installation mapping, platform-authoritative gating, and why quota and poll-timeout are neutral.

Wiring the regression check into CI is a two-phase system. The GitHub Action runs on every PR, calls odyssey ci review to submit commit-keyed runs, and exits — the platform completes the runs asynchronously and posts the branded Dystopic check via its GitHub App. This decoupling is what makes base-run caching, concurrent per-suite evaluation, and platform-side resilience possible: a job failure never blocks the check from landing.

Connect your repo on the platform and CI runs zero-yaml — the platform resolves the execution recipe, gate, and suite from your umbrella agent. The only file you commit is the workflow. The walkthrough in /docs/flow/cicd is the happy path. This page is the lookup surface: exactly what each trigger does, how the base side is derived, which failures are neutral vs. red, and the sharp edges (connect front door, secret bundles, fork/draft skips, gate resolution).

Connect your repo on the platform (the front door)

Connecting a repo maps it to an umbrella agent — the platform agent that supplies the CI execution recipe, the gate, and the suite binding. Once connected you run CI with no repo config at all beyond the workflow file.

The platform connect flow (all under the API base URL, org resolved from your platform auth — never from the callback query):

EndpointPurpose
GET /ci/app-infoWhere to send the browser to install the GitHub App — returns install_url = github.com/apps/<slug>/installations/new (with a signed, org-bound state param), or null in dev mode (App slug unset), so the UI hides the entry point.
GET /ci/connectThe App-setup callback: exchanges GitHub's OAuth code for the caller's user token and lists only the repos of installations that user actually controls, each carrying a claim_grant. Does NOT auto-claim.
GET /ci/connect/availableThe no-bounce "add another repository" path: for an org that already owns installations, lists their repos via the App token (no fresh OAuth round-trip) with the same per-repo claim_grant.

Ownership proof. /ci/connect refuses (403) to list an installation_id the OAuth-verified user does not control — this is what stops any authenticated caller from enumerating an arbitrary installation's (including private) repo names. Each listed repo is certified with a short-lived claim_grant HMAC bound to (org, installation, repo). The UI echoes that grant into the claim (below).

GET /ci/app-info → send the browser to install_url, install the App on the repo.

GitHub redirects back; GET /ci/connect lists the installation's repos with per-repo claim_grants. (Returning orgs skip the bounce via GET /ci/connect/available.)

The UI claims the chosen repo with POST /api/ci/installations (below), passing the claim_grant.

Headless connect (CLI/SDK). For an org that has already installed the App at least once, the claim is scriptable: dystopic repo connect owner/name [--agent-id N] drives GET /ci/connect/availablePOST /api/ci/installations (echoing the repo's claim_grant) with no browser round-trip; dystopic repo status owner/name is a connected predicate (exit 0/1) and dystopic repo list dumps the org's mappings. The typed SDK methods are get_ci_app_info, list_ci_installations, list_available_ci_repos, and register_ci_installation. Only the first-time App install itself stays browser-bound — GitHub has no headless install API, so connect prints install_url and exits 1 when the repo isn't claimable yet (or, if the App is installed but not on this repo, lists the repos that are).

CI config lives on the umbrella agent — the gate card, the suite binding, and the tools table, all on the platform Settings (dashboard), the CLI/SDK, or REST. CI reads them server-side. There is no per-repo config file to edit.

Installation mapping — claiming a repo↔agent trust row

The branded Dystopic check-run is posted by the platform's GitHub App, not the Actions job. Claiming a repo writes a repo↔org↔umbrella-agent trust mapping. The connect UI drives this endpoint, but you can call it directly:

curl -X POST https://api.pipelines.tech/api/ci/installations \
  -H "Authorization: Bearer $DYSTOPIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "installation_id": <app installation id>,
    "repository_id": <numeric repo id>,
    "repository_owner_id": <numeric owner id>,
    "repo_full_name": "owner/repo",
    "link_agent_id": <umbrella agent id, optional>,
    "claim_grant": "<grant from /ci/connect>"
  }'
  • The repository_id + repository_owner_id are the numeric OIDC/check-posting trust anchor; repo_full_name is display-only.
  • claim_grant is required (else 403, no row written) when the App is fully configured (OAuth + state secret) — it certifies the OAuth-verified GitHub user controls this repo, closing the repo-squat where any org could claim any App-installed unclaimed repo. It is ignored in dev/unconfigured deployments.
  • link_agent_id links the repo to a specific org-owned umbrella agent (must be in your org and not already bound to another installation, else 422 — one repo per umbrella agent). Omit it and the platform auto-provisions one stable org-scoped umbrella agent for the repo and links it.
  • With the App configured, the claimed numeric ids are verified against GitHub (via the App's installation token) before the row is written — a mismatch or unverifiable repo is a 422. Without the App configured, registration is accepted with a logged warning (dev mode: nothing can post checks anyway).
  • UNIQUE(repository_id): a repo maps to exactly one org. Re-posting from the same org updates the row in place (installation renewals rotate installation_id); a repo already claimed by another org is a 409 ("repository is already connected to another organization").
  • List with GET /api/ci/installations. PATCH /api/ci/installations/{id} applies partial updates (only fields present in the body): slack_webhook_url (write-only; masked to scheme+host on read), and agent_id to link/relink the umbrella agent (422 when cross-org or already bound elsewhere; null reverts to the auto-provisioned umbrella).

Without the App (dev / not installed): runs still execute and the Actions job check still reflects the verdict, but no branded Dystopic check is posted, PR comments don't post (they need the App's token), and private repos can't be cloned (no App-minted clone token).

Zero-yaml CI — platform-resolved execution

With the action's platform-suite: true input set (which appends --platform-suite and pins a single named suite — never --all-suites), the Action submits each side as platform_resolved (odyssey ci review --platform-suite): it omits the agent config from POST /api/ci/runs (CiRunSubmission.agent is Optional) and submits no scenarios. The platform resolves the execution recipe from the connected repo's umbrella agent (effective_config.resolve_platform_execution_agent):

  • The umbrella agent's stored config — the same config the in-app check path executes — becomes the CI execution recipe (run command, timeout, tools/ledger, credential_refs).
  • source_git is re-stamped to this submission's commit and marked self: true, so the runner mints its own platform-minted App clone token per side. Stored code-source keys (source_zip_file_id, and source_git's pinned ref/credential keys) are dropped so each side runs its own commit, never a platform-pinned branch or uploaded archive.

Resolution is fail-closed — it returns a 422 naming exactly what to configure when:

  • the repo is not connected on the platform ("connect the repo on the platform");
  • the umbrella agent could not be provisioned yet, or isn't a sandbox agent (platform-resolved execution is sandbox-only);
  • the umbrella agent has no run_command configured ("set the agent's run command on the platform").

Sandbox/code is the default agent mode; platform-resolved CI supports sandbox agents only. See /docs/reference/execution-modes for the Simulated / Executed / Live tool axis, and /docs/reference/port-agent/code-mode for the sandbox runtime.

Platform suite binding and frozen scenarios

Each side submits no scenarios; the platform sources the bound suite's frozen scenarios server-side (byte-identical to an in-app check of that suite).

  • The binding is agents.ci_suite_id on the umbrella agent, set from the dashboard Settings gate card's suite select (or dystopic agents ci-suite set / PUT /api/agents/{id}/ci-suite). When set, that suite is sourced regardless of the workflow's free-text suite name (effective_config.resolve_bound_suite); a defensive ownership check falls through if the bound suite no longer belongs to the agent.
  • A platform-resolved submission carries no scenarios; the submission must resolve to a bound platform Suite or POST /api/ci/runs fails closed with 422 ("no scenarios submitted"). Scenario edits apply to the next run of the bound suite — past checks froze their scenarios and are never rewritten retroactively.

Author scenarios, suites, and the CI binding on the dashboard, with the CLI/SDK (dystopic scenarios …, dystopic suites …, dystopic agents ci-suite set), or via REST (POST /api/agents/{id}/scenarios, POST /api/agents/{id}/suites, PUT /api/agents/{id}/suites/{sid}/scenarios, PUT /api/agents/{id}/ci-suite). See /docs/reference/create-suite.

The committed workflow file

The only file you commit is the workflow at .github/workflows/pipelines-regression.yml. dystopic ci init scaffolds exactly this (platform-first, no local config; --legacy restores the old three-file scaffold):

name: Dystopic regression

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
  # push:
  #   branches: [main]

permissions:
  contents: read
  checks: write
  pull-requests: write

concurrency:
  group: pipelines-regression-${{ github.ref }}
  cancel-in-progress: true

jobs:
  regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: BuildPipelines/pipelines-regression-action@v1
        with:
          api-key: ${{ secrets.DYSTOPIC_API_KEY }}
          platform-suite: true
  • platform-suite: true turns on zero-yaml mode — the action passes --platform-suite and the platform resolves everything from the umbrella agent and its bound CI suite. It requires an action release that includes the input (re-release @v1, or pin a revision that has it, if your copy predates SDK 0.5.0). The optional suite: input names a platform suite (default "default"); a bound CI suite always wins server-side.
  • fetch-depth: 0 is required so the merge-base and head commits are both present locally for the base-vs-head diff.
  • ready_for_review is in types because it is not a default pull_request event type. Without it, a check that skipped while the PR was a draft would hang expected forever once the PR flips to ready.
  • concurrency + cancel-in-progress: true cancels superseded runs on rapid pushes to the same ref, so you don't pay for stale runs.
  • The push: trigger is commented out; uncomment it to gate pushes to main in head-only mode.

Two setup steps the workflow can't do for you: create a dystopic org + API key on the platform and add it as the DYSTOPIC_API_KEY repository secret (GitHub → Settings → Secrets and variables → Actions — without it the Action green-skips with a "no API key" notice, never red), and install and connect the Dystopic GitHub App (above). The workflow file is placed under the git toplevel's .github/workflows/, so it works from a monorepo subdirectory.

Trigger modes

The composite action (Dystopic Agent Regression) handles exactly three GitHub event contexts. Anything else fails loud (unsupported event '<name>', exit 1) rather than submit with empty SHAs.

Base-vs-head, keyed on the derived merge-base.

The action derives base_sha = git merge-base <base-branch-tip> <head-sha> in a dedicated step, then calls:

odyssey ci review --base <merge-base> --head <head-sha>

The PR number rides $PR_NUMBER (from github.event.pull_request.number). Requires fetch-depth: 0. This is the only mode that derives a merge-base.

Base-vs-head, keyed on the event's own SHAs (merge queues).

github.event.pull_request.* is empty on merge_group; the SHAs come off github.event.merge_group.head_sha / .base_sha. The PR number is parsed out of the queue ref, which encodes it:

refs/heads/gh-readonly-queue/<target>/pr-<N>-<oid>
odyssey ci review --base <mg-base-sha> --head <mg-head-sha> --pr-number <N>

If the ref shape doesn't match the pattern, the action falls back to a head-only run against the target branch with a loud ::warning rather than mis-attribute the diff to the wrong PR.

The merge_group base SHA is the moving target-branch tip (or the previous queue entry's merge result), so — unlike the pull_request merge-base — it almost never repeats across runs and the platform's base-run cache rarely hits. Budget roughly 2× agent-run cost per queue entry.

Head-only — the pushed commit graded against the suite's absolute thresholds; no base side.

odyssey ci review --head-only --branch <branch-name> --head <commit-sha>

The only gate tier that applies is block_on_min_pass_rate (the absolute floor); the diff tiers (block_on_constraint, block_on_reviewer_severity) have no base to compare against. --head-only is mutually exclusive with --pr-number and needs --branch (defaults to $GITHUB_REF_NAME).

Merge-base ≠ base-branch tip

For a pull_request, the base side is the PR's merge-base, not the base-branch tip. This matters:

  • github.event.pull_request.base.sha (the tip) goes stale on every synchronize event as the base branch advances.
  • The merge-base is stable per PR across base-branch churn — which is exactly what makes the platform's base-run cache hit (the same base commit resolves to the same cached runs).

Derivation failure (the two commits aren't both fetched) posts a terminal check could not derive the merge-base of … — check out with 'fetch-depth: 0' and turns the job red. Because this fails client-side before any review row exists, the action posts the branded check itself (the platform never learns the run exists).

Fork and draft PRs skip green

Fork and draft guards are pull_request-only concepts (push and merge_group always run in the base repo with repo secrets, and drafts can't enter a merge queue). All of these skip green (run=false, exit 0) — never red:

ConditionWhy it skips
Fork PR (github.event.pull_request.head.repo.fork == true)GitHub withholds secrets and gives a read-only GITHUB_TOKEN, so no branded check can be posted and the platform never learns the run exists. ::notice + step summary; maintainer approval required.
Draft PR (github.event.pull_request.draft == true)Skipped by design; ready_for_review re-triggers it once ready.
No API key (empty api-key input)Belt-and-braces fork/misconfig detection — green-skip with a pointer, not a cryptic 401.

Do not try to "fix" the fork skip with pull_request_target — that runs untrusted PR code with secrets and is a security hole. The intended path is for a maintainer to push the fork's branch into the base repo and open an internal PR.

Secret bundles (read-once, out-of-band)

The umbrella agent's execution recipe references credentials by name (credential_refs). Most resolve server-side from org-stored credentials — nothing to forward, no bundle. A credential that must instead come from a GitHub Actions secret is forwarded as an env: block on the action's step, and the CLI posts it out-of-band; it is never placed in the run submission or the sandbox environment inline:

- uses: BuildPipelines/pipelines-regression-action@v1
  with:
    api-key: ${{ secrets.DYSTOPIC_API_KEY }}
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

The CLI reads them from the action step's environment and posts them out-of-band:

  • Preflight is a union check. Before creating any run, the CLI collects the union of both sides' forwarded credential refs and verifies each is present in the environment. Missing values fail with secret_ref_unfulfilled naming the exact missing names (exit 2, no submissions).
  • Only newly created runs get a bundle. After submission, the CLI posts a bundle only for runs where created == true (cache-reused runs already have theirs) to POST /api/ci/secret-bundles with {"run_ids": [...], "secrets": {"<ENV_NAME>": "<value>", ...}}.
  • Read-once. The endpoint accepts a bundle only while the referenced runs are still pending (undispatched) — 409 otherwise. The store replaces any unconsumed bundle, so a re-post is permitted while undispatched, but the platform consumes it once at dispatch and never stores the values. The response echoes only run_ids, stored_key_names, and stored_key_count — never a value. Body cap is 256KB (413 over).

A side whose credentials all resolve from org-stored credential_refs forwards no bundle — those resolve server-side.

What posts the check — action vs. platform

The Actions job exits after submission (or after the poll budget). It does not wait for the platform to grade the runs. The division of labor:

  • The platform owns the branded Dystopic check lifecycle: it completes the runs asynchronously, assembles the review, and posts/updates the check via the App.
  • The action posts a terminal branded check only for client-side stops where no review row exists yet — merge-base derivation failure, resolution/preflight failures (repo not connected, umbrella agent has no run command, suite unresolved), operational/config errors with no review_id, and quota deferrals. Sections that do carry a review_id (poll timeout, poll failure) stay silent — the review exists and the platform posts asynchronously. Posting from both would double-report.

For required-status-check branch protection, require the Actions job — not (only) the App-posted "Dystopic" check. Every path through the action leaves the job terminal (green skip, green submit/poll-timeout, or a red gate exit), whereas an App-side outage could leave the branded check sitting expected forever.

The PR-comment repost model

The per-suite regression report lands as a new comment at the bottom of the PR timeline on each run (the "Cursor moment"), never edited in place. Older Dystopic comments for the same suite are minimized as OUTDATED (GraphQL minimizeComment), never deleted. The body is the CLI's prebuilt report_markdown; its first line is the suite marker.

Markers looked up (oldest → newest):

  • Current: <!-- dystopic:<suite> -->
  • Teaser (App-authored): <!-- dystopic:<suite>:teaser -->
  • Legacy (pre-rename): <!-- pipelines-regression:<suite> -->

Decision matrix per suite section ("previous" = the newest existing match, verdict grepped from its ## … — <verdict> header line):

SituationAction
Newest match is the App's teaserPATCH it into the full report (one comment per run, App-authored body)
Verdict blocked / action_required, or findings > 0Minimize older + POST a new comment
Verdict pass/neutral and previous was blockedPOST a "resolved" comment + minimize older
Verdict pass/neutral otherwiseSkip (::notice)

Only sections with an assembled review and a non-empty report_markdown are eligible. Commenting needs pull-requests: write; without it (or on any API error) the step logs a ::notice and moves on — it never fails the job.

Gate: platform-authoritative

The gate lives on the umbrella agent — the dashboard Settings gate card (dystopic agents gate set / PUT /api/agents/{id}/gate), under RBAC and audit. It is authoritative, full stop: on a review, _resolve_platform_gate reads the linked agent's gate_config, review_service.create_ci_review stamps it _source: "platform", and it supersedes anything a PR could submit.

Because the gate has a single platform origin:

  • A PR cannot weaken or remove the gate. There is no base-vs-head weakening and no "gate weakened" banner — the divergence concept does not apply.
  • A PR cannot disarm the gate by dropping config. A platform-resolved base carries no submitted gate, so the CLI treats a config_removed / gate_removed disarm outcome as GATED (exit 1, red) when base.platform_resolved is true, instead of neutral. (A platform-resolved head is explicitly NOT treated as gate_removed.)
  • An explicit all-empty advisory gate ({} or all-null tiers) on the umbrella agent is still configured and stays authoritative — only a NULL gate_config leaves the agent ungated.

Gate tiers (AgentGateConfig): block_on_constraint (bool), block_on_reviewer_severity (nit|warning|critical), block_on_min_pass_rate (0..1 floor), block_on_scorers (list of scorer names). See /docs/reference/review-findings for how the assembled review renders gate_status and blocking_reasons.

Quota and poll-timeout are neutral, not failures

Two outcomes deliberately exit 0 (safe to merge) and are not gate blocks:

  • Poll-timeout. The CLI polls the review with a bounded budget (--timeout, default 1200.0s; --poll-interval default 5.0s, backing off ×1.5 to 30s). If the review hasn't reached a terminal status (assembled, failed, skipped) when the budget runs out, it exits 0 with a "still assembling (review <id>); the platform will finish and post the check asynchronously" notice. Timeout is a convenience; the platform owns completion.
  • Quota deferral (HTTP 429). When the platform is at capacity it answers 429; the CLI exits 0 and puts quota_deferred: true in the dump. Nothing was submitted, so the action posts a neutral terminal check ("platform busy … re-run the job to retry") rather than let a required slot hang expected. Capacity is not a verdict — never red on quota.

Exit-code contract

The action swallows the CLI exit (so the preflight check can post first) and re-propagates it in the final Enforce gate exit step (quota deferrals exempt).

CodeMeaning
0Gate pass / neutral / advisory / skip-neutral / poll-timeout / quota deferral — always safe to merge.
1Gate blocked (including a disarm attempt against a platform-resolved base); or an operational failure (HTTP error) after runs were created.
2Usage error, or a named resolution error with no submissions (repo not connected, umbrella agent has no run command, suite doesn't resolve).

Exit 2 always means no runs were submitted — the platform never learns they happened, so the action must post the terminal check itself. A config_removed / gate_removed disarm attempt is the opposite: a review row is created, and it exits 1 (red) against a platform-resolved base.

The full poll/iteration flags (--sample / --repeats / --fresh / --platform-suite) and the base-unresolvable floor-only degradation live in /docs/reference/run-checks.