ci: reusable PR triage workflow (local model, withhold-only approval) - #2
Merged
Merged
Conversation
…val) Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…velope Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…commit Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
…-coding docs-only Co-Authored-By: Lykos (Fable 5.1) <noreply@lykos.ai>
mohnjiles
approved these changes
Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A reusable
pr-triage.yml(workflow_call) plus the script it ships,pr-triage/triage.py, and its tests. A local model on Freya (qwen36-27b-fable-fusion-mtpthroughhttps://llm.ionite.io/v1, behind Cloudflare Access) reads a PR and either approves it or asks for a human.The rule
The model can only withhold approval.
decide()grants it only when every mechanical check passes AND the model saidapprove:OWNER/MEMBERon the payload andGET /repos/{repo}/collaborators/{login}/permissionreturnsadminorwrite(the payload can be shaped by the author; the collaborator API cannot);.github/is outside the envelope whatever the globs say;{"verdict": "approve" | "human", "reason": ...}object.Anything else posts or edits one triage comment (found by the
<!-- lykos-pr-triage -->marker, authored bygithub-actions[bot]) and exits 0. Model unreachable, model returned garbage, model returned onlyreasoning_content, GitHub API failure: all the same comment path,::warning::in the log, never a red run. Every run first dismisses the workflow's own earlierAPPROVEDreviews on that PR, then approves afresh if it may; without that, a docs-only push that got approved would carry its approval onto a later push that touches anything (Core's ruleset hasdismiss_stale_reviews_on_push: false).PR content reaches the model fenced as data with a system prompt that says so; text inside the PR that addresses the reviewer is itself a
humanreason. Even a fully jailbroken model output can do nothing outside the envelope. Secrets arrive as env vars and are sent as request headers only; nothing prints them (the e2e asserts their values never appear in the script output).Files
.github/workflows/pr-triage.yml— the reusable workflow. Inputsenvelope,model,endpoint,signature; secretsCF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,LLM_API_KEYreached via the caller'ssecrets: inherit. Checks out this repo at the same commit as the workflow file (job.workflow_sha,github.job_workflow_shaas the older spelling) with a sparse checkout ofpr-triage/, never the PR. Usage in the header comment, same convention as the other two workflows here.pr-triage/triage.py— stdlib only. Envelope, verdict parsing and the decision are pure functions; the two HTTP clients share onehttp()and honourGITHUB_API_URL/TRIAGE_ENDPOINT, which is what makes the e2e possible.pr-triage/tests/test_triage.py— 31 unit tests: envelope (outside, nested docs, root.md,.github/**excluded even under**, single-star segment semantics, malformed paths), rename paths, verdict parsing (valid JSON, JSON in prose/fences,<think>block, garbage, case), decision (every blocker), rendering.pr-triage/tests/test_e2e.py— 12 cases running the real script as a subprocess against a fake GitHub API + fake chat-completions server on localhost: approve (review POST withcommit_id, no comment), human (comment POST then PATCH on the next run, not a second POST), outside envelope with the model saying approve (comment only), non-member, stale approval dismissed, offline (connection refused), model 502, garbage reply, reasoning-only reply, GitHub outage..github/workflows/pr-triage-tests.yml— runs both on PRs touchingpr-triage/**.Verification
python3 -m unittest discover -s pr-triage/tests: 43 tests, OK (Python 3.12 locally; ubuntu-latest ships 3.12).decide()withoutside = []→ 5 red (test_outside_envelope_must_not_approve_even_when_the_model_says_approve,test_workflow_change_must_not_approve_even_when_the_model_says_approve,test_rename_out_of_envelope_must_not_approve, e2etest_outside_envelope_never_approves_and_only_comments, e2etest_a_stale_approval_is_dismissed_when_the_next_push_is_not_approvable); reverted; green.is_always_excludedfromEnvelope.covers→ 2 red (test_a_workflow_file_is_never_inside_even_when_a_glob_matches,test_a_markdown_file_under_dot_github_is_outside); reverted; green. The decision-level workflow test stayed green under this probe because the default globs do not match.github/…anyway, so I addedtest_workflow_change_must_not_approve_even_inside_a_wide_envelope(envelope**) in the second commit, which the same mutation reddens.Settings, checked
gh api repos/LykosAI/Lykos.Chat.Core/actions/permissions/workflow→{"default_workflow_permissions":"write","can_approve_pull_request_reviews":true}. The repo-level "Allow GitHub Actions to create and approve pull requests" is already on. The org-level value needs admin (403for me); if the org policy is off it overrides the repo and the approve call fails, which lands on the human comment.CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,LLM_API_KEYare Ionite's to set (org → Actions secrets, visible to the repos that call this); I could not list org secrets (403) and did not need to.lykos-reviewer: a LykosAI-owned GitHub App (id 4177422, installed org-wide,pull_requests: write,contents: read), backed by thelykos-reviewerAzure web app inLykos-Prodwith its own Key Vault (lykos-infraenvs/shared). Reusing it for the approval identity is possible the waybackport.ymldoes it (actions/create-github-app-tokenwith an app id variable + private-key secret), which would make approvals show as "Lykos Reviewer" and trigger downstream workflows; it needs the app's private key as an org secret. Built onGITHUB_TOKENas briefed; the swap is one step if wanted.Prompt renders the envelope (fix after a dry run)
A dry run against Core #95 and #89 (GitHub writes stubbed, Freya live) with the envelope widened to
Lykos.Chat.Core.csprojhad zero blockers fromdecide()but the model still answeredhuman, quoting the prompt's hard-coded "documentation or markdown only" rule back. The system prompt is now rendered fromTRIAGE_ENVELOPE(Envelope.system_prompt()): the globs and the.github/exclusion the clamp enforces are the envelope the model reasons about, one fact one place. The clamp is unchanged. Pinned by a unit test that the rendered prompt carries each glob, and two e2e cases: a widened envelope plus an approve verdict approves a.csprojpath (and the model's system message carried that glob), while the default envelope still refuses the same path. 47 tests OK.Deviations from the brief
gh pr review --approve: same endpoint, one HTTP client, and it is what lets the e2e fake GitHub too.GET /orgs/{org}/members/{user}:GITHUB_TOKENhas no orgmemberspermission (the org endpoint would 403 for private members and answer nothing useful), while the collaborator endpoint needs onlymetadata: read, which everyGITHUB_TOKENhas. Requiring write access is also the fact that matters for auto-approval.notesfield in the verdict JSON so the model can write more than one sentence in voice, and asignatureinput so the name can be changed without a script edit.reasoning_effort: "low"andmax_tokens: 2500(the shim shares the budget between thinking and answer; a too-small budget returns empty content, which is handled as offline).🐺 Generated with Lykos (Fable 5.1)