Running saga in CI

Saga has no CI-specific mode: it writes one self-contained HTML file, so a job just renders it, stores it somewhere, and links it from the PR. A working GitHub Actions workflow to start from:

name: Saga Review
on:
  # One render per PR — `synchronize` would re-run inference on every push.
  pull_request:
    types: [opened, ready_for_review]
  workflow_dispatch:
    inputs:
      pr_number:
        description: "Pull request number to render"
        required: true
        type: number

permissions:
  contents: read

jobs:
  saga:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      pull-requests: write # read is enough if you don't touch the PR
    # skip drafts and bots; run on real PR events or a manual dispatch
    if: |
      !endsWith(github.actor, '[bot]') &&
      ((github.event_name == 'pull_request' && !github.event.pull_request.draft) ||
      github.event_name == 'workflow_dispatch')
    concurrency:
      group: saga-$
      cancel-in-progress: true
    env:
      PR_NUMBER: $
    steps:
      - uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true

      # PR-URL mode fetches the diff through `gh` — no checkout needed.
      - name: Render saga
        env:
          GH_TOKEN: $
          ANTHROPIC_API_KEY: $
          SAGA_MODEL: anthropic/claude-opus-4-8
          PR_URL: $/$/pull/$
        run: uvx --from saga-cli==0.8.0 saga "$PR_URL" --no-serve --no-open -o "saga-$PR_NUMBER.html"

      # Persist the saga.html as a GH artifact
      - name: Upload saga
        id: upload
        uses: actions/upload-artifact@v7
        with:
          name: saga-pr-$
          path: saga-$.html
          retention-days: 30

      # Link it wherever your team looks. This edits the PR description in place;
      # a sticky PR comment or a job summary works just as well.
      - name: Link the saga in the PR description
        uses: actions/github-script@v9
        env:
          ARTIFACT_URL: $
        with:
          script: |
            const n = Number(process.env.PR_NUMBER);
            const [start, end] = ["<!-- saga:start -->", "<!-- saga:end -->"];
            const block = [
              start,
              "### 📖 Saga review",
              `[**View the guided walkthrough →**](${process.env.ARTIFACT_URL})`,
              "",
              `_Opening \`saga-${n}.html\` directly is read-only. To comment on the PR from`,
              `the saga, download it and run \`saga serve ~/Downloads/saga-${n}.html\`._`,
              end,
            ].join("\n");

            const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: n });
            const body = pr.body || "";
            const pattern = new RegExp(`${start}[\\s\\S]*?${end}`);
            // Replace in place so re-renders don't stack up.
            const next = pattern.test(body)
              ? body.replace(pattern, block)
              : (body ? `${body}\n\n${block}` : block);
            if (next !== body) {
              await github.rest.pulls.update({ ...context.repo, pull_number: n, body: next });
            }

Notes:

  • Pin the version so a release can’t change your output. --no-serve --no-open is explicit; saga only serves on an interactive terminal anyway.
  • A PR URL needs no checkout — only gh (pre-installed on GitHub runners) and GH_TOKEN. Diffing local refs (--base/--head) needs a checkout with fetch-depth: 0.
  • Storage is your choice. An artifact needs no infrastructure and works on private repos; a static host (Pages, S3/R2) gives reviewers a clickable page, but a saga contains your full diff, so mind who can read it.
  • CI output is read-only. Reviewers can read a file:// saga and draft in their browser, but drafts can’t be published and don’t transfer to a served page. To comment, they download it and saga serve it — see Reviewing.
  • Generation sends the diff to your provider; a local/ model on a self-hosted runner keeps it in-house. See Providers.
  • Failures print error: <msg> and exit 1, so a bad render fails the job.