Scheduled AI triage of GitHub issues. A reusable workflow scans an organisation's open issues, runs an agent session — Claude Code, the Gemini CLI, or the GitHub Copilot CLI, selected per run — that applies category labels per a versioned policy prompt, and attaches full run evidence to the workflow run: before/after snapshots, engine-specific session evidence (Claude: a turn-by-turn transcript; Gemini: telemetry plus the session summary, with transcript fidelity under verification — design doc §12; Copilot: CLI logs plus the shared session transcript), and a diff-based report.
https://lfreleng-actions.github.io/github-issues-triage/
Per-engine setup — credentials, permissions, and the checks that
prove them — lives in docs/setup/. The
design document covers the
architecture, containment model, and rollout plan in full.
snapshot (before) -> agent session -> snapshot (after)
-> diff -> report + summary
- Capture the organisation's open-issue state as JSON
- Skip the agent session when zero unlabelled issues exist
- Run the selected engine with read/label
ghverbs and a policy prompt (prompt/triage.md) - Capture the state again, diff, and report observed label movement — never the agent's own claims — to the step summary
- Upload the artefact bundle (snapshots, transcript, prompt, report) with 90-day retention, even when the session fails
Other organisations and users can call the pipeline directly, supplying their own tokens:
jobs:
triage:
permissions:
# Snapshot reads; label writes use the App token insteadissues: readcontents: read# Pin to an immutable release commit SHA; the tag rides along# as a comment. Never reference a mutable branch here: the# workflow receives your API key and an issues:write token.# yamllint disable-line rule:line-lengthuses: lfreleng-actions/github-issues-triage/.github/workflows/issues-triage.yaml@<commit-sha> # vX.Y.Zwith:
org: 'your-org'dry_run: truegithub_app_client_id: ${{ vars.YOUR_APP_CLIENT_ID }}secrets:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}github_app_private_key: ${{ secrets.YOUR_APP_PRIVATE_KEY }}Pinning the workflow pins its scripts and prompt too: the assets checkout defaults to the called workflow's own commit.
Warning
The Copilot engine holds a weaker containment boundary than the
other two — see the safety model below and design doc §13.2. It
never applies labels: pairing it with dry_run: false fails
the run before the session starts.
The copilot engine needs no model API key. Grant the calling
job copilot-requests: write and hand its GITHUB_TOKEN to the
pipeline; usage meters to the organisation, gated on the "Allow
use of Copilot CLI billed to the organization" policy.
jobs:
triage:
permissions:
issues: readcontents: read# Authorises Copilot model requests; carries no repository# access of its own.copilot-requests: write# yamllint disable-line rule:line-lengthuses: lfreleng-actions/github-issues-triage/.github/workflows/issues-triage.yaml@<commit-sha> # vX.Y.Zwith:
org: 'your-org'engine: 'copilot'dry_run: truesecrets:
copilot_token: ${{ secrets.GITHUB_TOKEN }}Where that policy is unavailable, pass a fine-grained PAT holding
the "Copilot Requests" permission as copilot_token instead and
drop the copilot-requests grant. Both routes, with their
trade-offs, are in
docs/setup/GITHUB.md.
Labels travel over a repository-access token, and the Copilot
credential is never that same token. What else the model
credential can reach depends on the route. A PAT scoped to
Copilot Requests reaches nothing but the model. The caller
GITHUB_TOKEN carries whatever the calling job grants —
issues: read and contents: read in the example above — so
grant that job reads alone, or the model credential becomes
write-capable too.
The reusable workflow does not declare copilot-requests itself:
a called workflow can narrow the caller's permissions but never
widen them, so declaring it there would fail every caller that
runs a different engine. Passing the token in as a secret leaves
that decision with you. Design doc §13.3 covers the reasoning, and
§13.5 records that no run has yet confirmed the grant survives the
hand-off — if it does not, the CLI rejects the token and the step
fails with the evidence bundle intact.
Without a GitHub App the pipeline cannot write: dry-run reports
work with the caller's github.token (issues: read), and live
runs refuse to start. Applying labels needs an App installed
across the target with issues: write and metadata: read;
single-repository runs scope the App token to that repository at
mint time. The copilot engine takes the same App token at
issues: read, which keeps its scan organisation-wide while
leaving it unable to label.
| Input | Default | Purpose |
|---|---|---|
org | (required) | GitHub organisation or user to triage |
engine | claude | Agent engine: claude, gemini, or copilot (refuses live runs) |
model | engine default | claude-opus-5 / gemini-3.5-flash-lite / claude-sonnet-5 |
dry_run | true | Report intended labels; apply nothing |
retriage | false | Re-examine issues that carry labels |
skip_agent | false | Plumbing test: skip the agent session |
repository | '' | Restrict the scan to one repository |
exclude_repos | '' | Comma-separated repositories to skip |
max_turns | 80 | Agent session turn ceiling (ignored by copilot) |
egress_policy | audit | harden-runner mode (audit/block) |
egress_allow_config | '' | harden-runner-block-action config coordinate |
github_app_client_id | '' | App auth; empty limits runs to dry-run |
assets_repository | this repo | Source of the prompt and scripts |
assets_ref | called workflow's commit | Ref of assets_repository to fetch |
| Secret | Required | Purpose |
|---|---|---|
anthropic_api_key | claude runs, unless skip_agent | Anthropic API authentication |
gemini_api_key | gemini runs, unless skip_agent | Gemini API (AI Studio) authentication |
copilot_token | copilot runs, unless skip_agent | Copilot model requests: caller GITHUB_TOKEN or a "Copilot Requests" PAT |
github_app_private_key | no | Pairs with github_app_client_id |
| Workflow | Purpose | Trigger |
|---|---|---|
issues-triage.yaml | The reusable pipeline | workflow_call |
issues-triage-cron.yaml | Org triage caller | 07:00 UTC weekdays / dispatch |
testing.yaml | Secretless plumbing test / manual dry-run | Pull request / dispatch |
documentation.yaml | Build and publish the docs site | Push to main / dispatch |
release.yaml | Promote draft release on tag push | Tag push |
- Dry-run by default: consumers opt in to live labelling
- Tool containment: the agent receives read verbs of
ghplus, in live mode, a constrained wrapper that validates repository, issue number, and label existence before a fixed--add-labeloperation — nogh issue edit, nogit, no arbitrary shell - Copilot engine caveat: that engine restricts the model to
shell tools, denies the mutating
ghandgitverbs, turns off built-in MCP servers and custom-instruction loading, and keeps both credentials out of the agent's own environment by seeding aghconfiguration directory instead. Its allow-list is an approval policy rather than a filter, though, and the CLI keeps auto-approving shell commands it treats as reads. Those commands run unsandboxed as the same user, so one can reach the configuration file, or the CLI's own environment a process up. Value-based redaction is what protects the token there, and a command that encodes the value defeats it. Writes stay shut, and the workflow refuses live runs on this engine until the enforcement in design doc §13.4 lands - Token scope: App tokens carry
metadata: readplusissues: write, orissues: readon thecopilotengine, which cannot label. Down-scoped at mint time, expiring in an hour. The model credential is never the repository-access token this pipeline mints: the Anthropic and Gemini keys carry no GitHub permissions at all, and a Copilot PAT scoped to Copilot Requests reaches nothing but the model. The Copilot route that reuses a callerGITHUB_TOKENcarries whatever that job grants, so grant such a job reads alone - Prompt-injection defence: the policy prompt instructs the agent to treat issue text as data; containment limits the worst case to a wrong label
- Egress control: harden-runner in audit or block mode, with the allow-list loaded from a tagged org baseline
Run the linting suite before pushing:
uvx pre-commit run --all-filesBuild and preview the documentation site locally:
uv venv --python 3.13
uv pip install --require-hashes --requirement docs/requirements.txt
.venv/bin/mkdocs serveThe report generator has a strict type-checking gate
(basedpyright, see pyproject.toml) and the prompt document
passes the same prose linting as the rest of the repository.