Install
LGTM is two workflow files and two secrets. There is no server to run, no database to provision, and no checkout: the diff and the configuration are both read through the API.
The two workflows
The split is a security property, not an accident.
.github/workflows/lgtm-collect.yml is deliberately powerless. It listens for
pull_request_review with permissions: {}, writes the event to an artifact,
and exits. On a pull request from a fork that event carries no secrets and a
read-only token, so nothing else could happen there anyway.
name: LGTM (collect)
on:
pull_request_review:
types: [submitted]
permissions: {}
jobs:
collect:
if: github.event.review.state == 'approved'
runs-on: ubuntu-latest
steps:
- env:
EVENT: ${{ toJSON(github.event) }}
run: |
mkdir -p relay
jq -n --arg name pull_request_review --argjson payload "$EVENT" \
'{event_name: $name, payload: $payload}' > relay/event.json
- uses: actions/upload-artifact@v4
with:
name: lgtm-event
path: relay/event.json
retention-days: 1.github/workflows/lgtm.yml is the privileged half. It picks the relayed event
up through workflow_run, and also listens to issue_comment directly, since
that event does carry secrets and a write token even on fork pull requests.
name: LGTM
on:
workflow_run:
workflows: [LGTM (collect)]
types: [completed]
issue_comment:
types: [created, edited]
permissions:
contents: read # read the diff
checks: write # post the review-confirmed check
issues: write # the quiz comment, replies, reactions
pull-requests: write # read the diff, react and reply
actions: read # download the relayed event artifact
jobs:
lgtm:
if: >
github.event_name == 'workflow_run' ||
(github.event.issue.pull_request != null &&
(contains(github.event.comment.body, 'lgtm:v1') ||
contains(github.event.comment.body, '/lgtm waive') ||
contains(github.event.comment.body, '@lgtm')))
runs-on: ubuntu-latest
steps:
- name: Fetch the relayed event
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
name: lgtm-event
path: relay
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- uses: sparepartslabs/spareparts-lgtm@v1
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
seal-key: ${{ secrets.LGTM_SEAL_KEY }}
relayed-event: ${{ github.event_name == 'workflow_run' && 'relay/event.json' || '' }}Declaring that permissions block sets every scope not listed to none. Each
line is load-bearing; none of them is a default.
The privileged half holds the model key and a write token while handling data a fork author controls, so it never checks out or executes anything from the fork. The only inputs are the event payload and the diff, both read as data. If you add steps here, do not add a checkout of a fork ref.
Every input the action takes is declared in action.yml, including
openai-api-key, gemini-api-key, github-token and bot-login.
The two secrets
| Secret | Required | What it does |
|---|---|---|
| A model key | Yes, one of them | Writes the questions, verifies them, produces the reading aids and answers @lgtm. |
LGTM_SEAL_KEY |
Yes | Signs the quiz seal, so a tampered quiz is reissued rather than graded. |
The model key is ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY,
passed as the matching input. The provider field in
.github/lgtm.yml picks between them and defaults
to Anthropic.
No vendor here is a second-class choice. All three can search the web, which is
what the reading aids and @lgtm answers need, so a repository running
provider: openai gets everything a repository running Anthropic gets. A
provider named without its key produces a neutral check, never a block.
Generate the seal key with:
openssl rand -base64 32Keep it stable across runs. It has no fallback, and an unset seal key is a loud startup failure rather than a quiet one: a repository that cannot verify its own quizzes should say so rather than reissue a fresh quiz under whoever is answering.
What you will see
On approval, LGTM posts a comment carrying the quiz and a check run named
LGTM — review confirmed. That name is a literal, em dash included, and branch
protection matches it exactly. Answer by ticking a box per question. Wrong answers
get a revisit note and the comment re-renders with your ticks intact. Right
answers collapse the comment to one line and turn the check green.
By default that check never holds a merge. To change that, see enforcing.