How it works and what keeps it safeΒΆ
The four workflows that run in a repository, the way of an issue from its first analysis to the release that ships its fix, and the design that keeps an AI that reads untrusted issues from writing anything unchecked. The security design has the threats, their countermeasures and the tests that pin them.
How it worksΒΆ
flowchart TB
accTitle: The four workflows of a repository and what starts them
accDescr: Four workflows, each with what starts it. In issue-assistant.yml, a new issue, an edit or a comment starts event, which sets the labels of the form and chooses the next task; analyze lets the AI read the checkout and answer with JSON; apply checks the answer and is the only job that writes. In issue-lifecycle.yml, every morning, a merged pull request, a release and the end of a release run start sweep, which sends reminders, closes issues, closes duplicates and marks fixed issues. In labels.yml, a change of labels.toml on main starts sync-labels, which creates and updates the labels. In findings.yml, every morning, a scan on main and a change of findings.toml start findings, which dismisses the accepted findings and fails on the others.
subgraph lifecycle ["issue-lifecycle.yml"]
direction TB
shipped(["every morning, a merged<br>pull request, a release,<br>the end of a release run"]) --> sweep["`**sweep**<br>reminders, closing,<br>duplicates, fixed issues`"]
end
subgraph assistant ["issue-assistant.yml"]
direction TB
opened(["a new issue, an edit<br>or a comment"]) --> event["`**event**<br>labels from the form,<br>the next task`"]
event --> analyze["`**analyze**<br>the AI reads the checkout<br>and answers with JSON`"]
analyze --> apply["`**apply**<br>checks the answer,<br>the only job that writes`"]
end
subgraph scanning ["findings.yml"]
direction TB
scanned(["every morning, a scan<br>on main, a change<br>of findings.toml"]) --> findings["`**findings**<br>dismisses the accepted<br>findings, fails on others`"]
end
subgraph labelling ["labels.yml"]
direction TB
changed(["a change of<br>labels.toml on main"]) --> sync["`**sync-labels**<br>creates and updates<br>the labels`"]
end
assistant ~~~ labelling
lifecycle ~~~ scanning
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
class analyze ai
class event,apply,sweep,sync,findings job
class opened,shipped,changed,scanned trigger
Four workflows run in the repository. Only the violet job runs an AI, and it can't write:
| Workflow | When | What |
|---|---|---|
issue-assistant.yml |
new issues, edits of their reporters, new comments, by hand | labels from the form, the first analysis, follow-ups when the reporter answers, whether a maintainer's comment waits for the reporter, whether a comment after the release reopens the issue, and the slash commands of maintainers and reporters |
issue-lifecycle.yml |
every morning, merged pull requests, published releases, the end of a workflow named Release or Release integration that no schedule started | reminders after 15 days, closing after 30 days, closing duplicates 3 days after the notice, or the days of [lifecycle], marking fixed issues and closing them with the release, and locking issues closed long ago when lock_closed_after_days is set |
labels.yml |
when .github/labels.toml changes on main |
creates and updates the labels; never deletes one |
findings.yml |
every morning, after each scan of CodeQL, OpenSSF Scorecard, Workflow security and OSV-Scanner on main, when .github/findings.toml changes |
dismisses the accepted findings of code scanning and fails while others are open |
Every job runs on ubuntu-24.04, the AI's on GitHub's egress-firewall runner of the same version. The events of one issue wait for each other, first in, first out, so a comment written while an analysis runs is handled after it, never dropped. A run of the lifecycle or the findings whose job has nothing to do, such as after the hourly scheduled run of a release workflow, takes no runner and never takes the place of one that waits.
Every task of the AI is one of five: triage for a new issue, follow-up when the reporter answered the assistant's questions (at most twice, and never once the maintainer joined the conversation), maintainer-reply to decide whether a maintainer's comment waits for the reporter, release-reply to decide whether the reporter of an issue that a release closed says that the problem persists, and maintainer-question to answer the question of a maintainer's /assistant ask. Which one an event of an issue gives it:
flowchart TB
accTitle: Which task an event of an issue gives the AI
accDescr: An event of a bot, on a pull request, or a new issue that a maintainer opened gives the AI no task. Otherwise, a new issue, or the command /assistant again, gives it triage. An answer of the reporter to the assistant's questions gives it follow-up, at most twice and only while no maintainer joined the conversation since the questions. A comment of a maintainer on a reporter's issue that waits for nobody gives it maintainer-reply. A comment of the reporter within 30 days after a release closed the issue gives it release-reply. A maintainer's command /assistant ask gives it maintainer-question. Any other event, such as a comment of someone else, gives it no task. On an issue with the label assistant-muted, only a command of a maintainer starts a task. A task runs only while the AI is on and its secret is set; otherwise no analysis comes, and labels, reminders and closing go on.
start(["an event of an issue"]) --> who{"a bot, a pull<br>request, or a<br>maintainer's<br>new issue?"}
who -- "yes" --> none["no task"]
who -- "no" --> what{"what<br>happened?"}
what --> triage["`**triage**<br>a new issue,<br>or /assistant<br>again`"]
what --> followup["`**follow-up**<br>the reporter<br>answered; at most<br>twice, and no<br>maintainer since`"]
what --> reply["`**maintainer-reply**<br>a maintainer<br>wrote on a<br>reporter's issue<br>that waits<br>for nobody`"]
what --> persists["`**release-reply**<br>the reporter<br>wrote within<br>30 days after<br>a release<br>closed it`"]
what --> question["`**maintainer-question**<br>a maintainer<br>asked with<br>/assistant ask`"]
what -- "anything else" --> none
triage & followup & reply & persists & question --> muted{"muted, and<br>no command of<br>a maintainer?"}
muted -- "yes" --> none
muted -- "no" --> on{"the AI on,<br>and its<br>secret set?"}
on -- "yes" --> analyze["`**analyze**<br>the AI reads<br>and answers`"]
on -- "no" --> paused["no analysis;<br>labels, reminders<br>and closing go on"]
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
class start trigger
class who,what,muted,on question
class triage,followup,reply,persists,question,analyze ai
class none,paused job
Every other event, such as a comment of someone else, gives it none, and on an issue with the label assistant-muted, only a maintainer's command does. A run by hand, under Actions β Issue assistant β Run workflow, gives it the task chosen there, triage by default. The AI is off while the repository variable ISSUE_ASSISTANT_AI is off, and pauses while the engine's secret is missing.
A first analysis looks like this, here on an issue of a demo project:
Every comment keeps the questions for the reporter in view and says what it is:
- Many links fold. More than three links to related issues and files fold into one line below the questions, such as Where to look: 5 links, that opens them.
- Issue numbers stay links when they exist. The AI names issues and discussions as
#123; the apply job looks up the first ten that its checks didn't find already, and only those that exist stay links. Any other number shows as code, so that a made-up one refers to nothing. - Lines of Markdown files open in their source. A link to a line of a file that GitHub shows rendered, such as Markdown, AsciiDoc, a notebook or an SVG picture, opens its source with
?plain=1, where the line is marked. - The footer explains the assistant. The footer of an analysis and of a follow-up links How the assistant works: the part What happens with your issue of the first file of
[transparency] files, or the file. It asks for a π or π on the comment, and the summary of the morning sweep, or of one started by hand, counts them for the analyses and follow-ups of the last 30 days, as numbers only, such asFeedback of the last 30 days: analyses 12, π 9, π 1; follow-ups 3, π 2, π 0.On a duplicate notice, a π also keeps the issue open. - A reminder links the questions that it reminds of, in the analysis, the follow-up or the comment of a maintainer that asked them.
flowchart TB
accTitle: What a comment links and how its feedback is counted
accDescr: The apply job looks up the issue numbers that the AI names, up to ten: an issue or a discussion that exists stays a link, any other number becomes code. More than three links to related issues and files fold into one line below the questions, which opens them. The footer of every analysis and follow-up links the notice to reporters and asks for a thumbs up or down, and the morning sweep counts those of the last 30 days in its summary. Questions that stay unanswered for 15 days get a reminder that links the comment with them.
answer["the answer of the AI"] --> named{"a #35;123 of an issue or<br>discussion that exists?"}
named -- "yes, up to ten" --> link["a link"]
named -- "no" --> code["code"]
answer --> many{"more than three links<br>to issues and files?"}
many -- "yes" --> fold["one line below the<br>questions that opens them"]
many -- "no" --> lists["the lists below<br>the questions"]
answer --> footer["`the footer: **How the<br>assistant works** and a<br>request for a π or π`"]
footer --> sweep["the morning sweep counts<br>the π and π of 30 days<br>in its summary"]
answer --> questions["questions for the reporter"]
questions -- "no answer in 15 days" --> reminder["a reminder that links<br>the comment with them"]
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class named,many question
class answer,footer,questions,sweep,reminder job
class link,code,fold,lists safe
The life of an issueΒΆ
Labels say where an issue stands, and the lifecycle moves it on; each box has the colour of its label:
flowchart TB
accTitle: The life of an issue and its labels
accDescr: A new issue gets needs-triage, the labels of its form and a first analysis, and the maintainer takes over. When information is missing, or the maintainer asks the reporter, it gets needs-info and waits for the reporter: on day 15 it gets stale and a reminder, and on day 30 it closes as not planned; an answer, also after the closing, brings it back to the maintainer. A sure duplicate gets possible-duplicate and a notice and closes as a duplicate on day 3, unless an objection, a comment or a thumbs down of the reporter or of someone who may push, brings it back to the maintainer. A merged fix gives it fixed-in-next-release, and the release closes it with a link; a comment within 30 days that it is still broken brings it back to the maintainer.
opened(["new issue"]) --> triage["`**needs-triage**<br>labels from the form,<br>a first analysis`"]
triage --> maintainer["the maintainer<br>takes over"]
triage -- "information<br>missing" --> waiting
maintainer -- "asks the<br>reporter" --> waiting
subgraph unanswered [" "]
direction TB
waiting["`**needs-info**<br>waits for<br>the reporter`"] -- "day 15" --> stale["`**stale**<br>a reminder`"]
stale -- "day 30" --> closed["closed as<br>not planned"]
end
unanswered -- "an answer, also<br>after the closing" --> maintainer
triage -- "sure<br>duplicate" --> notice
subgraph duplicates [" "]
direction TB
notice["`**possible-duplicate**<br>a notice`"] -- "day 3" --> duplicate["closed as<br>a duplicate"]
end
notice -- "an objection" --> maintainer
maintainer -- "fix merged" --> fixed
subgraph release [" "]
direction TB
fixed["`**fixed-in-next-release**<br>waits for the release`"] -- "release" --> released["closed with a link<br>to the release"]
end
released -- "still broken,<br>within 30 days" --> maintainer
classDef triage fill:#fbca0466,stroke:#bf8700,stroke-width:2px
classDef info fill:#fef2c059,stroke:#d4a72c,stroke-width:2px
classDef stale fill:#f9d0c466,stroke:#bc4c00,stroke-width:2px
classDef duplicate fill:#8c959f33,stroke:#6e7781,stroke-width:2px
classDef fixed fill:#c2e0c666,stroke:#1a7f37,stroke-width:2px
classDef done fill:#8250df1f,stroke:#8250df,stroke-width:2px
classDef person fill:#0969da26,stroke:#0969da,stroke-width:2px
class triage triage
class waiting info
class stale stale
class notice duplicate
class fixed fixed
class closed,duplicate,released done
class maintainer person
An issue that waits for its reporter keeps what the comments of the assistant promise:
- An answer is a comment or an edit of the text, as the questions offer. Either ends the waiting, also when the event job missed it, since the sweep reads the comments and the history of the text. A new title alone answers nothing, and the AI gets no follow-up to write for it.
- The reminder names the day, and the issue closes on it. The waiting counts days of the calendar in UTC, and the hidden marker of the reminder records the day, so the sweep of that morning closes the issue, whatever minute it starts.
- A comment of the reporter reopens the issue that the sweep closed for a missing answer, as the closing comment promises, whatever its labels say: also when an answer came in while the sweep closed it and took them.
- A follow-up waits for no maintainer. The AI writes for a minute or two; the job that posts its follow-up checks again, and posts nothing once a maintainer joined the conversation.
Waiting periods, exemptions and old threadsΒΆ
The days above are the defaults of [lifecycle] in config.toml, and a repository with slow reporters or a busy queue sets its own: the reminder after remind_after_days, the closing after close_after_days, a duplicate after duplicate_grace_days, and the window of reopen_after_release_days in which the reporter's comment after a release reopens the issue. The comments name the days that the repository set, in each of their languages, and check fails when the description of needs-info, stale or possible-duplicate in labels.toml names other days.
- Exempt issues are left to the maintainers: those with a label of
exempt_labels,pinnedandsecurityby default, those assigned to someone whenexempt_assigneesistrue, and those with at leastexempt_upvotesπ. They get no reminder and never close on their own, also not as a duplicate; one that becomes exempt after its reminder losesstale. - Old threads lock when
lock_closed_after_daysis more than 0: an issue closed for longer, and at leastreopen_after_release_days, gets one line that points to a new issue and is locked as resolved, the oldest first and 25 in a sweep. An exempt issue stays unlocked, and so does one that someone unlocked.
flowchart TB
accTitle: What the morning sweep does with an issue, by the settings of its lifecycle
accDescr: The sweep reads the lifecycle section of config.toml and stops with an error when a period can't work, such as a closing before the reminder. An issue that waits for its reporter or for the closing as a duplicate is left to the maintainers when it is exempt: it has one of the exempt labels, pinned and security by default, it is assigned while exempt_assignees is true, or it has at least exempt_upvotes thumbs up. Otherwise it gets the reminder after remind_after_days, 15 by default, and closes after close_after_days, 30, or closes as a duplicate after duplicate_grace_days, 3. Only when lock_closed_after_days is set does the sweep lock an issue closed for longer, and at least the reopen window of a release, with one line that points to a new issue, unless it is exempt or someone unlocked it.
sweep(["the morning sweep reads<br>[lifecycle] of config.toml"]) --> valid{"the periods<br>can work?"}
valid -- "no" --> stops["the sweep stops,<br>naming each value"]
valid -- "yes" --> open["an open issue waits for its reporter,<br>or for its closing as a duplicate"]
valid -- "yes" --> closed["an issue closed for more than<br>lock_closed_after_days"]
open --> exempt{"exempt? an exempt label,<br>an assignee or<br>enough π"}
exempt -- "yes" --> kept["stays open: no reminder,<br>no closing"]
exempt -- "no" --> periods["reminder after remind_after_days,<br>closing after close_after_days;<br>a duplicate after duplicate_grace_days"]
closed --> lockable{"locking on, not exempt,<br>and nobody unlocked it?"}
lockable -- "yes" --> locked["one line that points to a new issue,<br>locked as resolved"]
lockable -- "no" --> unlocked["stays as it is"]
classDef source fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef stop fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
class sweep source
class valid,exempt,lockable question
class stops stop
class kept,unlocked safe
class open,closed,periods,locked job
A duplicateΒΆ
A sure duplicate closes only when nobody objects. The AI must be sure, and the original must be older, open, and opened or triaged by a maintainer; any other duplicate becomes a suggestion for the maintainer, in GitHub's panel, and a related issue in the comment:
sequenceDiagram
accTitle: A duplicate, from the notice to its closing
accDescr: The reporter opens an issue. The analysis of issue-assistant.yml says that it looks like a duplicate of an older issue and closes in 3 days, and adds possible-duplicate. A comment of anyone in those days removes possible-duplicate at once, and the issue stays open. A thumbs down on the notice by the reporter or by someone who may push makes the morning sweep of issue-lifecycle.yml remove possible-duplicate after the 3 days, and the issue stays open. Without an objection, the morning sweep posts a comment, closes the issue as a duplicate of the original and labels it duplicate.
actor R as Reporter
participant I as Issue #35;14
participant A as issue-assistant.yml
participant L as issue-lifecycle.yml
R->>I: opens it
I->>A: a new issue
A->>I: the analysis, with a<br>notice: a duplicate of<br>#35;9, closes in 3 days
A->>I: label possible-duplicate
Note over I: 3 days for<br>an objection
alt a comment of anyone
I->>A: the comment
A->>I: removes possible-<br>duplicate at once
else a π on the notice, of the reporter<br>or of someone who may push
L->>I: the morning sweep:<br>removes possible-duplicate
else no objection
L->>I: the morning sweep: a comment,<br>closed as a duplicate of #35;9
L->>I: label duplicate
end
The closing comment asks the reporter to go on in the original, where a π shows that more people are affected. The security design says why only an original that a maintainer vouches for closes another issue.
Slash commandsΒΆ
Maintainers steer the assistant from the issue, and its reporter can ask for a new analysis: a comment whose first line starts with /assistant gives a command.
| Command | Who | What it does |
|---|---|---|
/assistant again |
maintainers; the reporter once | A new first analysis, also when the issue has one, which decides anew whether the issue is a duplicate. The reporter may ask once per issue, while it has needs-triage and after its first analysis, within the limits of the AI runs. |
/assistant ask <question> |
maintainers | The AI answers the question from the code, the documentation and the issue, in a reply that links the files it rests on. The question may go on over more lines. |
/assistant duplicate #12 |
maintainers | The notice that the issue duplicates the open issue #12, and possible-duplicate: it closes as a duplicate after duplicate_grace_days (3 by default) unless someone objects, as after a duplicate that the AI found. |
/assistant not-duplicate |
maintainers, the reporter | Removes possible-duplicate, so that the issue stays open. |
/assistant waiting |
maintainers | Adds needs-info: the issue waits for its reporter, with the reminder and the closing. |
/assistant mute, /assistant unmute |
maintainers | Adds or removes assistant-muted; the only commands that work on a closed issue too. On a muted issue, the AI starts nothing on its own: no analysis, follow-up, check of a maintainer's comment or reading of a comment after a release. A maintainer's again and ask still run, and labels, reminders and closing go on. |
flowchart TB
accTitle: What a command on an issue does
accDescr: A comment whose first line starts with /assistant is a command when a maintainer or the reporter of the issue wrote it; from anyone else, it is an ordinary comment. A maintainer may give every command. The reporter may give not-duplicate, and again once, while the issue has needs-triage, after its first analysis, while it isn't muted and within the limits of the day. A command that is carried out gets the reaction eyes: again starts a new analysis and ask an answer of the AI; duplicate, not-duplicate, waiting, mute and unmute set labels and post a notice without the AI. A command that is refused gets the reaction confused, and the summary of the run says why. A command answers, asks and objects to nothing.
comment(["a comment whose first<br>line starts with<br>/assistant"]) --> who{"who wrote it?"}
who -- "anyone else" --> ordinary["an ordinary<br>comment"]
who -- "a maintainer" --> every["every command"]
who -- "the reporter" --> reporter["not-duplicate; again<br>once, with needs-triage,<br>after the analysis,<br>unmuted, within the<br>limits of the day"]
every & reporter --> carried{"carried out?"}
carried -- "yes" --> eyes["reaction eyes"]
carried -- "no" --> confused["reaction confused;<br>the summary of the<br>run says why"]
eyes --> ai["`**again**, **ask**<br>a task of the AI`"]
eyes --> labels["duplicate, not-duplicate,<br>waiting, mute, unmute:<br>labels and a notice,<br>without the AI"]
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
class comment trigger
class who,carried question
class ai ai
class ordinary,every,reporter,eyes,confused,labels job
- Only maintainers and the reporter give commands. Maintainers are the owner, the members of the organization and the collaborators, as everywhere in the assistant. The same line from anyone else is an ordinary comment, which steers nothing.
- A reaction tells what happened. π on the comment: the command is carried out, or its task of the AI starts. π: it was refused, as an unknown command or one that only maintainers may give, and the summary of the run says why, without repeating the comment. A refused line is still an ordinary comment, so a mistyped command still answers a question or objects to a duplicate notice.
- A command is no part of the conversation. It answers no question of the assistant and asks the reporter none, so the waiting for an answer and its reminder go on, and it objects to no duplicate notice;
/assistant not-duplicatedoes that. - The event job runs no AI. It carries out the commands that need none itself, and hands
againandaskto the AI through the analyze job, as every other task.applychecks an answer to a question as every text of the AI and posts one answer to each question; a run by hand with the taskmaintainer-questionanswers the newest question again. - A maintainer vouches for the original. A duplicate that the AI finds closes only an issue whose original a maintainer opened or triaged; one that a maintainer names with
/assistant duplicateneeds no triage of the original. The original must be open, as after a notice of the AI. - The label of a muted issue is
assistant-muted, in the groupdecisionof the starterlabels.toml. Wherelabels.tomldoesn't define it, the first/assistant mutecreates it, and a maintainer can also add or remove it by hand.
From the fix to the releaseΒΆ
Apps and integrations reach their users with a release, not with a merge. So an issue stays open until the fix is released:
- A pull request names the issue it fixes with
Fixes #12, as usual. When it is merged into the default branch, the issue gets the labelfixed-in-next-releaseand a comment: fixed by the pull request, closes with the next release. A maintainer can also set the label by hand, for a fix without a pull request. The morning sweep catches a merge whose run failed, up to 14 days later and however many pull requests the repository merged since; if a release ships the fix by then, the issue closes with it at once, without the comment. - When a release is published, every issue with the label whose fix it contains closes as completed. A comment links the release and asks the reporter to update. It is the first final release, no draft or pre-release, that was published after the last fix was merged and contains every fix, so an issue that got its label late still closes with the release that shipped its fix. A label that a maintainer set by hand, for a fix without a pull request or after one that fell short, waits for the next release after it. Only this repository's pull requests count: one of another repository that names the issue with
Fixes owner/project#12neither keeps it open nor appears in the comment. The issue is filed under the milestone of the release, such asv1.2.0, which the assistant creates, closed, when it is missing; its page lists everything that the release fixed. - If the reporter comments within 30 days that the problem persists after the update, the AI reads the comment and the issue reopens. Thanks or questions keep it closed. The AI reads up to two comments of the reporter after each release, as
release_repliesinconfig.tomlsays; a maintainer reads the rest.
sequenceDiagram
accTitle: A fixed issue, from the merge to the release
accDescr: The maintainer merges a pull request with Fixes #12. issue-lifecycle.yml labels the issue fixed-in-next-release and comments that it closes with the next release; it stays open, since users don't have the fix yet. When the maintainer publishes the release v1.2.0, the lifecycle comments with the link to the release, closes the issue and files it under the milestone v1.2.0, and the reporter is asked to update. When the reporter comments within 30 days that it is still broken, issue-assistant.yml lets the AI read the comment and reopens the issue with needs-triage; after thanks or a question, it stays closed.
actor M as Maintainer
participant L as issue-lifecycle.yml
participant I as Issue #35;12
actor R as Reporter
participant A as issue-assistant.yml
M->>L: merges a pull request<br>with "Fixes #35;12"
L->>I: fixed-in-next-release,<br>a comment: closes with<br>the next release
Note over I: stays open: users<br>don't have the fix yet
M->>L: publishes the<br>release v1.2.0
L->>I: a comment with the link<br>to the release, closed,<br>milestone v1.2.0
I-->>R: please update
alt within 30 days: "still broken"
R->>A: comments on<br>the closed issue
A->>I: the AI reads it:<br>reopened, needs-triage
else thanks or a question
R->>A: comments on<br>the closed issue
A->>I: stays closed
end
The fixes of an issue are the pull requests of the repository that name it and were merged into the default branch at most 14 days before its label, the longest that the sweep takes to set it, and an hour more for the run of the sweep. A fix that a release closed the issue with before it was reopened didn't help, so neither that release nor the fix counts again; any other reopening, such as of an issue that GitHub closed at the merge, keeps the fixes:
flowchart TB
accTitle: Which release closes a fixed issue
accDescr: For an open issue with the label fixed-in-next-release, the assistant looks for its fixes: pull requests of this repository into the default branch that name it, merged at most 14 days before the label, leaving out those that a release closed the issue with before it was reopened. With fixes and a label that the assistant set, the first final release published after the last of them was merged that contains them all closes the issue, also one published before a late label. With fixes and a label set by hand, it is the first final release after the label that contains them all. Without fixes, the first final release published after the label closes it. Drafts and pre-releases never close an issue.
label(["`open issue with<br>**fixed-in-next-release**`"]) --> fixes{"fixes of this repository,<br>merged at most 14 days<br>before the label, that<br>no release closed it with<br>before a reopening?"}
fixes -- "yes" --> who{"who set<br>the label?"}
who -- "the assistant" --> shipped["the first final release<br>after the last merge<br>that contains every fix"]
who -- "a maintainer,<br>by hand" --> later["the first final release<br>after the label<br>that contains every fix"]
fixes -- "no" --> next["the first final release<br>after the label"]
shipped --> released(["closed with a link to the release,<br>under its milestone"])
later --> released
next --> released
classDef fixed fill:#c2e0c666,stroke:#1a7f37,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef release fill:#0969da26,stroke:#0969da,stroke-width:2px
classDef done fill:#8250df1f,stroke:#8250df,stroke-width:2px
class label fixed
class fixes,who question
class shipped,later,next release
class released done
GitHub closes linked issues when a pull request is merged unless the repository turns this off: under Settings β General β Issues, clear Auto-close issues with merged linked pull requests. A repository without releases sets close_with_release = false in config.toml.
What the AI readsΒΆ
Each task reads what it needs and no more, from the files that the context step writes into .issue-assistant/:
- Every task reads the issue with all its comments, in
issue.md. - A first analysis and a follow-up read a window of the repository: every open issue and the 1,000 closed ones that changed last, each with an excerpt of 400 characters in
issues.jsonland its whole text inissues/<number>.md, the 1,000 discussions that changed last and the labels. GitHub's GraphQL API lists a hundred issues a page and no pull request, so a repository with 50,000 issues and pull requests costs a few calls instead of five hundred, and the context keeps its size.[context]inconfig.tomlsets both numbers. - A first analysis also searches. Up to 30 older issues that GitHub's search finds for the names of errors in the new issue, such as
TypeErrororECONNREFUSED, and the words of its title join the window, so that an old duplicate is found beyond it. A search that fails leaves the window, with a warning. - A reply judges the comment of its event.
maintainer-reply,release-replyandmaintainer-questionread the issue andcomment.md, the comment whose event started the run, and nothing of the rest of the repository. A run can wait behind others for minutes, and a "still broken for me too" of someone else that comes in meanwhile never reopens an issue whose reporter just confirmed the fix. Theapplyjob reads that comment again and acts only when it is the reporter's, after a release, or a maintainer's, and for an answer a maintainer's/assistant ask. A run by hand judges the newest such comment.
flowchart TB
accTitle: What the AI reads for each task
accDescr: Every task reads issue.md, the issue with all its comments. A first analysis and a follow-up also read the labels, every open issue, the closed issues that changed last up to max_issues and the discussions that changed last up to max_discussions, from GitHub's GraphQL API, which lists no pull request; each issue has an excerpt in issues.jsonl and its whole text in issues/. A first analysis adds up to 30 older issues that a search for the names of errors and the words of the title finds. A reply, maintainer-reply, release-reply or maintainer-question, reads comment.md instead: the comment whose event started the run, not a newer one that came in while the run waited. The apply job acts only when that comment is the reporter's or a maintainer's, or a maintainer's question.
task(["a task of the AI"]) --> issue[("issue.md:<br>the issue and<br>its comments")]
task --> which{"which<br>task?"}
which -- "triage,<br>follow-up" --> window["`**a window**<br>every open<br>issue, and<br>those closed<br>issues and<br>discussions<br>that changed<br>last, up to<br>max_issues and<br>max_discussions`"]
which -- "triage" --> search["`**a search**<br>for errors<br>and words<br>of the title:<br>up to 30<br>older issues`"]
which -- "maintainer-reply,<br>release-reply,<br>maintainer-question" --> judged["`**the comment<br>of the event**,<br>not a newer<br>one that came<br>in meanwhile`"]
window --> files[("issues.jsonl,<br>issues/,<br>discussions.jsonl,<br>labels.md")]
search --> files
judged --> comment[("comment.md")]
comment -.-> apply["apply acts<br>only on the<br>reporter's or<br>a maintainer's<br>comment or<br>question"]
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class task trigger
class which question
class window,search,judged,apply job
class issue,files,comment safe
LanguagesΒΆ
The assistant answers every reporter in their language. Its own words, from the thanks and the headings to the footer, the reminders and the notes of a release, come from a catalog for each language in locales/, which has seven:
| Code | Language | Addresses a reporter with | A day |
|---|---|---|---|
de |
German | du | 2. November 2026 |
en |
English | you | November 2, 2026 |
es |
Spanish | tΓΊ | 2 de noviembre de 2026 |
fr |
French | vous | 2 novembre 2026 |
it |
Italian | tu | 2 novembre 2026 |
nl |
Dutch | je | 2 november 2026 |
pt |
Portuguese | vocΓͺ | 2 de novembro de 2026 |
flowchart TB
accTitle: How the assistant chooses the language of a comment
accDescr: The AI answers in the reporter's language and names its code. A code of a catalog in locales/, such as fr, gives the assistant's own words and days from that catalog; other, for a language without a catalog, gives English words around the AI's text and days in ISO 8601. The hidden marker of the comment keeps the code. A comment without AI, such as a reminder, a closing or the note of a release, takes the code from the marker of the last analysis or follow-up; without one, from the reporter's own words, the language whose stopwords they use most, English on a tie.
ai["`**The AI** answers in the<br>reporter's language`"] --> code{"its code: a catalog<br>in locales/?"}
code -- "yes, such as fr" --> catalog["the words and days<br>of locales/fr.toml"]
code -- "no: other" --> english["English words around<br>the AI's text,<br>days in ISO 8601"]
catalog --> marker["the hidden marker<br>keeps the code: lang=fr"]
english --> marker
sweep["`**A comment without AI**<br>reminder, closing, release`"] --> found{"a marker of an<br>analysis or a follow-up?"}
found -- "yes" --> same["the language of the marker"]
found -- "no" --> words["the reporter's own words:<br>most stopwords of a catalog,<br>English on a tie"]
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class code,found question
class ai,sweep,catalog,english,words job
class marker,same safe
- The AI names the language of its answer. The prompt lists the codes of the catalogs, and the schema of the answer takes them and
other. For a language without a catalog, such as Japanese, the AI answers withotherand writes in Japanese, and the assistant's own words around its text are English, with days in ISO 8601, such as 2026-11-02. - The comments without AI keep it. The hidden marker of an analysis or a follow-up records the language, such as
lang=fr, and the reminder, the closing, the closing of a duplicate and the comment of a release read it there. Before the first analysis, such as on an issue that a maintainer marks as a duplicate, the reporter's own words decide: the language of the catalog whose stopwords they use most, English on a tie or when no other language has at least two. - Each catalog is complete. The tests hold every catalog to English: the same texts, the same placeholders in braces, twelve months and stopwords that no other catalog has. A text that a catalog still lacks comes from English, so that a comment never fails for want of one. The English and German texts are written by hand; the French, Spanish, Portuguese, Italian and Dutch ones are machine drafts, which native speakers are welcome to improve. The section What happens with your issue that
installwrites comes in English and German.
To add a language, copy locales/en.toml to locales/<code>.toml, named after the code of the language in ISO 639-1, such as pl.toml. Translate each text and keep its placeholders, such as {author}, and set the name of the language in English, how it addresses a reporter, how it writes a day, its twelve months and at least twenty stopwords: frequent words of the language that no other catalog has. Nothing else changes: the prompt, the schema of the answer and the guess of a reporter's language take the new file. bash scripts/check.sh runs the tests that check it.
Limits of the AI runsΒΆ
Anyone can open an issue, and every run of the AI costs money or counts toward the usage limits of a subscription. So the runs that people who don't maintain the repository start have limits, which [engine] in config.toml sets:
| Setting | Default | What it limits |
|---|---|---|
max_per_day |
20 | analyses of new issues, follow-ups, replies after a release and new analyses that reporters ask for with /assistant again together, in the 24 hours before an event |
max_per_author_per_day |
3 | analyses of the new issues of one reporter, in the same 24 hours |
release_replies |
2 | comments of the reporter after a release closed their issue that the AI reads, again after each release |
flowchart TB
accTitle: How the limits decide whether the AI runs
accDescr: A new issue, an answer of the reporter or a comment of the reporter after a release asks for a run of the AI. When a maintainer asks, or someone runs the workflow by hand, the AI runs. For everyone else, a comment after a release needs the AI to have read fewer than release_replies comments of the reporter since the release. Then the day needs room: fewer than max_per_day new issues, follow-ups and replies after a release in the 24 hours before. A new issue also needs its reporter to have opened fewer than max_per_author_per_day issues in those 24 hours. Over a limit, the maintainer takes over, and the run notes which limit was reached.
asks(["a new issue, an answer<br>or a comment after a release"]) --> who{"a maintainer,<br>or by hand?"}
who -- "yes" --> runs["the AI runs"]
who -- "no" --> release{"`after a release:<br>room in<br>**release_replies**?`"}
release -- "yes, or no release" --> day{"`room in<br>**max_per_day**?`"}
release -- "no" --> waits["the maintainer takes over;<br>the run notes the limit"]
day -- "no" --> waits
day -- "yes" --> new{"`a new issue:<br>room in<br>**max_per_author_per_day**?`"}
new -- "yes, or no new issue" --> runs
new -- "no" --> waits
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef person fill:#0969da26,stroke:#0969da,stroke-width:2px
class asks trigger
class who,release,day,new question
class runs ai
class waits person
- Every new issue counts, also one that a limit kept from the AI, and each counts only the issues opened before it. So a flood of issues at once, or issues made to keep the AI from answering, start no more runs than the limit, even while the first analyses still run. Issues and comments of maintainers and bots and runs by hand never count and are never limited.
- A follow-up, a reply after a release and a reporter's
/assistant againcount as they start. The event job records the run in the hidden marker of the assistant's own comment that the run answers: the questions of the analysis, the note about the release, or the analysis that the new one repeats. Only the assistant can change its comments, so a reporter who deletes their comments gets no more runs, and an edit notifies nobody. Answers that come in within the same few seconds can still pass the count together. - Over a limit, nothing is lost. A new issue gets the labels of its form and
needs-triage, as a maintainer's own issue does, and an answer of the reporter still ends the waiting. The run notes the limit and its setting, such as Daily limit of AI runs reached (max_per_day = 20); to get an analysis anyway, run the workflow by hand. - The day moves on with every event. A repository that usually gets more new issues a day than
max_per_daygets analyses again only when fewer came in during the 24 hours before, so set it above the usual number.0lets only maintainers start the AI, by hand.
With the defaults, strangers can start about 20 runs a day, about $4 at the prices of the API for first analyses.
What one run may costΒΆ
The limits above count the runs; [engine] in config.toml also bounds each one. For the task of a run, the step engine chooses the model, the turns and the dollars at which the engine stops, and the step of the engine passes them to its command line:
| Task | Model | Turns by default | What it reads |
|---|---|---|---|
A first analysis, triage |
model |
40, all of max_turns |
the issue, the other issues, the discussions and the repository |
| A follow-up | model |
20, half of max_turns |
the same, with the reporter's answers to the questions |
A maintainer's question, /assistant ask |
model |
40, all of max_turns |
the issue, the question and the repository |
| A reply to a maintainer's comment or after a release | light_model, else model |
4, a tenth of max_turns, but never fewer than 4 |
the issue and the one comment that it judges, yes or no |
flowchart TB
accTitle: What bounds one run of the AI
accDescr: The event job names the task of the run. The engine command chooses from [engine] in config.toml what the run may use: the model, or light_model for a reply to one comment; the turns, all of max_turns for an analysis or a maintainer's question, half for a follow-up and a tenth for a reply; the dollars of max_budget_usd and the effort. The context command writes the rules into rules.md and the task into prompt.md. The engine takes the rules as its system prompt and the task as its prompt, reads the checkout and stops at its turns or its dollars; its answer goes to the job apply. The usage command reads the numbers of the run, also after a failure, and writes one line into the summary of the job: the turns, the time, the cost and the tokens. When the run stopped at a limit, a warning names the setting.
task(["the task of the run"]) --> engine["`**engine**<br>model or light_model,<br>max_turns: all, half<br>or a tenth,<br>max_budget_usd, effort`"]
config[("config.toml<br>[engine]")] --> engine
task --> context["`**context**<br>the rules into rules.md,<br>the task into prompt.md`"]
engine --> ai["`**the engine**<br>the rules as its system<br>prompt, the task as its<br>prompt; stops at its<br>turns or its dollars`"]
context --> ai
ai --> answer["its answer, JSON,<br>to the job apply"]
ai -. "also after<br>a failure" .-> usage["`**usage**<br>one line of numbers:<br>turns, time, cost<br>and tokens`"]
usage --> summary[("the summary<br>of the job")]
usage -. "stopped at<br>a limit" .-> warning["a warning that<br>names the setting"]
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef stop fill:#cf222e26,stroke:#cf222e,stroke-width:2px
class task trigger
class engine,context,usage job
class ai ai
class config,answer,summary safe
class warning stop
- Every run stops once it has spent
max_budget_usd, $1.00 unless you set another sum, as the engine counts it at the prices of its API. The engine checks the sum between its calls to the API, so a run can go past it by the cost of its last turn. An issue written to make the AI read on gets no further than the turns and the dollars of its task, and a run that stops there posts nothing. effortsets how much a model that takes it thinks, fromlow, the cheapest, tomax; without it, the model's default runs.- The rules come first.
contextwrites them into.issue-assistant/rules.md, and the engine takes them as its system prompt, apart from its task in the prompt and from the texts of GitHub that it reads in the folder. - What each run cost is one line in the summary of its job, numbers only, also after a failure: the model, how the run ended, its turns and time, its cost against
max_budget_usdand its tokens, such as claude-opus-5-5 answered after 15 of 40 turns and 52 s, $0.202 of $1.00, 412,345 tokens in (398,000 of them from the cache) and 3,210 out. A run that stopped at its turns or its dollars also gets a warning that names the setting to raise.
What the sweep tells the maintainersΒΆ
The summary of the morning sweep, on the page of its run in the Actions tab, says what waits on whom, and once a month how well the assistant did. A sweep started by hand has both:
flowchart TB
accTitle: What the morning sweep tells the maintainers
accDescr: After its steps, the morning sweep, or one started by hand, reads the comments and issues of the last 30 days and the open issues labeled needs-triage, needs-info and fixed-in-next-release. Its summary lists the issues that wait on the maintainers, those that wait on their reporters with the day of the reminder or the closing, and the fixed ones that wait for a release. On the first day of a month, and in a sweep started by hand, it reads the label events of the issues analyzed in those 30 days and adds the numbers of the assistant's quality. The summary holds numbers and links only, never the text of an issue.
sweep(["the morning sweep,<br>or one by hand"]) --> reads["reads the comments and issues<br>of the last 30 days and the open issues<br>labeled needs-triage, needs-info<br>and fixed-in-next-release"]
reads --> digest["`**Maintainer digest**<br>what waits on the maintainers,<br>on reporters and on a release`"]
reads --> monthly{"the first day of a month,<br>or by hand?"}
monthly -- "yes" --> events["reads the label events<br>of the issues analyzed"]
events --> quality["`**Assistant quality**<br>analyses, time to the analysis,<br>labels kept, duplicates,<br>questions and feedback`"]
digest --> summary["the summary of the job:<br>numbers and links only"]
quality --> summary
classDef trigger fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef person fill:#0969da26,stroke:#0969da,stroke-width:2px
class sweep trigger
class monthly question
class reads,events job
class digest,quality,summary person
- The maintainer digest, every morning, lists the open issues in three parts, each the longest waiting first and, among those of a day, the one with the most π first, up to 20 a part:
- Waiting on the maintainers: an issue of someone else that needs triage for more than 3 days, one whose reporter answered a maintainer in the last 30 days with no word of a maintainer since, and one where someone objected in a comment to the duplicate notice and no maintainer answered. A fixed issue waits for its release alone.
- Waiting on reporters: every issue labeled
needs-info, with the day of its reminder, or of its closing once reminded, or why it is exempt. - Fixed, waiting for a release: every issue labeled
fixed-in-next-release, with the pull request that fixed it. - The assistant's quality, on the first day of a month, has the numbers of the 30 days before:
| Number | What it counts |
|---|---|
| Analyses posted | the analyses, and the issues they were on |
| Median time from a new issue to its analysis | for the issues opened in the 30 days |
| Labels of the AI kept for 14 days | the kinds, areas and topics that an analysis or follow-up added and no maintainer removed within 14 days, without the labels that the area field of the form chose; one of the last 14 days counts as kept until someone removes it |
| Labels that maintainers added within 7 days | kinds, areas and topics that a maintainer added in the week after the first analysis, which the AI missed |
| Duplicate notices | closed as a duplicate, by the sweep or a maintainer; objected to in a comment; kept open otherwise; and still waiting for the closing |
| Questions to reporters | the issues whose analysis or follow-up asked: answered by the reporter in a comment, closed without an answer, settled otherwise, as by a maintainer, and still waiting |
| Feedback on the analyses | the π and π on them |
- Numbers and links only. Anyone who can read the repository can open the run, so the summary names issues by their number and never shows a title or a text.
- It costs few requests. The digest lists the comments and issues of the 30 days and the open issues of three labels, and reads the comments and events of each issue that waits for its reporter, which the sweep has read for its reminders already, so it reads them once. The quality reads the events of the issues analyzed in the 30 days, at most of the 100 analyzed last. A sweep after a merge or a release has neither, and GitHub failing them is a warning in the log, never a failed sweep.
When GitHub failsΒΆ
GitHub has bad minutes too: a rate limit, a server error, an incident. The assistant never takes such an error for an answer, so a bad minute never changes what it posts:
flowchart TB
accTitle: What the assistant does when a call to GitHub fails
accDescr: A call to GitHub that succeeds lets the run go on. Not found means that the thing doesn't exist, and it is left out. A secondary rate limit runs the call again after a minute, up to three times. A server error runs a read again after 2, 4 and 8 seconds. Every other error, and one that stays, stops the run with one error that names what it wrote; running it again completes the rest without a second comment.
call["a call to GitHub"] --> answer{"GitHub<br>answers"}
answer -- "with the thing" --> on["the run goes on"]
answer -- "not found" --> left["left out: it<br>doesn't exist"]
answer -- "with an error" --> error{"which<br>error?"}
error -- "secondary<br>rate limit" --> minute["again after a minute,<br>also a write"]
error -- "server error" --> read["a read: again after<br>2, 4 and 8 seconds"]
error -- "any other,<br>or one that stays" --> stop["the run stops with<br>one error and names<br>what it wrote"]
stop --> again["running it again<br>completes it, without<br>a second comment"]
classDef question fill:#d4a72c33,stroke:#bf8700,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
classDef stop fill:#cf222e26,stroke:#cf222e,stroke-width:2px
class answer,error question
class call,minute,read job
class on,left,again safe
class stop stop
- Only "not found" means missing. A duplicate, a related issue or discussion, or a release that GitHub says doesn't exist is left out. A rate limit or a server error stops the run instead, so an analysis never leaves out a duplicate that GitHub didn't show for a moment, and the AI never reads "no release yet" because GitHub failed.
- A failure of a moment runs again. A read that a server error or a broken connection stopped runs again after 2, 4 and 8 seconds. A call that a secondary rate limit refused runs again after a minute, a write too, since GitHub did nothing with it. A write that a server error stopped doesn't run again, since GitHub may have done it all the same: the run stops, and running it again completes it.
- The labels come before the comment. An analysis or a follow-up adds its labels first,
possible-duplicateamong them, then its comment, andneeds-infolast: an issue never waits for an answer to a question that GitHub didn't post, so the sweep never reminds its reporter or closes it for that. Running the job again posts a missing comment once. When the comment is there and labels are missing, after a failure in between or from a run of an earlier version, it adds them without a second comment: the labels of the answer, andneeds-infoandpossible-duplicateas the comment said. A label that someone added or removed after the comment stays as it is, and so does an issue whose reporter answered. - One issue never stops the sweep. Every step and every issue of the daily sweep runs on its own. One that GitHub fails is a warning in the log, the others are done, and the run fails at the end, so that GitHub tells the maintainer. The next sweep completes it: each step checks what is done, so no reminder or closing comment is posted twice, and an issue closes before its labels go, so that the sweep finds it again. A fixed issue gets its labels before its notice: it stops waiting for an answer and gets
fixed-in-next-release, so a notice that GitHub fails never leaves it to be closed unanswered. - One clear error. A command that GitHub stops ends with one error in the log, not a Python traceback, and its summary lists what the run wrote before and the write that failed.
SecurityΒΆ
Built against PromptPwnd and Comment and Control, the attacks that make an AI in GitHub Actions run commands, leak secrets or write what an issue tells it to. The assistant follows every mitigation that the research on them recommends for a workflow like it, each pinned by a test; the security design maps them one by one. The issue examples of the Claude Code action, by contrast, give the AI a token that writes to issues.
Issues come from anyone, so the design assumes that an issue tries to steer the AI. Red is what anyone can write; the numbers are the trust boundaries of the security design:
flowchart TB
accTitle: The trust boundaries of the issue assistant
accDescr: Anyone can write an issue, its comments, other issues and discussions, and a pull request. The text reaches the event job, which runs no AI and chooses the task, and, as data in files, the job analyze: the first boundary. There the AI reads with Read, Grep and Glob alone, behind a firewall, with a read-only token and the engine's secret. Its answer in JSON crosses the second boundary into the job apply, which runs no AI and holds no secret. Its checks want the schema, labels and issues that exist, and no secret, mention, image, HTML or foreign link. When they fail, nothing is posted; when they pass, apply writes to GitHub, only to the issue of the event: the third boundary. A merged pull request starts issue-lifecycle.yml on the default branch, without AI, which learns only the issues that it fixes, the fourth boundary, and writes to GitHub.
text["issue, comments,<br>other issues, discussions"]
merged(["merged pull request"])
event["`**event**<br>no AI`"]
subgraph firewall ["analyze: firewall, read-only token"]
analyze["`**the AI** with Read,<br>Grep and Glob,<br>the engine's secret`"]
end
subgraph checked ["apply: no AI, no secret"]
checks("`**checks**<br>schema, labels and<br>issues that exist; no<br>secret, mention, image,<br>HTML or foreign link`")
refused["nothing is posted"]
end
text -- "1 Β· as data, in files" --> firewall
text --> event
event -- "the task" --> firewall
firewall -- "2 Β· an answer in JSON" --> checked
checks -- "fails" --> refused
checks -- "3 Β· passes: only the<br>issue of the event" --> github[("GitHub")]
merged -- "4 Β· only the issues it fixes" --> lifecycle["`**issue-lifecycle.yml**<br>the default branch, no AI`"]
lifecycle --> github
classDef untrusted fill:#cf222e26,stroke:#cf222e,stroke-width:2px
classDef ai fill:#8250df33,stroke:#8250df,stroke-width:2px
classDef job fill:#8c959f1f,stroke:#8c959f,stroke-width:2px
classDef safe fill:#1a7f3726,stroke:#1a7f37,stroke-width:2px
class text,merged untrusted
class analyze ai
class event,lifecycle,checks,refused job
class github safe
- The AI only reads. It runs in the
analyzejob, with a read-only token, on GitHub's egress-firewall runner with an allow list in.github/egress-firewall.yaml. Claude Code runs with--restrictedand the toolsRead,GrepandGlobalone: no commands, no web pages, no files outside the checkout. The issue, the other issues and the discussions reach it as files that the prompt calls data, not instructions. - Issue text can't pass for someone else or hide. The AI learns who wrote what from headings that only the assistant writes; each text stands below them in a code fence that nothing in the text can close, so a line such as
## Comment 4 by @owner (maintainer)stays part of that text. Characters that GitHub shows as nothing, such as Unicode tags and zero-width spaces, which can spell instructions that no maintainer sees, are removed from every text the AI reads and from its answer. And since every analysis reads the excerpts of many other issues, only an original that a maintainer opened or triaged can close a duplicate on its own, so no issue can steer the AI into closing others. - Nothing is posted unchecked. The AI's answer is JSON with a fixed schema. The
applyjob, which runs no AI and never sees its secret, checks it against the schema, the repository's labels and the issues that exist, refuses anything that looks like a token or key, but not the links and paths that answers are full of, turns mentions, e-mail addresses and references to other repositories into code, drops images, HTML and links to other sites, and links files only when git tracks them. What counts is the whole comment as GitHub shows it, so the code of the AI stays code in a list and behind an issue number too, and no table cuts it in two; the security design shows how, and how property tests and fuzzing judge whole comments by a model of GitHub's rendering of their own. It writes only to the issue of the event. - Its words run nothing outside a comment either. The AI's words also reach the log of the run, its summary and, as the reason for a label or a suggested duplicate, the timeline of the issue. There they are cleaned like a comment and put on one line, every error and notice of the assistant is escaped, and the summary turns workflow commands off while it prints, so that no answer can run one, such as
::add-mask::. A possible security report gets a fixed reason on the timeline instead of the AI's words, and neither label names nor the model ofconfig.tomlcan add arguments to the command line of the engine. - No issue can stall it. Every analysis reads the texts of every open issue and of many closed ones, so the assistant reads each text in time that grows only with its length, and no more of it than GitHub keeps. The AI gets at most 20,000 characters of each text of its issue. Tests and fuzzing check that no crafted text takes longer than half a second.
- No stranger can run up the bill. The runs of the AI that people who don't maintain the repository start have limits: by default 20 analyses, follow-ups and replies after a release a day, 3 analyses of one reporter's new issues, and 2 comments of a reporter after each release. Every new issue counts, also while the first analyses still run, so a flood of issues at once starts no more, and a reporter who deletes their comments gets no more replies.
- The secret stays in one place: the environment
issue-assistant, which only the default branch may use. - No code of a pull request runs. The lifecycle workflow starts on
pull_request_targetonly to learn which issues a merged pull request fixes. It checks out the default branch, runs no AI and skips Dependabot's pull requests. - The jobs that write reach only GitHub. Every job without the AI starts with Harden-Runner in block mode: the network allows
api.github.comandgithub.comalone, besides the runner's own hosts, and no step can use sudo or containers to lift the block. A compromised action or tool in a job with a write token can't send it anywhere else. The Action test of this repository runs the action behind the same block. The block holds in a public repository, and in a private one only with a subscription or trial of StepSecurity, the maker of Harden-Runner; without one, or when its service is unavailable, the job runs unblocked. StepSecurity receives the repository, the run and the job of each of these jobs, and the hosts that its steps connect to. - Findings stay private. Code scanning alerts are only shown to maintainers, but the logs of a public repository's runs are public. So the Findings workflow names an open alert by its number and link only, never its rule, file or message, and nothing becomes an issue.
- Every repository uses exactly the templates. The templates are the four workflows and the firewall policy in
templates/, which this repository uses for its own issues and findings too.checkfails when a repository's workflow differs from its template in anything but the commit hashes of its actions, and the tests of this repository pin the rules of the templates.
What apply makes of an answer that an issue steered:
The security design lists the threats, their countermeasures and the tests that pin them. Report vulnerabilities privately, as described in SECURITY.md.



