CI/CD Gate Flow

Overview

The CI/CD Gate Flow describes how StellaOps integrates into continuous integration and deployment pipelines to provide automated security gates. The flow covers CLI-based scanning, policy evaluation, and pass/fail decisions that control pipeline progression.

Audience: DevOps and platform engineers wiring the stella CLI into CI/CD pipelines (GitHub Actions, GitLab CI, Gitea Actions) to gate releases on policy verdicts and attestation checks.

Business Value: Shift-left security by catching vulnerabilities before deployment, with deterministic, reproducible verdicts that integrate into existing DevOps workflows.

Actors

ActorTypeRole
CI PipelineSystemGitHub Actions, GitLab CI, Gitea Actions (template generator); other CI can invoke the CLI directly
StellaOps CLI (stella)ToolRuns scan/gate/verify from the pipeline
GatewayServiceAPI entry point
ScannerServicePerforms image analysis
Policy EngineServiceEvaluates the release gate (api/v1/policy/gate/evaluate)
AttestorServiceSigns scan results (DSSE)

Prerequisites

Source of truth: the CLI command tree is assembled in src/Cli/StellaOps.Cli/Commands/CommandFactory.cs. The CI/CD-facing verbs below are scan, gate, ci, guard, proof, and verify.

Supported CI/CD Platforms

The CLI ships first-class workflow templates via stella ci init for the platforms below. Other CI systems can still invoke the CLI directly (it is a self-contained binary), but no template generator exists for them.

PlatformTemplate generator (stella ci init --platform)Credentials
GitHub ActionsgithubAuthority client credentials
GitLab CIgitlabAuthority client credentials
Gitea ActionsgiteaAuthority client credentials

NOT IMPLEMENTED (no ci init template): Azure DevOps, Jenkins, CircleCI, Tekton. stella ci init accepts only github, gitlab, gitea, or all (see CiCommandGroup.cs). These platforms can run the CLI by hand but are not generated.

Flow Diagram

┌─────────────────────────────────────────────────────────────────────────────────┐
│                            CI/CD Gate Flow                                       │
└─────────────────────────────────────────────────────────────────────────────────┘

┌────────────┐  ┌───────────┐  ┌─────────┐  ┌─────────┐  ┌────────┐  ┌─────────┐
│ CI Pipeline│  │StellaOps  │  │ Gateway │  │ Scanner │  │ Policy │  │ Attestor│
│            │  │   CLI     │  │         │  │         │  │        │  │         │
└─────┬──────┘  └─────┬─────┘  └────┬────┘  └────┬────┘  └───┬────┘  └────┬────┘
      │               │             │            │           │            │
      │ docker build  │             │            │           │            │
      │───────┐       │             │            │           │            │
      │       │       │             │            │           │            │
      │<──────┘       │             │            │           │            │
      │               │             │            │           │            │
      │ stella scan   │             │            │           │            │
      │ image <ref>   │             │            │           │            │
      │ + gate eval   │             │            │           │            │
      │──────────────>│             │            │           │            │
      │               │             │            │           │            │
      │               │ POST /scans │            │           │            │
      │               │────────────>│            │           │            │
      │               │             │            │           │            │
      │               │             │ Dispatch   │           │            │
      │               │             │───────────>│           │            │
      │               │             │            │           │            │
      │               │             │            │ Analyze   │           │
      │               │             │            │ image     │           │
      │               │             │            │───┐       │           │
      │               │             │            │   │       │           │
      │               │             │            │<──┘       │           │
      │               │             │            │           │            │
      │               │             │            │ Evaluate  │           │
      │               │             │            │──────────>│           │
      │               │             │            │           │            │
      │               │             │            │           │ Apply     │
      │               │             │            │           │ rules     │
      │               │             │            │           │───┐       │
      │               │             │            │           │   │       │
      │               │             │            │           │<──┘       │
      │               │             │            │           │            │
      │               │             │            │ Verdict   │           │
      │               │             │            │<──────────│           │
      │               │             │            │           │            │
      │               │             │            │ Sign      │           │
      │               │             │            │──────────────────────>│
      │               │             │            │           │            │
      │               │             │            │ DSSE      │            │
      │               │             │            │<──────────────────────│
      │               │             │            │           │            │
      │               │             │ Result     │           │            │
      │               │             │<───────────│           │            │
      │               │             │            │           │            │
      │               │ Verdict     │            │           │            │
      │               │<────────────│            │           │            │
      │               │             │            │           │            │
      │ Exit code     │             │            │           │            │
      │ (0=pass,      │             │            │           │            │
      │  1=fail)      │             │            │           │            │
      │<──────────────│             │            │           │            │
      │               │             │            │           │            │
      │ [if pass]     │             │            │           │            │
      │ docker push   │             │            │           │            │
      │───────┐       │             │            │           │            │
      │       │       │             │            │           │            │
      │<──────┘       │             │            │           │            │
      │               │             │            │           │            │

Step-by-Step

1. Pipeline Configuration

Generate these workflows with stella ci init --platform <platform> --template gate.

Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli@sha256:<digest> release reference. The wrapper mounts the workspace and forwards Authority credentials and CI provenance by environment-variable name. Configure a confidential Authority client using STELLAOPS_AUTHORITY_URL, STELLAOPS_AUTHORITY_CLIENT_ID, STELLAOPS_AUTHORITY_CLIENT_SECRET and the tenant.

For a private CA, provide a PEM CA bundle through STELLAOPS_CLI_CA_BUNDLE. The wrapper mounts it read-only; TLS verification remains enabled.

For an air-gap mirror, use its qualified digest reference. For preloaded runners, generate with --offline --cli-image sha256:<qualified-local-image-id>. Retain the release qualification receipt and verify the transferred image archive checksum before loading it. The offline CLI installer only inspects the preloaded digest. Prepare self-hosted runners with Docker, jq, provider actions and any GitLab job/service images locally available.

Github

# StellaOps Release Gate Workflow
# Generated by: stella ci init --platform github --template gate
# Documentation: https://docs.stellaops.io/ci/github-actions

name: StellaOps Release Gate

on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  security-events: write  # For SARIF upload

env:
  STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
  STELLAOPS_CLI_IMAGE: ${{ vars.STELLAOPS_CLI_IMAGE }}
  STELLAOPS_AUTHORITY_URL: ${{ secrets.STELLAOPS_AUTHORITY_URL }}
  STELLAOPS_AUTHORITY_CLIENT_ID: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_ID }}
  STELLAOPS_AUTHORITY_CLIENT_SECRET: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_SECRET }}
  STELLAOPS_AUTHORITY_TENANT: ${{ vars.STELLAOPS_AUTHORITY_TENANT }}
  # A repository in a registry connected to Stella Ops (`stella registry list`), for example
  # registry.example.com/team/app. The scan runs server-side and pulls the pushed image from it.
  IMAGE_REPOSITORY: ${{ vars.STELLAOPS_IMAGE_REPOSITORY }}
  # What the gate does while the tenant has no baseline: block (default), warn, or bootstrap
  # (set it for the first run on a new installation to create the tenant's first baseline).
  STELLAOPS_MISSING_BASELINE: ${{ vars.STELLAOPS_MISSING_BASELINE || 'block' }}
  # The Attestor signing key the build provenance is signed with (`stella attest build`).
  STELLAOPS_ATTESTOR_KEY_ID: ${{ vars.STELLAOPS_ATTESTOR_KEY_ID }}
  STELLAOPS_BUILDER_ID: ${{ vars.STELLAOPS_BUILDER_ID }} # actual reported builder URI

jobs:
  gate:
    name: Release Gate Evaluation
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up StellaOps CLI
        run: |
          # Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
          # release digest, its air-gap mirror, or a qualified preloaded local image ID.
          export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
          printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
            echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
            exit 2
          }
          docker pull "$STELLA_CLI_IMAGE"
          mkdir -p .stella-ci/bin
          cat > .stella-ci/bin/stella <<'STELLA_CLI'
          #!/bin/sh
          set -eu
          : "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
          set -- "$STELLA_CLI_IMAGE" "$@"
          # Forward credentials and CI provenance by variable name; never print values.
          for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
            set -- --env "$name" "$@"
          done
          if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
            test -f "$STELLAOPS_CLI_CA_BUNDLE"
            set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
          fi
          if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
            set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
          fi
          exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
            --user "$(id -u):$(id -g)" --env HOME=/tmp \
            --volume "$PWD:/workspace" --workdir /workspace "$@"
          STELLA_CLI
          chmod +x .stella-ci/bin/stella
          export PATH="$PWD/.stella-ci/bin:$PATH"
          echo "STELLA_CLI_IMAGE=$STELLA_CLI_IMAGE" >> "$GITHUB_ENV"
          echo "$PWD/.stella-ci/bin" >> "$GITHUB_PATH"


      - name: Build and Push Container Image
        # The scan, the provenance and the gate all name the manifest digest the registry
        # returned for this push; a tag never reaches them.
        run: |
          echo "${{ secrets.STELLAOPS_REGISTRY_PASSWORD }}" \
            | docker login "${IMAGE_REPOSITORY%%/*}" -u "${{ secrets.STELLAOPS_REGISTRY_USERNAME }}" --password-stdin
          docker build -t "$IMAGE_REPOSITORY:${{ github.sha }}" .
          docker push "$IMAGE_REPOSITORY:${{ github.sha }}"
          IMAGE_REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REPOSITORY:${{ github.sha }}")
          test -n "$IMAGE_REF"
          echo "IMAGE_REF=$IMAGE_REF" >> "$GITHUB_ENV"
          echo "IMAGE_DIGEST=${IMAGE_REF#*@}" >> "$GITHUB_ENV"

      - name: Record Build Provenance
        # Records caller-reported SLSA v1 through Evidence sign/submit/exact export.
        # commit/repo/run come from GITHUB_*; the configured builder URI is required.
        # --subject is the pushed image digest. --attach additionally publishes an OCI referrer.
        run: |
          stella attest build --subject "$IMAGE_REF"

      - name: Scan Image
        id: scan
        run: |
          stella registry scan "$IMAGE_REF" --wait --json > stellaops-scan.json
          SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
          stella scan sarif --scan-id "$SCAN_ID" --output results.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif

      - name: Evaluate Gate
        id: gate
        # Without --baseline the gate compares against the last approved baseline
        # (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
        # 10 warn, 2 input error, 11/12/99 operational errors.
        run: |
          set +e
          stella gate evaluate \
            --image "$IMAGE_DIGEST" \
            --missing-baseline "$STELLAOPS_MISSING_BASELINE" \
            --commit "${{ github.sha }}" \
            --output json > gate-result.json
          EXIT_CODE=$?
          set -e
          echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
          echo "decision_id=$(jq -r '.decisionId // empty' gate-result.json 2>/dev/null)" >> "$GITHUB_OUTPUT"

      - name: Gate Summary
        if: always() && steps.gate.outputs.decision_id != ''
        run: |
          echo "## Release Gate Result" >> "$GITHUB_STEP_SUMMARY"
          stella gate status \
            --decision-id "${{ steps.gate.outputs.decision_id }}" \
            --output table >> "$GITHUB_STEP_SUMMARY" || true

      - name: Check Gate Status
        if: steps.gate.outputs.exit_code != '0'
        run: |
          echo "::error::Release gate check failed (exit ${{ steps.gate.outputs.exit_code }})"
          exit ${{ steps.gate.outputs.exit_code }}

Gitlab

# StellaOps GitLab CI Pipeline
# Generated by: stella ci init --platform gitlab --template gate
# Documentation: https://docs.stellaops.io/ci/gitlab-ci

stages:
  - build
  - scan
  - gate
  - deploy

variables:
  STELLAOPS_BACKEND_URL: $STELLAOPS_BACKEND_URL
  DOCKER_TLS_CERTDIR: "/certs"
  # What the gate does while the tenant has no baseline: block, warn, or bootstrap (set it as a
  # project or pipeline variable for the first run on a new installation).
  STELLAOPS_MISSING_BASELINE: "block"
  # The Attestor signing key the build provenance is signed with (`stella attest build`).
  STELLAOPS_ATTESTOR_KEY_ID: $STELLAOPS_ATTESTOR_KEY_ID
  STELLAOPS_BUILDER_ID: $STELLAOPS_BUILDER_ID # actual reported builder URI

.stellaops-setup:
  image: docker:24-dind
  services: [docker:24-dind]
  before_script:
    - apk add --no-cache jq
    - |
      # Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
      # release digest, its air-gap mirror, or a qualified preloaded local image ID.
      export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
      printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
        echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
        exit 2
      }
      docker pull "$STELLA_CLI_IMAGE"
      mkdir -p .stella-ci/bin
      cat > .stella-ci/bin/stella <<'STELLA_CLI'
      #!/bin/sh
      set -eu
      : "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
      set -- "$STELLA_CLI_IMAGE" "$@"
      # Forward credentials and CI provenance by variable name; never print values.
      for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
        set -- --env "$name" "$@"
      done
      if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
        test -f "$STELLAOPS_CLI_CA_BUNDLE"
        set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
      fi
      if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
        set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
      fi
      exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
        --user "$(id -u):$(id -g)" --env HOME=/tmp \
        --volume "$PWD:/workspace" --workdir /workspace "$@"
      STELLA_CLI
      chmod +x .stella-ci/bin/stella
      export PATH="$PWD/.stella-ci/bin:$PATH"


build:
  stage: build
  extends: .stellaops-setup
  image: docker:24-dind
  services:
    - docker:24-dind
  script:
    # $CI_REGISTRY_IMAGE must be in a registry connected to Stella Ops (`stella registry list`).
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA | tee /tmp/push.log
    # The pushed manifest digest, read from the docker push output, is what the scan, the
    # provenance and the gate name; later jobs receive it through the dotenv report.
    - export IMAGE_DIGEST=$(sed -n 's/.*digest: \(sha256:[0-9a-f]*\).*/\1/p' /tmp/push.log | head -n1)
    - test -n "$IMAGE_DIGEST"
    - echo "IMAGE_DIGEST=$IMAGE_DIGEST" > build.env
    # Record signed caller-reported SLSA v1 through Evidence and retain its exact envelope.
    # commit/repo/pipeline auto-fill from CI_COMMIT_SHA/CI_PROJECT_URL/CI_PIPELINE_URL.
    - stella attest build --subject "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
  artifacts:
    reports:
      dotenv: build.env
  rules:
    - if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID

scan:
  stage: scan
  extends: .stellaops-setup
  script:
    - stella registry scan "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST" --wait --json > scan-results.json
    - export SCAN_ID=$(jq -r '.status.scanId' scan-results.json)
    - stella scan sarif --scan-id "$SCAN_ID" --output scan.sarif
    - stella sbom generate --image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST" --output sbom.cdx.json
    - stella sbom upload --file sbom.cdx.json
      --artifact "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
  artifacts:
    paths:
      - sbom.cdx.json
      - scan-results.json
      - scan.sarif
  rules:
    - if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID

gate:
  stage: gate
  extends: .stellaops-setup
  script:
    # Without --baseline the gate compares against the last approved baseline
    # (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
    # 10 warn, 2 input error, 11/12/99 operational errors.
    - |
      set +e
      stella gate evaluate \
        --image "$IMAGE_DIGEST" \
        --missing-baseline "$STELLAOPS_MISSING_BASELINE" \
        --commit "$CI_COMMIT_SHA" \
        --output json > gate-result.json
      EXIT_CODE=$?
      set -e
      if [ "$EXIT_CODE" != "0" ]; then
        echo "Release gate failed (exit $EXIT_CODE)"
        exit $EXIT_CODE
      fi
  artifacts:
    when: always
    paths:
      - gate-result.json
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy:
  stage: deploy
  script:
    - echo "Deploy $CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  needs:
    - gate

Gitea

# StellaOps Gitea Actions Release Gate Workflow
# Generated by: stella ci init --platform gitea --template gate
# Documentation: https://docs.stellaops.io/ci/gitea-actions

name: StellaOps Release Gate

on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main]

env:
  STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
  STELLAOPS_CLI_IMAGE: ${{ vars.STELLAOPS_CLI_IMAGE }}
  STELLAOPS_AUTHORITY_URL: ${{ secrets.STELLAOPS_AUTHORITY_URL }}
  STELLAOPS_AUTHORITY_CLIENT_ID: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_ID }}
  STELLAOPS_AUTHORITY_CLIENT_SECRET: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_SECRET }}
  STELLAOPS_AUTHORITY_TENANT: ${{ vars.STELLAOPS_AUTHORITY_TENANT }}
  # A repository in a registry connected to Stella Ops (`stella registry list`), for example
  # registry.example.com/team/app. The scan runs server-side and pulls the pushed image from it.
  IMAGE_REPOSITORY: ${{ vars.STELLAOPS_IMAGE_REPOSITORY }}
  # What the gate does while the tenant has no baseline: block (default), warn, or bootstrap
  # (set it for the first run on a new installation to create the tenant's first baseline).
  STELLAOPS_MISSING_BASELINE: ${{ vars.STELLAOPS_MISSING_BASELINE || 'block' }}
  # The Attestor signing key the build provenance is signed with (`stella attest build`).
  STELLAOPS_ATTESTOR_KEY_ID: ${{ vars.STELLAOPS_ATTESTOR_KEY_ID }}

jobs:
  gate:
    runs-on: ubuntu-latest
    env:
      STELLAOPS_BUILDER_ID: ${{ vars.STELLAOPS_BUILDER_ID }} # actual reported builder URI
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up StellaOps CLI
        run: |
          # Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
          # release digest, its air-gap mirror, or a qualified preloaded local image ID.
          export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
          printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
            echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
            exit 2
          }
          docker pull "$STELLA_CLI_IMAGE"
          mkdir -p .stella-ci/bin
          cat > .stella-ci/bin/stella <<'STELLA_CLI'
          #!/bin/sh
          set -eu
          : "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
          set -- "$STELLA_CLI_IMAGE" "$@"
          # Forward credentials and CI provenance by variable name; never print values.
          for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
            set -- --env "$name" "$@"
          done
          if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
            test -f "$STELLAOPS_CLI_CA_BUNDLE"
            set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
          fi
          if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
            set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
          fi
          exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
            --user "$(id -u):$(id -g)" --env HOME=/tmp \
            --volume "$PWD:/workspace" --workdir /workspace "$@"
          STELLA_CLI
          chmod +x .stella-ci/bin/stella
          export PATH="$PWD/.stella-ci/bin:$PATH"
          echo "STELLA_CLI_IMAGE=$STELLA_CLI_IMAGE" >> "$GITHUB_ENV"
          echo "$PWD/.stella-ci/bin" >> "$GITHUB_PATH"

      - name: Build and Push Image
        # The scan, the provenance and the gate all name the manifest digest the registry
        # returned for this push; a tag never reaches them.
        run: |
          echo "${{ secrets.STELLAOPS_REGISTRY_PASSWORD }}" \
            | docker login "${IMAGE_REPOSITORY%%/*}" -u "${{ secrets.STELLAOPS_REGISTRY_USERNAME }}" --password-stdin
          docker build -t "$IMAGE_REPOSITORY:${{ gitea.sha }}" .
          docker push "$IMAGE_REPOSITORY:${{ gitea.sha }}"
          IMAGE_REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REPOSITORY:${{ gitea.sha }}")
          test -n "$IMAGE_REF"
          echo "IMAGE_REF=$IMAGE_REF" >> "$GITHUB_ENV"
          echo "IMAGE_DIGEST=${IMAGE_REF#*@}" >> "$GITHUB_ENV"

      - name: Record Build Provenance
        # The builder URI and actual commit/repository/run are required.
        # If GitHub-compatible runner variables are absent, set STELLAOPS_GIT_COMMIT,
        # STELLAOPS_GIT_REPO and STELLAOPS_PIPELINE_REF to the actual execution inputs.
        run: |
          stella attest build --subject "$IMAGE_REF"

      - name: Scan and Gate
        # Without --baseline the gate compares against the last approved baseline
        # (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
        # 10 warn, 2 input error, 11/12/99 operational errors.
        run: |
          stella registry scan "$IMAGE_REF" --wait --json > stellaops-scan.json
          SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
          stella scan sarif --scan-id "$SCAN_ID" --output results.sarif
          stella gate evaluate \
            --image "$IMAGE_DIGEST" \
            --missing-baseline "$STELLAOPS_MISSING_BASELINE" \
            --commit "${{ gitea.sha }}"

2. CLI Authentication

Authentication is handled by stella auth login. It acquires and caches an access token from the configured StellaOps Authority. The grant used depends on how the CLI profile is configured (see CommandFactory.BuildAuthCommand and CommandHandlers.HandleAuthLoginAsync):

# Acquire and cache a token (uses the configured client credentials in CI)
stella auth login

# CI/kiosk: keep the token in process memory only (do not write the secrets blob / OS keystore)
stella auth login --no-save        # implied when STELLAOPS_LOGIN_NO_SAVE=1 in a non-interactive shell

# Force re-authentication, ignoring any cached token
stella auth login --force
CommandDescription
stella auth loginAcquire and cache an access token
stella auth logoutRemove cached tokens for the current credentials
stella auth statusDisplay cached token status
stella auth whoamiDisplay cached token claims (subject, scopes, expiry)

CORRECTED: there are no --token, --oidc, or --keyless flags on stella auth login. The only flags are --force and --no-save. Generated workflows configure Authority client credentials; no published Stella Ops GitHub authentication action exists. The git provider’s CI token (e.g. CI_JOB_TOKEN) is used by SCM steps, not passed to auth login.

3. Scan Execution

CORRECTED: there is no flat stellaops scan <image> --policy ... --fail-on violation command. The scan command is a group (CommandFactory.BuildScanCommand) whose subcommands include run, upload, sarif, entrytrace, replay, layers, layer-sbom, recipe, diff, delta, verify-patches, download, workers, secrets, and submit-list. In CI the scan step emits results (SARIF/JSON/SBOM); the pass/fail decision is a separate gate evaluate step.

The sample scan image group is retired. stella image inspect <ref> --print-layers reads OCI metadata; it does not submit a scan. The generated templates submit the scan to the backend instead: stella registry scan <ref> submits it for an image in a registry connected to Stella Ops, --wait polls until it reaches a terminal state, and --json writes the result with the scan id at .status.scanId. stella scan sarif --scan-id <id> then exports that scan as SARIF.

The generated templates run the scan step like this (from CiTemplates.cs):

stella registry scan registry.example.com/myorg/myapp:v1.2.3 --wait --json > stellaops-scan.json
SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
stella scan sarif --scan-id "$SCAN_ID" --output scan-results.sarif

Relevant scan subcommands

SubcommandPurpose
stella scan runExecute a scanner bundle with the configured runner (--runner dotnet|self|docker, --entry, --target). This is the implemented scanning verb.
stella scan submit-listSubmit a list of images/artifacts to the backend for scanning
stella scan upload --file <path>Upload completed scan results to the backend
stella scan sarif --scan-id <id>Export Scanner’s SARIF 2.1.0 document (--output/-o, --pretty); preserve the owner’s metadata
stella scan replayDeterministic verdict replay from explicit input hashes (--artifact, --manifest, --feeds, --policy)
stella image inspect <ref> --print-layersPull OCI metadata and layer digests from the registry. The sample scan image group is retired.

The scan sarif destination flags are --output/-o (file, defaults to stdout), with --pretty for indentation. Severity filtering is not a CLI option. scan run forwards scanner-args to the runner rather than taking a --format/--output pair. Policy evaluation and the deploy gate are applied by stella gate evaluate (below), which takes the policy/baseline — not by the scan step.

3a. Source-tree SBOM (stella sbom generate --directory)

For a source-side bill of materials — generated from a checked-out working tree before (or instead of) an image build — use the deterministic sbom generate verb (Commands/Sbom/SbomGenerateCommand.cs). Unlike the image-analysis helpers under scan image, this is a real, implemented, reproducible SBOM producer.

# Source-tree SBOM from the checked-out repo (deterministic; offline-friendly)
stella sbom generate \
  --directory ./src \
  --format cyclonedx \
  --output sbom.cdx.json

Flags (verified against SbomGenerateCommand.cs):

FlagPurpose
--directory/-dLocal directory to scan (mutually exclusive with --image/-i).
--format/-fcyclonedx, spdx, or both.
--output/-oOutput file path (or directory when --format both).
--source-dateDeterministic SBOM timestamp (Unix epoch or ISO-8601). For --directory this is the only timestamp source; defaults to SOURCE_DATE_EPOCH, else 1970-01-01.
--forceOverwrite an existing output file.
--show-hashPrint the golden hash after generation (verify with stella sbom hash / sbom verify).

Because it is timestamp-pinned and deterministic, the same source tree yields a byte-identical SBOM (and golden hash) across machines — suitable as a CI artifact and as a material for build provenance.

3c. Build provenance (stella attest build)

After the image is built (and pushed, so a manifest digest exists), mint build provenance — the commit -> artifact lineage as an in-toto link — and submit it to the Attestor’s links endpoint (POST /api/evidence/v1/attestor/links). The endpoint signs the link and returns the DSSE envelope to the pipeline; it records no Evidence entry and no transparency-log entry for the pushed digest (the command warns that no transparency-log entry was returned). The ci init gate templates (§1) emit this step automatically.

The Console counts CI evidence as received only when Evidence holds a provenance attestation for a release component digest whose statement records the repository, commit and pipeline run (Release Orchestrator GET /api/v1/release-orchestrator/first-delivery/ci-receipt; see the first-delivery API). Because the links endpoint records nothing, a pipeline built from these templates does not yet complete that step; a scan or a commit recorded on the release component does not complete it either.

# commit / repo / pipeline auto-fill from CI env (CI_COMMIT_SHA, CI_PROJECT_URL, CI_PIPELINE_URL,
# GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_RUN_ID, ...). --subject is the artifact digest.
stella attest build --subject registry/app@sha256:<digest>

# Air-gapped / no backend: emit the (clearly-labelled) UNSIGNED predicate locally instead of submitting.
stella attest build --subject registry/app@sha256:<digest> --offline --output provenance.intoto.json

Key flags (verified against Commands/AttestCommandGroup.cs): --subject/-s (required — the artifact ref/digest; --subject-digest supplies the digest when the ref carries none), --commit/-c, --repo/-r, --builder/-b, --pipeline, --offline (emit unsigned, do not submit), --attach (also push the signed DSSE as an OCI referrer), --no-rekor (sign but skip the transparency log). Missing required lineage (subject digest, commit, repo) is refused with an actionable error — no field is fabricated. Verify a minted envelope with stella attest verify --attestation <dsse.json> --key <public.pem> (add --uuid <rekor-uuid> for a live inclusion check; --offline labels inclusion as NOT CHECKED rather than claiming it).

3b. AI Code Guard (optional)

Run AI code guard checks on a change set and emit CI-friendly output (GuardCommandGroup.cs):

stella guard run . \
  --policy .stellaops.yml \
  --format sarif \
  --output guard.sarif

Key options: a positional path argument (defaults to .), --policy/-p, --base/--head (diff analysis), --format/-f (json default, sarif, gitlab, table), --output/-o (defaults to stdout), --confidence (default 0.7), --min-severity (default low), --sealed (deterministic, no network), --categories/-c, --exclude/-e, --server.

CORRECTED: the output flag is --output/-o, not --out. Exit codes (GuardExitCodes) are:

VerdictExit Code
Pass0
Pass with warnings1
Fail (blocking findings)2
Input error10
Network error11
Analysis error12
Unknown error99

So a blocking verdict exits 2, not 1; warnings exit 1.

4. Policy Evaluation

Policy engine evaluates findings against CI-specific rules:

# Policy Set: production
version: "stella-dsl@1"
name: production

rules:
  - name: block-critical
    condition: severity == 'critical' AND vex_status != 'not_affected'
    action: FAIL

  - name: block-high-unfixed
    condition: severity == 'high' AND fixed_version == null
    action: FAIL

  - name: block-known-exploited
    condition: kev == true
    action: FAIL

  - name: require-sbom
    condition: sbom_complete == false
    action: FAIL
    message: "SBOM must cover all detected packages"

gates:
  ci:
    max_critical: 0
    max_high_unfixed: 0
    require_attestation: true

The policy/gate YAML above is illustrative of the policy authoring shape; the authoritative DSL and gate-condition grammar live in the Policy module. The CI step that invokes the gate is stella gate evaluate (below).

4b. Gate Evaluation (stella gate)

The deploy decision is produced by the gate command group (GateCommandGroup.cs), which calls the Policy Engine’s gate endpoint (POST api/v1/policy/gate/evaluate) and maps the result to a process exit code.

stella gate evaluate \
  --image "$IMAGE_DIGEST" \
  --baseline last-approved \
  --policy <policy-id> \
  --branch "$CI_COMMIT_BRANCH" \
  --commit "$CI_COMMIT_SHA" \
  --pipeline "$CI_PIPELINE_ID" \
  --env production \
  --output json
OptionDescription
--image / -iImage manifest digest to evaluate (sha256: and 64 hex characters). A tag or a name@digest reference exits 2 without calling the gate. Required.
--baseline / -bBaseline to compare against: last-approved (the default when omitted), previous-build, production (alias production-deployed), branch-base, or a snapshot (snapshot:<id> or a snapshot GUID). Any other value exits 2: the gate would resolve it as last-approved
--missing-baselineWhat the gate does when no baseline exists: block (the server default), warn, or bootstrap (create the tenant’s first baseline; only without --baseline). Any other value, or bootstrap with --baseline, exits 2 without calling the gate
--policy / -pPolicy ID to use for evaluation
--allow-overrideAllow override of blocking gates (requires --justification)
--justification / -jJustification for override
--branch, --commit, --pipeline, --envGit/CI context recorded with the decision
--output / -otable (default), json, or exit-code-only
--timeoutRequest timeout in seconds (default 60)

Sibling subcommands include stella gate score (score-based gating) and stella gate status, which reads a decision back by the decisionId that gate evaluate printed:

stella gate status --decision-id "$DECISION_ID" --output json

gate status calls GET api/v1/policy/gate/decision/{decisionId} (scope policy:read), prints the decision in the --output format (table, json, or exit-code-only), and exits with the decision’s outcome code, as gate evaluate does (§5). The gate keeps a decision for 30 minutes after evaluation; an unknown or expired decision exits 12.

Without --missing-baseline, a tenant with no baseline is denied (missing-baseline, exit 1). A fresh install creates its first baseline once with stella gate evaluate --image <digest> --missing-baseline bootstrap (no --baseline): the gate enrolls the scanned image automatically (a policy run over its scanned components, then the baseline snapshot). In that mode the CLI requests policy:run policy:write instead of policy:run, because the gate refuses initial-baseline enrollment without write authority; a client that may not have policy:write gets no token and the step fails. The first-baseline procedure describes that path, the cases in which it is refused, and the manual fallback (submit a policy run, wait for it to complete, then evaluate with bootstrap). Later stella gate evaluate calls use that baseline.

4c. Template Scaffolding (stella ci)

stella ci (CiCommandGroup.cs) generates and lists CI/CD workflow templates:

# Scaffold a release-gate workflow into the current repo
stella ci init --platform github --template gate

# List the available templates
stella ci list

# Check that a template path exists (it does not parse or check the workflow)
stella ci validate .github/workflows/stellaops-gate.yml

stella ci validate reports success for any existing file or directory; it does not parse the workflow, its stella commands or their options. The generated templates’ commands are checked against the CLI command tree by the CLI test suite (CiTemplatesCommandParseTests), not by this command.

Option (ci init)Values
--platform / -pgithub, gitlab, gitea, all (required)
--template / -tgate (default), scan, verify, full
--output / -oOutput directory (default: current directory)
--offlineGenerate offline-friendly bundle with pinned digests
--cli-imageQualified CLI registry digest or preloaded local sha256 image ID
--force / -fOverwrite existing files

--template selects the workflow steps. Gate templates include build provenance, a scan, and gate evaluation; scan and verify templates provide their respective workflows. full emits the provider’s complete template set.

5. Verdict and Exit Code

stella gate evaluate, stella gate status and stella guard run translate their verdict into a process exit code. The values below are the canonical codes (GateExitCodes / GuardExitCodes; the gate codes follow the CLI-wide convention in exit codes):

Verdictgate evaluate / gate statusguard runPipeline Result
PASS00Continue
WARN101 (a warning fails the guard)Continue or block (pipeline decides; the code is non-zero)
FAIL / block11Block deployment
Input error210Invalid parameters
Network error1111Unable to reach gate/scanner service
Policy/analysis error1212Evaluation failed (for gate status, also an unknown or expired decision)
Unknown error9999Unexpected failure

There is no --fail-on flag controlling this. The proof verify command uses a different exit-code scheme (see §5a). Pipelines branch on the process exit code. The JSON exitCode field is the policy engine’s own code (0 pass, 1 warn, 2 fail) and does not equal the process exit code for a warning or a block.

5a. Attestation / Proof Verification

Sprint: SPRINT_20260112_004_DOC_cicd_gate_verification

Before deploying, pipelines verify the attestation chain and (online) Rekor inclusion. This ensures attestation integrity and a tamper-evident audit trail. Two CLI surfaces apply here:

CORRECTED: there is no stella proof verify --image ... --attestation-type ... --check-rekor --fail-on-missing form, and no --ledger-path. Those flags do not exist. proof verify operates on a --bundle file. Image-level attestation verification is stella verify image. There is no stella evidence-pack verify; the evidence-pack verb is an export subcommand (stella export evidence-pack) — bundle verification is stella proof verify or stella verify bundle.

Verify the attestation chain on an image (online)

stella verify image ghcr.io/org/myapp@sha256:... \
  --require sbom vex decision \
  --strict \
  --output json

stella verify image flags: positional reference; --require/-r (required attestation types — defaults to sbom vex decision; also approval); --trust-policy (path to a YAML/JSON trust policy); --output/-o (table/json/sarif); --strict (fail if any required attestation is missing); --allow-unsigned (opt out of the trust-policy gate with a WARN — see below).

Honest default (SPRINT_20260704_001 / CAT-3): with no --trust-policy configured, verify image now fails closed with an explicit No trust policy configured message instead of silently passing — signer trust cannot be enforced without a policy. Pass --allow-unsigned to explicitly proceed (attestations are structurally parsed but signer trust is NOT enforced; emitted with a [WARN]). The image transparency signal is annotation-presence only and is reported as such (transparency: presence-only), never as a verified inclusion proof.

The generated verify workflow templates (stella ci init --template verify, CiTemplates.cs) run stella verify image <ref> --require sbom --strict. --require/-r is a single repeatable option that takes attestation-type values (sbom, vex, decision, approval); widen it to the attestations your pipeline produces.

Verify an exported bundle (offline / air-gapped)

# proof verify operates on an exported attestation bundle (.tar.gz)
stella proof verify \
  --bundle /path/to/attestation-bundle.tar.gz \
  --offline \
  --output json

# E2E evidence bundle (replay-checked)
stella verify bundle --bundle /path/to/evidence-pack.tar.gz --output json

stella proof verify flags: --bundle/-b (required, path to .tar.gz); --offline (skip Rekor / transparency verification); --output/-o (text default, or json); --verbose. (proof also exposes proof spine show <bundle-id>, but spine retrieval is not yet implemented — it prints a placeholder.)

CORRECTED: the exit codes that stella proof verify actually returns come from AttestationBundleExitCodes(Services/Models/AttestationBundleModels.cs), surfaced via result.ExitCode in ProofCommandGroup.HandleVerifyAsync. The ProofExitCodes type (Proof/ProofExitCodes.cs) defines the advisory §15.2 scheme but the verify handler only uses it for the catch-all SystemError (exception path); a missing bundle returns AttestationBundleExitCodes.FileNotFound (6), not ProofExitCodes.KeyRevoked. The two schemes diverge at codes 2–8, so trust the runtime (AttestationBundleExitCodes) table below, not the advisory ProofExitCodes numbering.

stella proof verify runtime exit codes (AttestationBundleExitCodes):

CodeMeaning
0Success — bundle verified
1General verification failure
2Checksum mismatch (bundle integrity)
3Signature failure (DSSE envelope)
4Missing transparency / Rekor inclusion (online mode)
5Format error (malformed bundle)
6Bundle file not found
7Import failed

For reference, the advisory ProofExitCodes scheme (defined but largely unused by the verify handler) is: 0 Success · 1 PolicyViolation · 2 SystemError · 3 VerificationFailed · 4 TrustAnchorError · 5 RekorVerificationFailed · 6 KeyRevoked · 7 OfflineModeError · 8 InputError. Do not rely on this numbering for proof verify branching today.

GitHub Actions Integration

- name: Verify attestation chain
  run: |
    stella verify image ghcr.io/org/myapp@${{ steps.build.outputs.digest }} \
      --require sbom vex decision \
      --strict

- name: Push to registry (only if verified)
  if: success()
  run: |
    docker push ghcr.io/org/myapp:${{ github.sha }}

NOT IMPLEMENTED / external: the cosign verify-attestation examples previously shown here are upstream Sigstore commands, not StellaOps CLI commands, and the --type https://stellaops.io/attestation/... predicate URIs were illustrative. Use stella verify image / stella proof verify for the in-product path. If you verify with cosign directly, consult cosign’s own documentation for current flags.

Related Documentation:

6. SARIF Integration

CLI outputs SARIF for IDE and GitHub integration:

{
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "StellaOps",
          "version": "2.1.0",
          "informationUri": "https://stellaops.io"
        }
      },
      "results": [
        {
          "ruleId": "CVE-2024-1234",
          "level": "error",
          "message": {
            "text": "Critical vulnerability in lodash@4.17.20"
          },
          "locations": [
            {
              "physicalLocation": {
                "artifactLocation": {
                  "uri": "package-lock.json"
                }
              }
            }
          ]
        }
      ]
    }
  ]
}

7. Attestation Verification

CORRECTED: there is no top-level stellaops attestation command and no attestation show --scan verb. DSSE attestations are inspected and verified through the attest group (AttestCommandGroup, wired via CommandFactory.BuildAttestCommand) and the unified verify group (VerifyCommandGroup).

The live root stella attest verify (CommandFactory.BuildAttestCommand → HandleAttestVerifyAsync) is envelope-anchored: it takes --envelope/-e (required, path to a DSSE envelope file), --policy (JSON policy: required predicate types / minimum signatures / required signers), --root (an EC/RSA public key or X.509 certificate PEM), --transparency-checkpoint, --require-timestamp, --max-skew, --output/-o, --format/-f (table/json), and --explain.

Real signature check (SPRINT_20260704_001 / CAT-2): the Signature Verification step is now a real cryptographic DSSE check over PAE(payloadType, payload) using the public key in --root — it passes only when a signature actually verifies. (It was previously a placeholder that passed merely because a --root file existed.) A tampered payload, a wrong key, or an unsigned envelope fails. The transparency step in this online mode is presence-only and is reported as such — use stella attest verify-offline for a real inclusion proof.

The image-anchored path is stella verify image <ref> / stella verify attestation --image <ref> (real ImageAttestationVerifier; §5a — fails closed without a trust policy). The attest group’s own client-side DSSE checker also accepts a raw envelope + public key (stella attest verify --attestation <dsse.json> --key <public.pem> [--uuid <rekor>] [--offline]).

# Verify a DSSE envelope's signature (+ policy / timestamp) against trust material
stella attest verify \
  --envelope provenance.intoto.json \
  --root cosign.pub \
  --require-timestamp \
  --format json

# Image-anchored: verify the attestations attached to an OCI image
stella verify attestation --image ghcr.io/org/myapp@sha256:... --trust-policy trust-policy.yaml

# Offline / air-gapped: verify an exported evidence bundle (no network)
stella attest verify-offline --bundle /path/to/attestation-bundle.tar.gz --trust-root ./trust-root

8. PR/MR Comment and Status Integration

QUARANTINED — not available in this release (PLAN_20260704 finding A5, decision D3 — quarantine, not delete; see the evidence-moat program plan “Explicitly deferred / cut” list). The outbound SCM-feedback path (the stella github command group and the GitHub/GitLab SCM annotation clients) is built but not wired: no IGitHubCodeScanningClient is DI-registered, so every stella github * verb now exits with a clean not available in this release error (exit code 1, no stack trace). The verb code and the annotation clients are preserved for a future SCM-feedback initiative. The sanctioned scan ingress and feedback path is CLI-in-CI: emit SARIF with the scanner and, on GitHub, upload it with GitHub’s own github/codeql-action/upload-sarif action (see §“CI/CD Pipeline Integration” above). Treat the command table and example below as a design reference, not a runnable path.

StellaOps historically surfaced scan results in the SCM platform via the github command group (GitHubCommandGroup.cs): uploading SARIF to GitHub Code Scanning and managing the resulting code-scanning alerts. The inline --pr-comment / --check-run flags described historically below are roadmap and are not wired on any scan/gate command today.

CORRECTED: the github group does NOT update commit status, and there is no commit-status command. Its only five subcommands (GitHubCommandGroup.cs) are upload-sarif, list-alerts, get-alert, update-alert, and upload-status — and upload-status checks the processing status of a prior SARIF upload (it takes a <sarif-id> argument), it does not write a commit status.

GitHub Integration (QUARANTINED — verbs exit not available in this release)

The github command shapes (design reference; not runnable in this release) are:

CommandPurpose
stella github upload-sarif <file> --repo owner/repoUpload SARIF to GitHub Code Scanning (--ref, --sha, --wait/-w, --timeout/-t, --tool-name, --github-url)
stella github list-alerts --repo owner/repoList code-scanning alerts
stella github get-alert --repo owner/repoInspect a single alert
stella github update-alert --repo owner/repoUpdate a code-scanning alert state (dismiss/resolve)
stella github upload-status <sarif-id> --repo owner/repoCheck the processing status of a prior SARIF upload (NOT a commit-status writer)
# GitHub Actions: emit SARIF with the scanner, then upload with GitHub's own action.
# (The quarantined `stella github upload-sarif` push is NOT used here.)
- name: Scan and emit SARIF
  run: |
    stella registry scan "registry.example.com/myapp:${{ github.sha }}" --wait --json > stellaops-scan.json
    stella scan sarif --scan-id "$(jq -r '.status.scanId' stellaops-scan.json)" --output results.sarif
- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: results.sarif

ORPHANED (Draft / roadmap): the rich inline-comment surface below — --pr-comment, --mr-comment, --check-run, --commit-status, --update-existing, --collapse-details, --evidence-link, --github-token, --gitlab-token — does not exist on any current CLI command (the only reference in source is the CLI TASKS.md backlog). It is retained here as a design target for the comment/annotation format; do not treat the flags as runnable.

# DRAFT (roadmap) — flags below are not implemented
- name: Scan with PR feedback
  run: |
    stellaops scan myapp:${{ github.sha }} \
      --policy production \
      --pr-comment \
      --check-run \
      --github-token ${{ secrets.GITHUB_TOKEN }}

Example PR comment format:

## StellaOps Scan Results

**Verdict:** :warning: WARN

| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 2 |
| Medium | 5 |
| Low | 12 |

### Findings Requiring Attention

| CVE | Severity | Package | Status |
|-----|----------|---------|--------|
| CVE-2026-1234 | High | lodash@4.17.21 | Fix available: 4.17.22 |
| CVE-2026-5678 | High | express@4.18.0 | VEX: Not affected |

<details>
<summary>View full report</summary>

[Download SARIF](https://stellaops.example.com/scans/abc123/sarif)
[View in Console](https://stellaops.example.com/scans/abc123)

</details>

---
*Scan ID: abc123 | Policy: production | [Evidence](https://stellaops.example.com/evidence/abc123)*

GitLab MR Integration (Draft / roadmap)

ORPHANED (Draft / roadmap): GitLab MR note/discussion posting via scan flags is not implemented. The block below is a design target, not a runnable command.

# DRAFT (roadmap) — flags below are not implemented
scan:
  stage: test
  script:
    - stellaops scan $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --policy production \
        --mr-comment \
        --commit-status \
        --gitlab-token $CI_JOB_TOKEN
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

Comment Behavior Options (Draft / roadmap)

ORPHANED (Draft / roadmap): none of the options below are implemented on any current CLI command; they describe the intended comment-posting behavior.

OptionDescriptionDefault
--pr-comment / --mr-commentPost summary commentfalse
--check-runCreate GitHub check run with annotationsfalse
--commit-statusUpdate commit statusfalse
--update-existingEdit previous comment instead of newtrue
--collapse-detailsUse collapsible sections for long outputtrue
--evidence-linkInclude link to evidence bundletrue

Evidence Anchoring in Comments

Comments include evidence references for auditability:

Error Handling

ScenarioBehavior
No SCM tokenSkip comment, log warning
API rate limitRetry with backoff, then skip
Comment too longTruncate with link to full report
PR already mergedSkip comment

Evidence-First Annotation Format

PR/MR comments use ASCII-only output for determinism and maximum compatibility:

## StellaOps Security Scan

**Verdict:** [BLOCKING] Policy violation detected

| Status | Finding | Package | Action |
| --- | --- | --- | --- |
| [+] New | CVE-2026-1234 | lodash@4.17.21 | Fix: 4.17.22 |
| [-] Fixed | CVE-2025-9999 | express@4.17.0 | Resolved |
| [^] Upgraded | CVE-2026-5678 | axios@1.0.0 | High -> Medium |
| [v] Downgraded | CVE-2026-4321 | react@18.0.0 | Medium -> Low |

### Evidence

| Field | Value |
| --- | --- |
| Attestation Digest | sha256:abc123... |
| Policy Verdict | FAIL |
| Verify Command | `stella verify image <ref> --strict` |

---
*[OK] 12 findings unchanged | Policy: production v2.1.0*

ASCII Indicator Reference:

IndicatorMeaning
[OK]Pass / Success
[BLOCKING]Fail / Hard gate triggered
[WARNING]Soft gate / Advisory
[+]New finding introduced
[-]Finding fixed / removed
[^]Severity upgraded
[v]Severity downgraded

Offline Mode

In air-gapped environments:

The DSSE envelope shape and verification details belong to the Attestor module; the example envelope below illustrates the stored signed result that stella attest verify / stella verify attestation validate.

Attestation is stored as a DSSE envelope:

{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEi...",
  "signatures": [
    {
      "keyid": "sha256:abc123...",
      "sig": "MEQCI..."
    }
  ]
}

Gate Behaviors

Soft Gate (Warning Only)

# .stellaops.yaml
gates:
  ci:
    mode: soft  # Report but don't fail
    notify:
      - slack://security-channel

Hard Gate (Blocking)

gates:
  ci:
    mode: hard  # Fail pipeline on violations
    exceptions:
      - CVE-2024-9999  # Known false positive

Progressive Gate

gates:
  ci:
    mode: progressive
    thresholds:
      - branch: main
        max_critical: 0
        max_high: 5
      - branch: develop
        max_critical: 2
        max_high: 20
      - branch: feature/*
        mode: soft  # Warn only on feature branches

Data Contracts

Gate Evaluate Output (gate evaluate --output json)

This is the JSON gate evaluate --output json and gate status --output json print: the policy engine’s response, shape from GateEvaluateResponse in GateCommandGroup.cs. The pipeline verdict is the CLI’s process exit code (§5); the exitCode field is the policy engine’s own code and differs from it for a warning (1 here, exit 10) and a block (2 here, exit 1). Templates read decisionId from it for gate status.

interface GateEvaluateResponse {
  decisionId: string;
  status: 0 | 1 | 2;           // 0 = Pass, 1 = Warn, 2 = Fail (serialized as a number)
  exitCode: number;            // policy engine code: 0 = Pass, 1 = Warn, 2 = Fail
  imageDigest: string;
  baselineRef?: string;
  decidedAt: string;           // ISO-8601
  summary?: string;
  advisory?: string;
  gates?: Array<{
    name: string;
    result: string;            // pass | warn | fail | block
    reason: string;
    note?: string;
    condition?: string;
  }>;
  blockedBy?: string;
  blockReason?: string;
  suggestion?: string;
  overrideApplied: boolean;
  deltaSummary?: { added: number; removed: number; unchanged: number };
}

CLI Scan Output Schema (illustrative)

The schema below is an illustrative shape for scan output; it does not correspond to a single CLI command’s contract (the scan group emits SARIF/JSON/SBOM via subcommands, not one fixed envelope). Treat GateEvaluateResponse above as the load-bearing CI contract.

interface CliScanOutput {
  scan_id: string;
  image: string;
  digest: string;
  verdict: 'PASS' | 'WARN' | 'FAIL';
  confidence: number;
  summary: {
    critical: number;
    high: number;
    medium: number;
    low: number;
  };
  violations: Array<{
    cve: string;
    severity: string;
    package: string;
    rule: string;
    message: string;
  }>;
  attestation?: {
    digest: string;
    rekor_log_index?: number;
  };
  timing: {
    queued_ms: number;
    scan_ms: number;
    policy_ms: number;
    total_ms: number;
  };
}

Error Handling

Error/exit codes follow each command’s own scheme (see §5 for gate/guard, §5a for proof). For the gate/guard groups, common conditions are:

ErrorExit CodeRecovery
Input error (invalid parameters)2 (gate), 10 (guard)Check arguments (e.g. --image digest, --justification when overriding)
Network error (gate/scanner unreachable)11Check connectivity / backend URL; retry with --timeout
Policy / analysis error12Inspect policy/baseline; check service logs
Unknown error99Re-run with --verbose; inspect logs

There is no uniform “exit 2 for everything” scheme. gate uses 0 pass, 1 block, 2 input error, 10 warn and 11/12/99 for the other error classes; guard uses 0/1 for its verdict and 10/11/12/99 for error classes; proof verify uses the 0–8 scheme in §5a.

Observability

Metrics

The CLI emits OpenTelemetry metrics under the stellaops.cli.* namespace (CliMetrics.cs). Relevant counters/histograms:

MetricTypeNotes
stellaops.cli.scan.run.countCounterscan run invocations
stellaops.cli.command.duration.msHistogramPer-command duration (tagged with command name)
stellaops.cli.attest.verify.countCounterattest verify invocations
stellaops.cli.scanner.download.countCounterScanner-bundle downloads

CORRECTED: the previously listed cli_scan_total, cli_scan_duration_seconds, and cli_gate_blocked_total are not emitted by the CLI; the canonical names use the stellaops.cli. prefix.

CI/CD Annotations

GitHub Actions annotations:

::error file=package-lock.json::CVE-2024-1234: Critical vulnerability in lodash@4.17.20
::warning file=Dockerfile::Using outdated base image