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-openis explicit; saga only serves on an interactive terminal anyway. - A PR URL needs no checkout — only
gh(pre-installed on GitHub runners) andGH_TOKEN. Diffing local refs (--base/--head) needs a checkout withfetch-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 andsaga serveit — 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.