> ## Documentation Index
> Fetch the complete documentation index at: https://ravion-b90c0359-mintlify-78fd7baf.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# CI integration

> Plan Ravion configuration changes on every pull request and apply them on merge using GitHub Actions, GitLab CI, or your own runner.

Keep your [project](/config-as-code/project-config-file) and [pipeline](/config-as-code/pipeline-config-file) config files in your repository and let GitHub Actions manage them. Open a pull request to preview the planned changes as a comment, then apply them automatically when the pull request merges.

The workflow uses path filters so unrelated changes do not allocate a runner. YAML anchors define each config file path once and reuse it in the pull request and push filters.

Three jobs cover projects and pipelines. Only the job for the current event runs:

* **Plan on pull request** — shallow-fetches the comparison commit, runs `ravion ... config apply --dry-run` for each changed resource, and posts every plan in a single, updating PR comment.
* **Apply on merge** — shallow-fetches the comparison commit and runs `ravion ... config apply` for each changed resource when the branch merges into your default branch. Project applies pass `--description` so every stack run they create links back to the merged pull request, or to the commit for a direct push. The description uses the same markdown as the run descriptions Ravion writes for pipeline runs it triggers from webhooks, such as `[PR #42 - Add dark mode support](https://github.com/acme/app/pull/42) ([abcdef1](https://github.com/acme/app/commit/abcdef123456))`.
* **Update the CLI monthly** — on the first of each month, opens a pull request bumping `.github/ravion-cli-version` when a newer [CLI release](/cli/releases) exists. Run it on demand from the **Actions** tab.

## Authenticate in CI

The CLI reads a service-account API key from the `RAVION_API_KEY` environment variable.

<Steps>
  <Step title="Create a service-account API key">
    Create a service account for CI and an API key for it. See [`ravion
            service-account`](/cli/reference/service-account) and [`ravion
            api-key`](/cli/reference/api-key).
  </Step>

  <Step title="Store the key as a repository secret">
    In your GitHub repository, go to **Settings → Secrets and variables → Actions** and add a secret
    named `RAVION_API_KEY` with the API key value.
  </Step>
</Steps>

## Add the workflow

Copy the prompt below into your coding agent to add and configure the GitHub action.

<Prompt description="Discover your Ravion config files and add the CI workflow." icon="sparkles" actions={["copy", "cursor"]}>
  Add a GitHub Actions workflow to this repository that plans Ravion config changes on pull requests and applies them on merge to the default branch. Work through these steps:

  1. Fetch the workflow template from the Ravion docs at [https://www.ravion.com/docs/config-as-code/ci-integration](https://www.ravion.com/docs/config-as-code/ci-integration) and use the `.github/workflows/ravion.yml` YAML block there as the starting point. Keep its jobs and logic unchanged. Only edit the indexed resource variables, their path aliases, the `.github/ravion-cli-version` file, optionally `RAVION_AUTOAPPROVE`, the default branch, and the `runs-on` values as described below.

  2. Find every Ravion config file in the repo:
     * Search files with these extensions: `.yaml`, `.yml`, `.json`, `.jsonc`, `.cue`. The names usually include `ravion` (project configs) or `pipeline` (pipeline configs), so prioritize those, but confirm each by its contents.
     * Classify each by its top-level keys (ignore everything else, such as `package.json`, CI configs, or Kubernetes manifests):
       * Project config: has a top-level `project` object AND an `environments` array whose entries contain `moduleInstances` (modules have a `type` like `rvn-...` and reference each other with `moduleGivenIdRef`).
       * Pipeline config: has top-level `steps` AND `variants` arrays (often also `triggers` and `inputs`).
     * Exclude legacy Flightcontrol config files. Skip a file if any of these are true: it is named `flightcontrol.json`, `flightcontrol.cue`, or `flightcontrol-*.json`; it has a `$schema` pointing at `app.flightcontrol.dev`; or it has a top-level `environments` array whose entries contain `services` (with fields like `buildType`, `ci`, `region`, or `source`) and there is NO top-level `project` object. These are a different product.
     * Read each file's unique ID from inside the file. Project: use `project.id` (a value starting with `proj_`); do not put `project.givenId` in the workflow, and if only `project.givenId` is present, look up the unique ID by running `ravion project list --json` (when the `ravion` CLI is installed and authenticated) and using the `id` of the entry whose `givenId` matches, otherwise treat the ID as undetermined. Pipeline: use the `pipe_` ID, which Ravion writes into a header comment at the top of pulled files; if the body has no ID, take it from that comment.
     * For `.jsonc` and `.cue`, account for comments when parsing. Use each file's path relative to the repo root for its `*_FILE` value.

  3. Add one contiguous indexed environment-variable pair for each config file:
     * Projects use `RAVION_PROJECT_N_ID` and `RAVION_PROJECT_N_FILE`, starting at `N=1`. Set the ID to the `proj_` ID. Define a unique YAML anchor on the file value, for example `RAVION_PROJECT_1_FILE: &ravion_project_1_file packages/web/ravion.yaml`.
     * Pipelines use `RAVION_PIPELINE_N_ID` and `RAVION_PIPELINE_N_FILE`, starting at `N=1`. Set the ID to the `pipe_` ID and define a unique YAML anchor on the file value.
     * Keep indexes contiguous within each resource type. Omit that resource type when none exist.

  4. Add every file anchor once to `on.pull_request.paths`. Keep the `&ravion_paths` anchor on that list and `paths: *ravion_paths` under `on.push`, so both events use the same filters without repeating any path string.

  5. Write the latest Ravion CLI tag, with its leading `v`, to `.github/ravion-cli-version` (a single line, such as `v0.2.23`). Find it from the latest release at [https://github.com/flightcontrolhq/cli/releases/latest](https://github.com/flightcontrolhq/cli/releases/latest), which redirects to the newest tag. It must be `v0.2.23` or newer, because the apply job passes `--description`. The workflow's monthly `update-cli` job keeps this file current afterwards, so do not inline the version in the workflow.

  6. Set `on.push.branches` to the repository's default branch. Match the runner to the repo by checking the other workflows under `.github/workflows/`. If they run on a custom or self-hosted runner (a `runs-on` value other than the GitHub-hosted `ubuntu-latest`/`ubuntu-*` labels — for example a self-hosted label or a runner group), set `runs-on` to that same runner for both jobs. Otherwise keep `ubuntu-latest`.

  7. Write the workflow to `.github/workflows/ravion.yml` and the CLI tag to `.github/ravion-cli-version`. If you could not determine the ID for any file, add its indexed pair with a TODO placeholder and list those files so I can fill them in.
</Prompt>

Create `.github/workflows/ravion.yml`. Define each project or pipeline as a contiguous indexed ID/file pair. Anchor every `*_FILE` value and reuse those aliases in `ravion_paths`, so each config file path appears once.

Put the [CLI release](/cli/releases) tag you want CI to use in `.github/ravion-cli-version`, for example `v0.2.23`. The `update-cli` job opens a pull request against that file each month when a newer release exists, so the version stays outside the workflow file — GitHub blocks the Actions token from pushing changes to files under `.github/workflows/`. Leave `RAVION_AUTOAPPROVE` as `"false"` to require manual approval of [stack change pipeline runs](/modules/stack#stacks-change-through-pipelines) in the Ravion dashboard, or set it to `"true"` to auto-approve project applies.

````yaml .github/workflows/ravion.yml theme={null}
name: Ravion config

env:
  RAVION_API_KEY: ${{ secrets.RAVION_API_KEY }}
  RAVION_AUTOAPPROVE: "false"
  # The CLI version lives in .github/ravion-cli-version so the monthly job can
  # bump it: GitHub blocks the Actions token from pushing workflow file changes.
  RAVION_CLI_VERSION_FILE: .github/ravion-cli-version
  # Keep indexes contiguous within each resource type.
  RAVION_PROJECT_1_ID: proj_3AV4cGr0t841Bstn4swCCrHrgly
  RAVION_PROJECT_1_FILE: &ravion_project_1_file packages/web/ravion.yaml
  RAVION_PROJECT_2_ID: proj_1CV4cGr0t841Bstn4swCCrHrgh7
  RAVION_PROJECT_2_FILE: &ravion_project_2_file packages/api/ravion.yaml
  RAVION_PIPELINE_1_ID: pipe_8Kd2mZq0t41Bstn4swCCrHrAa9
  RAVION_PIPELINE_1_FILE: &ravion_pipeline_1_file ravion-pipeline.yaml

on:
  pull_request:
    paths: &ravion_paths
      - *ravion_project_1_file
      - *ravion_project_2_file
      - *ravion_pipeline_1_file
  push:
    branches:
      - main
    paths: *ravion_paths
  schedule:
    - cron: "0 14 1 * *"
  workflow_dispatch:

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - &list_changed_resources
        id: list
        name: List changed resources
        env:
          BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
          HEAD: ${{ github.sha }}
        run: |
          all_changed=false
          if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
            all_changed=true
          else
            if ! git cat-file -e "$BASE^{commit}" 2> /dev/null; then
              git fetch --no-tags --depth=1 origin "$BASE"
            fi
            git diff --name-only "$BASE" "$HEAD" > changed.txt
          fi

          resources='[]'
          configured=0
          for resource in project:RAVION_PROJECT pipeline:RAVION_PIPELINE; do
            kind=${resource%%:*}
            prefix=${resource#*:}
            i=1
            while true; do
              id_var="${prefix}_${i}_ID"
              file_var="${prefix}_${i}_FILE"

              if [[ ! -v $id_var && ! -v $file_var ]]; then
                break
              fi
              if [[ ! -v $id_var || ! -v $file_var ]]; then
                echo "$kind $i must define both $id_var and $file_var" >&2
                exit 1
              fi

              configured=$((configured + 1))
              id="${!id_var}"
              file="${!file_var}"
              changed=$all_changed
              if [ "$changed" = false ]; then
                while IFS= read -r changed_file; do
                  if [ "$changed_file" = "$file" ]; then
                    changed=true
                    break
                  fi
                done < changed.txt
              fi

              if [ "$changed" = true ]; then
                resources=$(jq -cn \
                  --argjson current "$resources" \
                  --arg kind "$kind" \
                  --arg id "$id" \
                  --arg file "$file" \
                  '$current + [{kind: $kind, id: $id, file: $file}]')
              fi

              i=$((i + 1))
            done
          done

          if [ "$configured" -eq 0 ]; then
            echo "At least one Ravion project or pipeline must be configured" >&2
            exit 1
          fi

          echo "resources=$resources" >> "$GITHUB_OUTPUT"

      - &install_ravion_cli
        name: Install the Ravion CLI
        if: steps.list.outputs.resources != '[]'
        run: |
          version=$(cat "$RAVION_CLI_VERSION_FILE")
          curl -fsSL "https://github.com/flightcontrolhq/cli/releases/download/${version}/install.sh" | sh

      - name: Build plan comment
        if: steps.list.outputs.resources != '[]'
        id: plan
        env:
          RESOURCES: ${{ steps.list.outputs.resources }}
        run: |
          {
            echo 'body<<BODY_EOF'
            echo '<!-- ravion-plan -->'
            echo '<a href="https://app.ravion.com">'
            echo '  <img src="https://www.ravion.com/docs/logo/dark.svg" alt="Ravion" width="152">'
            echo '</a>'
            echo

            while read -r resource; do
              kind=$(jq -r '.kind' <<< "$resource")
              id=$(jq -r '.id' <<< "$resource")
              file=$(jq -r '.file' <<< "$resource")
              name="$id"
              label="Pipeline"

              if [ "$kind" = project ]; then
                label="Project"
                name=$(ravion project get "$id" --json 2> /dev/null | jq -r '.name // empty') || true
                name=${name:-$id}
              fi

              echo "### [$name](https://app.ravion.com/go/$id)"
              echo
              echo "$label: \`$file\`"
              echo
              plan_file=$(mktemp)
              if [ "$kind" = project ]; then
                NO_COLOR=1 ravion project config apply "$id" --file "$file" --dry-run > "$plan_file" 2>&1 || true
              else
                NO_COLOR=1 ravion pipeline config apply "$id" --file "$file" --dry-run > "$plan_file" 2>&1 || true
              fi
              echo '```diff'
              cat "$plan_file"
              echo '```'
              echo
            done < <(jq -c '.[]' <<< "$RESOURCES")

            echo 'BODY_EOF'
          } >> "$GITHUB_OUTPUT"

      - name: Comment the plan on the PR
        if: steps.list.outputs.resources != '[]'
        uses: actions/github-script@v7
        with:
          script: |
            const marker = '<!-- ravion-plan -->';
            const body = ${{ toJSON(steps.plan.outputs.body) }};
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const existing = comments.find((c) => c.body.includes(marker));
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

  update-cli:
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: Open a pull request when a newer CLI is released
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          current=$(cat "$RAVION_CLI_VERSION_FILE")
          latest=$(gh release view --repo flightcontrolhq/cli --json tagName --jq .tagName)
          if [ "$current" = "$latest" ]; then
            echo "Already on the latest Ravion CLI ($current)"
            exit 0
          fi

          branch="ravion/cli-$latest"
          if git ls-remote --exit-code --heads origin "$branch" > /dev/null 2>&1; then
            echo "$branch already exists"
            exit 0
          fi

          echo "$latest" > "$RAVION_CLI_VERSION_FILE"
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git checkout -b "$branch"
          git commit -am "ci(ravion): update the Ravion CLI used by the GitHub Action to $latest"
          git push origin "$branch"

          gh pr create \
            --base "${{ github.ref_name }}" \
            --head "$branch" \
            --title "ci(ravion): update the Ravion CLI used by the GitHub Action to $latest" \
            --body "Updates the Ravion CLI used by the Ravion config workflow from \`$current\` to [\`$latest\`](https://github.com/flightcontrolhq/cli/releases/tag/$latest)."

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - *list_changed_resources

      - *install_ravion_cli

      - name: Apply changes
        if: steps.list.outputs.resources != '[]'
        env:
          RESOURCES: ${{ steps.list.outputs.resources }}
          COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
        run: |
          # Link every stack run this apply creates back to the change that caused
          # it, in the same markdown Ravion uses for pipeline runs it triggers
          # from webhooks. The subject only names a pull request when GitHub
          # authored it as a merge or squash commit; any other "#123" is an issue
          # reference, so those runs link to the commit instead.
          subject=$(head -n 1 <<< "$COMMIT_SUBJECT")
          short_sha=${GITHUB_SHA:0:7}
          commit_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA"

          pull_request=""
          title="$subject"
          if [[ $subject =~ ^Merge\ pull\ request\ \#([0-9]+)\ from\  ]]; then
            pull_request="${BASH_REMATCH[1]}"
            # GitHub puts the pull request title on the last line of a merge commit.
            title=$(grep -v '^[[:space:]]*$' <<< "$COMMIT_SUBJECT" | tail -n 1)
          elif [[ $subject =~ ^(.*)\ \(\#([0-9]+)\)$ ]]; then
            title="${BASH_REMATCH[1]}"
            pull_request="${BASH_REMATCH[2]}"
          fi

          # Square brackets would otherwise break the markdown link label.
          title=$(sed 's/[][]/\\&/g' <<< "${title:0:200}")

          if [ -n "$pull_request" ]; then
            description="[PR #$pull_request - $title]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pull_request) ([$short_sha]($commit_url))"
          else
            description="[$title ($short_sha)]($commit_url)"
          fi

          while read -r resource; do
            kind=$(jq -r '.kind' <<< "$resource")
            id=$(jq -r '.id' <<< "$resource")
            file=$(jq -r '.file' <<< "$resource")

            if [ "$kind" = project ]; then
              autoapprove_args=()
              if [ "$RAVION_AUTOAPPROVE" = "true" ]; then
                autoapprove_args=(--autoapprove)
              fi
              ravion project config apply "$id" --file "$file" --description "$description" "${autoapprove_args[@]}"
            else
              ravion pipeline config apply "$id" --file "$file"
            fi
          done < <(jq -c '.[]' <<< "$RESOURCES")
````

<Note>
  GitHub leaves a path-filtered workflow's check pending when the workflow is configured as a
  required status check but none of its paths match. Keep this workflow optional, or use a separate
  always-running check for branch protection.
</Note>

<Warning>
  `RAVION_AUTOAPPROVE: "true"` makes project applies approve supported stack change pipeline runs
  without a manual gate. Removing a module instance still runs a Terraform destroy. Keep it
  `"false"` if you want a human to approve runs in the Ravion dashboard. It applies to projects
  only; pipeline applies always create a new pipeline version.
</Warning>

<Note>
  `--description` sets the description on every stack run a project apply creates, so each run in
  the Ravion dashboard links back to the pull request or commit that changed the config. Without it,
  runs keep Ravion's generated description. It applies to projects only; pipeline applies create a
  new pipeline version and take no description.
</Note>

<Note>
  In CI the CLI isn't attached to a terminal, so its output is already plain text. The plan step
  also sets `NO_COLOR=1` to strip any ANSI color codes before the output goes into the PR comment.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Project config file" icon="folder" href="/config-as-code/project-config-file">
    Pull, edit, and apply a project's config.
  </Card>

  <Card title="Pipeline config file" icon="diagram-project" href="/config-as-code/pipeline-config-file">
    Pull, edit, and apply a pipeline's config.
  </Card>
</CardGroup>
