# Run as code in CI

Validate and plan detection content on every pull request and apply the reviewed plan from main, with GitHub Actions or any CI that runs Node.js 20 or later.

A pipeline for detection content follows one pattern: every pull request is validated and planned with a read-only key, and `main` applies the reviewed plan with a key that only a protected environment holds. A required reviewer approves the exact plan that ships, and the workspace refuses to apply anything that changed since.

> **Detection content only, for now**
>
> API keys can publish detection rules, value lists and YARA rulesets. Monitor and dashboard bundles need a workspace admin and can't be applied from CI yet. See [Publish a bundle](https://tracix.io/docs/api/as-code#publish-a-bundle).

## Before you begin

* A detection repository with a `tracix-detections.yaml` manifest. See [Detection repositories](https://tracix.io/docs/api/as-code#detection-repositories).
* A workspace admin to create two CI keys. See [Create an API key](https://tracix.io/docs/api/authentication#create-an-api-key).
* Your workspace's address, such as `https://production-a7k3p2q9.app.tracix.io`.

## Set up GitHub Actions

**1. Create two CI keys**

In your workspace, under **Settings › Tokens & API**, create one CI key with `security.read` and another with `security.manage`.

**2. Store them in GitHub**

In the repository's settings, add the read key as the repository secret `TRACIX_READ_KEY`. Create an environment named `tracix-production`, add required reviewers, allow only the `main` branch, and add the apply key to it as the environment secret `TRACIX_APPLY_KEY`.

**3. Add the workspace address**

Add the repository variable `TRACIX_URL` with your workspace's address.

**4. Add the workflow**

Save the workflow below as `.github/workflows/tracix-detections.yml` and push it on a pull request.

Two repository variables are optional: `TRACIX_CLI_SHA256` pins the CLI to a build you reviewed, using the digest the first run prints, and `TRACIX_REPO_CREDENTIAL` turns on archive mode for large repositories (see below).

```yaml title=".github/workflows/tracix-detections.yml"
name: Tracix detections

on:
  pull_request:
    paths:
      - 'tracix-detections.yaml'
      - 'tracix-detections.yml'
      - 'sigma/**'
      - 'native/**'
      - 'lists/**'
      - 'yara/**'
      - 'tests/**'
      - '.github/workflows/tracix-detections.yml'
    types: [opened, synchronize, reopened, labeled, unlabeled]
  push:
    branches: [main]
    paths:
      - 'tracix-detections.yaml'
      - 'tracix-detections.yml'
      - 'sigma/**'
      - 'native/**'
      - 'lists/**'
      - 'yara/**'
      - 'tests/**'
      - '.github/workflows/tracix-detections.yml'
  workflow_dispatch:
    inputs:
      replace:
        description: Confirm replacing UI or pack rules that share a rule id with this repository
        type: boolean
        default: false

permissions:
  contents: read

concurrency:
  group: tracix-detections-${{ github.ref }}
  # Pull requests: the newest push wins. main: applies queue, never cancel.
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  TRACIX_URL: ${{ vars.TRACIX_URL }}
  TRACIX_REPO_CREDENTIAL: ${{ vars.TRACIX_REPO_CREDENTIAL }}

jobs:
  validate:
    # Pull requests from forks get no secrets, so they are not validated here.
    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    env:
      TRACIX_API_KEY: ${{ secrets.TRACIX_READ_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: Fetch the CLI from the deployment
        env:
          TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
        run: |
          set -euo pipefail
          headers="$RUNNER_TEMP/cli.headers"
          curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
          served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
          expected="${TRACIX_CLI_SHA256:-$served}"
          if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
          echo "$expected  $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
          echo "tracix-detections CLI sha256 $expected"
      - name: Validate (compile every file, run the fixtures)
        run: node "$RUNNER_TEMP/tracix-detections.mjs" validate .

  plan:
    if: >-
      (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
      (github.event_name != 'pull_request' && github.ref == 'refs/heads/main')
    runs-on: ubuntu-latest
    permissions:
      contents: read
      # push to main: read the labels of the pull request that was merged.
      pull-requests: read
    env:
      TRACIX_API_KEY: ${{ secrets.TRACIX_READ_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: Fetch the CLI from the deployment
        env:
          TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
        run: |
          set -euo pipefail
          headers="$RUNNER_TEMP/cli.headers"
          curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
          served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
          expected="${TRACIX_CLI_SHA256:-$served}"
          if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
          echo "$expected  $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
      # The label confirmed the replacement on the pull request; the run on
      # main after the merge carries it over from the merged pull request.
      - name: Read the merged pull request's tracix-replace label
        id: merged
        if: github.event_name == 'push'
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          SHA: ${{ github.sha }}
          BRANCH: ${{ github.ref_name }}
        run: |
          set -euo pipefail
          replace=$(gh api "repos/$REPO/commits/$SHA/pulls" | jq -r --arg branch "$BRANCH" \
            '[.[] | select(.merged_at != null and .base.ref == $branch) | .labels[].name] | index("tracix-replace") != null')
          echo "replace=$replace" >> "$GITHUB_OUTPUT"
          if [ "$replace" = "true" ]; then echo "The merged pull request carries tracix-replace: replacements are confirmed"; fi
      - name: Plan (what apply would create, update, delete or replace)
        env:
          REPLACE: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'tracix-replace')) || (github.event_name == 'workflow_dispatch' && inputs.replace) || steps.merged.outputs.replace == 'true' }}
        run: |
          set -euo pipefail
          flags=()
          if [ "$REPLACE" = "true" ]; then flags+=(--replace); fi
          node "$RUNNER_TEMP/tracix-detections.mjs" plan . "$RUNNER_TEMP/tracix-plan.json" "${flags[@]}"
      - name: Upload the plan
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: tracix-detections-plan
          path: ${{ runner.temp }}/tracix-plan.json
          if-no-files-found: ignore
          retention-days: 30

  apply:
    needs: plan
    if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    # Required reviewers on this environment approve the plan above (its
    # table is in the plan job's summary) before anything is published.
    environment: tracix-production
    env:
      TRACIX_API_KEY: ${{ secrets.TRACIX_APPLY_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: Fetch the CLI from the deployment
        env:
          TRACIX_CLI_SHA256: ${{ vars.TRACIX_CLI_SHA256 }}
        run: |
          set -euo pipefail
          headers="$RUNNER_TEMP/cli.headers"
          curl -fsS --proto '=https' --max-time 60 -H "x-api-key: $TRACIX_API_KEY" -D "$headers" -o "$RUNNER_TEMP/tracix-detections.mjs" "$TRACIX_URL/api/security/detections/cli"
          served=$(grep -i '^x-tracix-cli-sha256:' "$headers" | awk '{print $2}' | tr -d '\r' || true)
          expected="${TRACIX_CLI_SHA256:-$served}"
          if [ -z "$expected" ]; then echo "::error::$TRACIX_URL did not serve the CLI (check TRACIX_URL and the key)"; exit 1; fi
          echo "$expected  $RUNNER_TEMP/tracix-detections.mjs" | sha256sum -c -
      - uses: actions/download-artifact@v4
        with:
          name: tracix-detections-plan
          path: ${{ runner.temp }}/plan
      # The server re-plans at this commit and refuses a plan that no longer
      # matches (someone changed a rule since): re-run the workflow.
      - name: Apply the reviewed plan
        run: node "$RUNNER_TEMP/tracix-detections.mjs" apply . "$RUNNER_TEMP/plan/tracix-plan.json"
```

## What the workflow does

| Event                                           | Jobs                                                                                       | Key                                                    |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| A pull request from a branch of this repository | `validate`, then `plan`, which uploads the plan as an artifact                             | `TRACIX_READ_KEY`                                      |
| A push to `main`                                | `plan`, then `apply` once a required reviewer approves the `tracix-production` environment | `TRACIX_READ_KEY` to plan, `TRACIX_APPLY_KEY` to apply |
| A manual run from `main`                        | `plan`, with replacements confirmed when you tick **replace**, then `apply`                | Same as a push                                         |

* Each job downloads the CLI from your workspace and checks its SHA-256 against `TRACIX_CLI_SHA256`, or against the digest the workspace serves when the variable isn't set.
* On GitHub, the CLI writes annotations on the files that fail and a summary of the plan to the job.
* Pull requests from forks get no secrets, so the workflow skips them. Never give a pipeline that runs untrusted pull requests a key.
* On a pull request, a newer push cancels the older run. On `main`, applies queue and are never cancelled.

## Confirm a replacement

When a repository rule shares its rule ID with a rule created in the UI or installed from the Sigma pack, the plan fails with a conflict until you confirm the replacement. Add the `tracix-replace` label to the pull request. The run on `main` after the merge reads the label from the merged pull request, which is why its plan job has `pull-requests: read`. You can also run the workflow from `main` with **replace** ticked.

## Use another CI system

The same steps work in any CI that runs Node.js 20 or later with `curl` and `sha256sum`. Run validate and plan on merge requests with the read key, and apply from your main branch with the apply key in a protected job:

```bash
curl -fsS --proto '=https' -H "X-API-Key: $TRACIX_API_KEY" \
  -D cli.headers -o tracix-detections.mjs "$TRACIX_URL/api/security/detections/cli"
expected=$(grep -i '^x-tracix-cli-sha256:' cli.headers | awk '{print $2}' | tr -d '\r')
echo "$expected  tracix-detections.mjs" | sha256sum -c -
node tracix-detections.mjs validate .
node tracix-detections.mjs plan . plan.json
```

```bash
node tracix-detections.mjs apply . plan.json
```

Keep `plan.json` from the planning job and pass it to the apply job, so the plan your reviewer read is the plan that's applied. The CLI records the commit it publishes from the Git `HEAD` of the folder; set `TRACIX_REVISION` or `--revision` when the job has no Git checkout.

## Large repositories

The CLI keeps each request under 1,000,000 bytes by default (`TRACIX_MAX_REQUEST_BYTES`, at most 10,485,760). Over that, `validate` sends the repository in batches, and `plan` and `apply` ask your workspace to fetch the commit from GitHub or GitLab instead of sending the files. That needs:

* a GitHub or GitLab credential stored in your workspace, with its ID in `TRACIX_REPO_CREDENTIAL`;
* the credential shared with your team at the Viewer level, because an API key reads stored credentials the way a Viewer does;
* the commit pushed, since the workspace fetches it from the code host.

Pass `--archive` to always use this mode.

## Keep keys safe

* Use a read key to validate and plan, and keep the apply key in a protected environment with required reviewers.
* Give keys an expiry, and rotate them by creating a new key, updating the secret and revoking the old key.
* The CLI sends the key only in a header to `TRACIX_URL` over HTTPS, refuses redirects and masks the key in its output, but your CI logs still deserve the same care as the key.

## Next steps

- [Monitors, dashboards and detections as code](https://tracix.io/docs/api/as-code): Repository layout, manifest and CLI reference

- [Authenticate with API keys](https://tracix.io/docs/api/authentication): Create and rotate CI keys

- [Detection rules and shadow mode](https://tracix.io/docs/security/detections): Stages, shadow mode and rule tuning
