Skip to content

Runtime verification

Let Warpway run approved test profiles in your own GitHub Actions and use the results as evidence.

Runtime verification lets lenses use the results of real test runs as evidence. Warpway runs profiles you approve, in your own GitHub Actions, on the exact commit under review. A model never writes or chooses the commands.

Runtime verification is part of the Team plan and is off until an Admin turns it on for a repository.

How it stays safe

  • Only approved profiles run. Profiles come from your configuration: .warpway.yml on the base branch, or the same settings in the dashboard. Warpway can dispatch a profile only if it is listed there; nothing in a pull request, a comment or a model's output can add one.
  • Commands live in your workflow. The workflow file maps each profile to the commands it runs. It sits in your repository and changes through review like any other code.
  • Warpway sends four values. The profile's input, the commit to test, a correlation id and the review run id. No command text.
  • A pull request cannot change its own verification. Warpway starts the copy of the workflow on the pull request's base branch; the workflow then checks out the commit under review and confirms it got exactly that commit. Pull requests from forks are not verified.
  • Results are checked. A result counts only when the run name, the artifact name and warpway-result.json all carry the correlation id of the dispatch, and the file names the dispatched commit and profile. A pass also needs the workflow run itself to succeed with no failed tests. A missing, oversized or inconsistent result is rejected, never treated as a pass.
  • Failure fails closed. Only a passing run for the exact commit under review satisfies a profile. If a required profile fails, cannot be dispatched, does not report a usable result, or exceeds its timeout, the lenses that need it cannot be completed, and the review does not pass.

Turn it on

  1. Enable it for the repository. In the dashboard, open the repository's Runtime verification settings. An Admin turns it on.
  2. Install Warpway Runtime. Warpway needs Actions: write to dispatch the workflow and read its results, and the Warpway app never asks for it. On the same settings page, choose Install Warpway Runtime: a separate GitHub app with only Actions and repository metadata access. Give it the repositories you verify. See GitHub permissions.
  3. Add the workflow below to your default branch, and to any other branch your pull requests target. GitHub only dispatches workflows that exist on the default branch, and Warpway runs the copy on each pull request's base branch.
  4. Define profiles in .warpway.yml and merge it, so the base branch has them.

The workflow

Save this file as .github/workflows/warpway-verify.yml. Edit only the commands: each profile is a WARPWAY_COMMAND_<NAME> entry under Run approved profile plus a matching line in the case statement, which fails every profile it does not list. Everything else is the contract Warpway relies on: the four inputs, a run-name containing the correlation id, the checkout and check of the exact commit, and an artifact named warpway-result-<correlation id> that holds warpway-result.json.

.github/workflows/warpway-verify.yml
name: Warpway verify
run-name: 'Warpway verify ${{ inputs.profile }} [${{ inputs.correlation_id }}]'

on:
  workflow_dispatch:
    inputs:
      profile:
        description: Approved verification profile to run
        required: true
        type: string
      sha:
        description: Full commit SHA to check out and verify
        required: true
        type: string
      correlation_id:
        description: Warpway correlation id (names the result artifact)
        required: true
        type: string
      review_run_id:
        description: Warpway review run id
        required: true
        type: string

permissions:
  contents: read

jobs:
  verify:
    name: Verify ${{ inputs.profile }}
    runs-on: ubuntu-latest
    timeout-minutes: 45
    defaults:
      run:
        shell: bash
    env:
      WARPWAY_PROFILE: ${{ inputs.profile }}
      WARPWAY_SHA: ${{ inputs.sha }}
      WARPWAY_CORRELATION_ID: ${{ inputs.correlation_id }}
      WARPWAY_REVIEW_RUN_ID: ${{ inputs.review_run_id }}
    steps:
      - name: Validate inputs
        id: validate
        run: |
          fail() { echo "::error::$1"; exit 1; }
          [[ "$WARPWAY_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "Input sha must be a full 40-character lowercase commit SHA."
          [[ "$WARPWAY_CORRELATION_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$ ]] || fail "Input correlation_id is malformed."
          [[ "$WARPWAY_PROFILE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || fail "Input profile is malformed."
          [[ "$WARPWAY_REVIEW_RUN_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || fail "Input review_run_id is malformed."
          echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

      - name: Check out the requested commit
        id: checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ inputs.sha }}
          persist-credentials: false

      - name: Verify the checked-out commit
        id: verify_sha
        run: |
          actual="$(git rev-parse HEAD)"
          if [ "$actual" != "$WARPWAY_SHA" ]; then
            echo "::error::The checked-out commit does not match the requested SHA."
            exit 1
          fi

      - name: Run approved profile
        id: profile
        timeout-minutes: 30
        env:
          # Approved commands, one per profile. Edit them through a normal pull request. Commands must not
          # contain GitHub expressions; read inputs from the WARPWAY_* environment variables instead.
          WARPWAY_COMMAND_UNIT: |-
            npm ci
            npm test
          WARPWAY_COMMAND_INTEGRATION: |-
            npm ci
            npm run test:integration
        run: |
          case "$WARPWAY_PROFILE" in
            unit) profile_command="$WARPWAY_COMMAND_UNIT" ;;
            integration) profile_command="$WARPWAY_COMMAND_INTEGRATION" ;;
            *)
              echo "approved=false" >> "$GITHUB_OUTPUT"
              echo "::error::The requested profile is not approved in this workflow."
              exit 64
              ;;
          esac
          echo "approved=true" >> "$GITHUB_OUTPUT"
          export WARPWAY_REPORT="$RUNNER_TEMP/warpway-report.json"
          rm -f "$WARPWAY_REPORT"
          set +e
          bash --noprofile --norc -eo pipefail -c "$profile_command"
          status=$?
          set -e
          echo "exit_code=$status" >> "$GITHUB_OUTPUT"
          exit "$status"

      - name: Write warpway-result.json
        if: always() && steps.validate.outcome == 'success'
        env:
          WARPWAY_STARTED_AT: ${{ steps.validate.outputs.started_at }}
          WARPWAY_CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
          WARPWAY_VERIFY_OUTCOME: ${{ steps.verify_sha.outcome }}
          WARPWAY_PROFILE_OUTCOME: ${{ steps.profile.outcome }}
          WARPWAY_PROFILE_APPROVED: ${{ steps.profile.outputs.approved }}
          WARPWAY_EXIT_CODE: ${{ steps.profile.outputs.exit_code }}
        run: |
          : "${RUNNER_TEMP:?RUNNER_TEMP is not set}"
          result_dir="$RUNNER_TEMP/warpway-result"
          rm -rf "$result_dir"
          mkdir -p "$result_dir"
          result="$result_dir/warpway-result.json"
          finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
          started_at="$WARPWAY_STARTED_AT"
          if ! [[ "$started_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then started_at="$finished_at"; fi
          exit_code="$WARPWAY_EXIT_CODE"
          if ! [[ "$exit_code" =~ ^[0-9]{1,3}$ ]]; then exit_code=""; fi
          failures="[]"
          if [ "$WARPWAY_CHECKOUT_OUTCOME" != "success" ] || [ "$WARPWAY_VERIFY_OUTCOME" != "success" ]; then
            status="error"
            summary="Could not check out and verify commit $WARPWAY_SHA."
          elif [ "$WARPWAY_PROFILE_APPROVED" != "true" ]; then
            status="error"
            summary="Profile $WARPWAY_PROFILE is not approved in this workflow."
          elif [ "$WARPWAY_PROFILE_OUTCOME" = "success" ] && [ "$exit_code" = "0" ]; then
            status="passed"
            summary="Profile $WARPWAY_PROFILE passed."
          elif [ "$WARPWAY_PROFILE_OUTCOME" = "failure" ] && [ -n "$exit_code" ]; then
            status="failed"
            summary="Profile $WARPWAY_PROFILE failed with exit code $exit_code."
            failures="[{\"name\":\"$WARPWAY_PROFILE\",\"message\":\"Command exited with code $exit_code.\"}]"
          else
            status="error"
            summary="Profile $WARPWAY_PROFILE did not finish (it was cancelled or timed out)."
          fi
          exit_field=""
          if [ -n "$exit_code" ]; then exit_field=",\"exitCode\":$exit_code"; fi
          printf '{"schemaVersion":1,"profile":"%s","sha":"%s","correlationId":"%s","reviewRunId":"%s","status":"%s","summary":"%s","tests":{"passed":0,"failed":0,"skipped":0},"failures":%s,"startedAt":"%s","finishedAt":"%s"%s}\n' \
            "$WARPWAY_PROFILE" "$WARPWAY_SHA" "$WARPWAY_CORRELATION_ID" "$WARPWAY_REVIEW_RUN_ID" "$status" "$summary" \
            "$failures" "$started_at" "$finished_at" "$exit_field" > "$result"
          report="$RUNNER_TEMP/warpway-report.json"
          if [ -f "$report" ]; then
            merge='
          def count: if type == "number" and . >= 0 and . < 1000000000 then floor else 0 end;
          def text($n): (if type == "string" then . else tostring end) | .[0:$n];
          $report[0] as $r
          | if ($r | type) != "object" then error("the report must be a JSON object") else . end
          | if ($r.tests | type) == "object" then
              .tests = { passed: ($r.tests.passed | count), failed: ($r.tests.failed | count), skipped: ($r.tests.skipped | count) }
            else . end
          | if ($r.failures | type) == "array" and ($r.failures | length) > 0 then
              .failures = [ $r.failures[0:50][] | select(type == "object")
                | { name: ((.name // "unnamed") | text(200)), message: ((.message // "") | text(2000)) }
                  + (if (.file | type) == "string" and (.file | length) > 0 then { file: (.file | text(300)) } else {} end)
                  + (if (.line | type) == "number" and .line >= 1 and .line < 100000000 then { line: (.line | floor) } else {} end) ]
            else . end
            '
            if ! command -v jq > /dev/null 2>&1; then
              echo "::warning::The test report was ignored because jq is not installed on this runner."
            elif [ "$(wc -c < "$report" | tr -d ' ')" -gt 1048576 ]; then
              echo "::warning::The test report was ignored because it is larger than 1 MiB."
            elif jq -c --slurpfile report "$report" "$merge" "$result" > "$result.tmp" 2> /dev/null; then
              mv "$result.tmp" "$result"
            else
              rm -f "$result.tmp"
              echo "::warning::The test report was ignored because it is not a JSON object in the documented format."
            fi
          fi
          echo "Warpway result: $status"

      - name: Upload the result
        if: always() && steps.validate.outcome == 'success'
        uses: actions/upload-artifact@v4
        with:
          name: warpway-result-${{ inputs.correlation_id }}
          path: ${{ runner.temp }}/warpway-result/warpway-result.json
          retention-days: 7
          if-no-files-found: error
          overwrite: true

Commands run with bash -eo pipefail in the checkout and can read WARPWAY_PROFILE, WARPWAY_SHA, WARPWAY_CORRELATION_ID and WARPWAY_REVIEW_RUN_ID. They must not contain GitHub expressions (${{ }}). You can add setup steps, such as actions/setup-node, before Run approved profile. The Run approved profile step stops a command after its timeout-minutes (30 in this template).

The repository's Runtime verification settings page shows the same template.

Tip

Read inputs from the WARPWAY_* environment variables, as the template does, rather than writing ${{ inputs.* }} inside run: lines. That keeps an unexpected input from being interpreted as shell code.

Test counts and failures

Without anything extra, the result records whether the profile passed and its exit code. To report test counts and failing tests as well, have the command write a JSON object to the path in WARPWAY_REPORT. The workflow merges it into the result with jq when the runner has it, keeping up to 50 failures, and ignores a report that is larger than 1 MiB or not in this format.

json
{
  "tests": { "passed": 120, "failed": 1, "skipped": 3 },
  "failures": [{ "name": "rejects expired tokens", "message": "expected 401", "file": "src/auth.test.ts", "line": 42 }]
}

Define profiles

.warpway.yml
version: 1
runtime:
  profiles:
    unit:
      workflow: warpway-verify.yml
      input: unit
      timeout_minutes: 20
      required_for_lenses:
        - testing
    integration:
      workflow: warpway-verify.yml
      input: integration
      timeout_minutes: 30
      required_for_lenses:
        - testing
      run_when:
        paths:
          - services/**
FieldMeaning
workflowThe workflow file name in .github/workflows.
inputThe value passed as the profile input. It must match a label in the workflow's case statement.
timeout_minutesHow long the profile may run, 1–180 minutes (default 30). Warpway allows 15 more minutes for queueing, checkout and upload, then treats the run as timed out. Keep it within the workflow step's timeout-minutes.
required_for_lensesLenses that need this profile. Until it passes for the commit under review, they cannot be completed.
run_whenRequire the profile only when the change matches, using the applicability fields.

A lens can also list profiles it needs with its own runtime_profiles setting.

What happens during a review

  1. The review plan attaches to each lens the profiles it needs: those the lens lists in runtime_profiles, and those whose required_for_lenses names the lens and whose run_when matches the change.
  2. Warpway dispatches the workflow on the pull request's base branch with the profile's input, the head commit, a correlation id and the review run id, and records a verification run linked to the review.
  3. It follows the run through workflow_run events (which Warpway Runtime receives) and polling, matching it by the correlation id in the run name.
  4. When the run completes, Warpway downloads the warpway-result-<correlation id> artifact, checks warpway-result.json against the dispatch, and stores the outcome as runtime evidence: the profile, workflow, run link, conclusion and commit, with any test counts and failing tests. Logs are never downloaded.
  5. Lenses use that evidence. Failing tests can back a finding, and a lens that requires the profile cannot be Cleared until it passes; a missing or late result makes the lenses that required it Incomplete.
  6. If a new commit arrives, runs for the old commit are superseded and cannot affect the new review.

Keep the workflow safe

The workflow runs code from the pull request, like any CI job for pull requests:

  • give it permissions: contents: read and nothing more;
  • keep secrets out of it; verification should not need production credentials;
  • use persist-credentials: false on checkout;
  • pin third-party actions to versions you trust;
  • treat the result as evidence about tests, not as approval.

Something unclear or missing? Email marcus@cmglabs.ai.