This directory contains reusable GitHub Actions workflows that can be used across all Knitli repositories.
Checks if PR contributors have signed the CLA and stores signatures centrally.
Usage in your repository:
# .github/workflows/cla.yml
name: CLA Assistant
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
cla-check:
uses: knitli/.github/.github/workflows/cla-check.yml@main
with:
repo_name: "your-repo-name" # e.g., "codeweaver", "thread"
cla_document_url: "https://github.com/knitli/your-repo/blob/main/CONTRIBUTORS_LICENSE_AGREEMENT.md"
secrets: inheritInputs:
repo_name(required): Repository name for signature file (e.g., "codeweaver")cla_document_url(optional): URL to CLA document (defaults to repo'sCONTRIBUTORS_LICENSE_AGREEMENT.md)branch(optional): Branch for storing signatures (default: "main")
Secrets:
CLA_ACCESS_TOKEN: Organization-level secret with write access toknitli/.githubrepo
Features:
- Checks org membership automatically - org members are exempt from CLA
- Automatically exempts bots and automation accounts (e.g.,
claude,copilot,dependabot) - Posts clear success message when all contributors are exempt
- Stores signatures in
knitli/.github/cla-signatures/{repo_name}.json - Friendly PR comments with clear instructions for non-exempt contributors
Examples:
A reactive, persona-driven Claude agent ("Knitli Agent · PR Reviewer") that
reviews pull requests under the knitli-agent[bot] identity — distinct from
human users and from github-actions[bot]. It posts inline diff comments plus a
single branded summary comment.
Usage in your repository:
# .github/workflows/claude-pr-reviewer.yml
name: Claude PR Reviewer
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
review:
permissions:
contents: read
pull-requests: read
id-token: write
uses: knitli/.github/.github/workflows/claude-pr-reviewer.yml@main
secrets: inheritA ready-to-copy caller lives at examples/claude-pr-reviewer.yml.
Inputs:
review_alias(optional): comment mention that triggers an on-demand review (default:@knitli-review)model(optional): override the Claude model (e.g.claude-opus-4-8); empty uses the action default
Secrets (org-level, granted to the target repo) — shared by all knitli-agent personas:
KNITLI_AGENT_CLIENT_ID: Client ID for theknitli-agentGitHub AppKNITLI_AGENT_PRIVATE_KEY: private key (.pem) for theknitli-agentGitHub AppCLAUDE_CODE_OAUTH_TOKEN: org subscription OAuth token for Claude CodeANTHROPIC_API_KEY(optional): API credential preferred over subscription OAuth when configured; useful when subscription OAuth is unavailable in CI
Triggers:
- Auto:
pull_request(opened, synchronize, reopened, ready_for_review) - On-demand: a comment containing
@knitli-reviewon a PR (issue comment) or on a diff line (review comment)
Reviews are queued one at a time per caller repository (queue: max) so a
stacked-PR submission cannot consume the provider quota in one burst. Before
starting the agent, the workflow skips superseded queued events and performs a
one-token Anthropic availability probe. The probe logs only the credential type,
HTTP status, and allowlisted rate-limit headers; it never logs the credential or
response body. This keeps authentication failures (401) distinguishable from
quota exhaustion (429) without enabling the action's sensitive full-output
mode.
Permission model (three layers of "review only" enforcement):
- The minted App token is down-scoped to
pull-requests: writeplus read-onlycontents,checks,issues,actions, andsecurity-events. - The workflow
GITHUB_TOKENcarries only the scopes the action needs; all author-facing writes flow through the App token (so comments post asknitli-agent[bot]). --allowedToolsrestricts Claude to the GitHub MCP servers (mcp__github_*, which includes inline review comments) plus read-onlygh pr/gh issue/gh runqueries andAgent(Explore)— no git, no push, no file edits. The review itself runs the/code-reviewplugin (code-review@claude-code-plugins).
Note on fork PRs: this uses pull_request (not pull_request_target), so PRs
from forks are intentionally skipped — fork PRs cannot access secrets. Enabling
fork review needs a separate, carefully-reviewed pull_request_target variant.
A reactive Claude triage persona ("Knitli Agent · Issue Triage") that labels,
dedups, and routes issues under the same knitli-agent[bot] identity. It applies
existing labels, links likely duplicates, asks for missing information, and posts
one branded summary comment — it never closes, assigns, or edits the issue body.
Usage in your repository:
# .github/workflows/claude-issue-triage.yml
name: Claude Issue Triage
on:
issues:
types: [opened, reopened]
issue_comment:
types: [created]
jobs:
triage:
permissions:
contents: read
issues: write
pull-requests: read
id-token: write
actions: read
checks: read
uses: knitli/.github/.github/workflows/claude-issue-triage.yml@main
secrets: inheritA ready-to-copy caller lives at examples/claude-issue-triage.yml.
Inputs:
triage_alias(optional): comment mention that triggers an on-demand triage (default:@knitli-triage)model(optional): override the Claude model; empty uses the action default
Triggers:
- Auto:
issues(opened, reopened) - On-demand: a comment containing
@knitli-triageon an issue (not a PR)
Permission model:
- The minted App token is down-scoped to
issues: writeplus read-onlycontentsandpull-requests. - The workflow
GITHUB_TOKENcarries only the scopes the action needs. --allowedToolsrestricts Claude to the GitHub MCP servers plus read-onlyghqueries andgh issue/gh labellabel & comment commands — no git, no push, no file edits.
A reactive Claude security persona ("Knitli Agent · Security Review") that does a
focused application-security pass on pull requests under the same
knitli-agent[bot] identity. It flags only real, plausibly-exploitable findings
as inline comments and posts one branded, severity-ordered summary comment.
Usage in your repository:
# .github/workflows/claude-security-review.yml
name: Claude Security Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
security-review:
permissions:
contents: read
pull-requests: write
id-token: write
actions: read
checks: read
security-events: read
uses: knitli/.github/.github/workflows/claude-security-review.yml@main
secrets: inheritA ready-to-copy caller lives at examples/claude-security-review.yml.
Inputs:
security_alias(optional): comment mention that triggers an on-demand security review (default:@knitli-security)model(optional): override the Claude model; empty uses the action default
Triggers:
- Auto:
pull_request(opened, synchronize, reopened, ready_for_review) - On-demand: a comment containing
@knitli-securityon a PR or a diff line
Permission model:
- The minted App token is down-scoped to
pull-requests: writeplus read-onlycontents,checks, andsecurity-events(code scanning alerts). - The workflow
GITHUB_TOKENcarries only the scopes the action needs; all writes flow through the App token. --allowedToolsrestricts Claude to the GitHub MCP servers plus read-onlygh prcommands — no git, no push, no file edits.
Note on fork PRs: same pull_request limitation as the reviewer — fork PRs are skipped.
A reactive Claude docs persona ("Knitli Agent · Docs") that makes documentation
changes under the knitli-agent[bot] identity. On-demand only: mention
@knitli-docs in a comment on an issue or PR and it edits docs on a new branch,
opens a PR, and links it back. It never touches source-code logic or the default
branch.
Usage in your repository:
# .github/workflows/claude-docs.yml
name: Claude Docs
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
docs:
permissions:
contents: write
pull-requests: write
issues: read
id-token: write
actions: read
checks: read
uses: knitli/.github/.github/workflows/claude-docs.yml@main
secrets: inheritA ready-to-copy caller lives at examples/claude-docs.yml.
Inputs:
docs_alias(optional): comment mention that triggers a docs change (default:@knitli-docs)model(optional): override the Claude model; empty uses the action default
Permission model:
- The minted App token is down-scoped to
contents: write+pull-requests: write(push a branch, open a PR) +issues: read. - The workflow
GITHUB_TOKENcarries only the scopes the action needs. --allowedToolsgives Claude file edits +git/gh+ the GitHub MCP servers — no build/test runners. The prompt forbids source-code logic changes, default-branch pushes, and force-pushes.
A reactive Claude implementation persona ("Knitli Agent · Fix") — the
highest-trust bot. On-demand only: mention @knitli-fix in a comment on
an issue or PR and it explores the codebase, makes a small tested change on a new
branch, runs the project's test/lint commands, opens a PR, and links it back.
Usage in your repository:
# .github/workflows/claude-fix.yml
name: Claude Fix
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
fix:
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
checks: read
uses: knitli/.github/.github/workflows/claude-fix.yml@main
secrets: inheritA ready-to-copy caller lives at examples/claude-fix.yml.
Inputs:
fix_alias(optional): comment mention that triggers a fix (default:@knitli-fix)model(optional): override the Claude model; empty uses the action default
Permission model:
- The minted App token is down-scoped to
contents: write+pull-requests: write+issues: write+checks: read. - The workflow
GITHUB_TOKENcarries only the scopes the action needs. --allowedToolsgives Claude file edits +git/gh+ a curated set of build/test runners (mise,make,npm,pnpm,yarn,bun,uv,cargo,go,pytest) + the GitHub MCP servers. Extend the runner list in the workflow to match a repo's toolchain. The prompt forbids default-branch pushes, force-pushes, and weakening tests.
A deterministic (non-Claude) find-and-replace bot: scans a PR's changed files
and rewrites British spellings to their American equivalents (colour ->
color, initialise -> initialize, travelled -> traveled, ...), then
commits the fix straight back to the branch. It exists because AI coding
assistants habitually leak British spellings into Rust code, which then trips
typos in every repo that gates CI on it — teaparty fixes that before the
gate ever sees it.
Usage in your repository:
# .github/workflows/teaparty.yml
name: Teaparty
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
teaparty:
permissions:
contents: write
uses: knitli/.github/.github/workflows/teaparty.yml@main
secrets: inheritA ready-to-copy caller lives at examples/teaparty.yml.
Inputs:
dry_run(optional): report what would change without committing (default:false)exclude_paths(optional): newline-separated extended-regex (ERE) fragments; a changed file whose path matches any of them is skipped. Added to the built-in exclusions, never replaces them. Blank lines and#comments are ignored; an invalid pattern fails the run rather than silently scanning nothing. See Byte-sensitive paths below.
Secrets (both optional):
KNITLI_AGENT_CLIENT_ID/KNITLI_AGENT_PRIVATE_KEY: if provided, the fix commit is pushed asknitli-agent[bot]; otherwise it falls back to the defaultGITHUB_TOKENand shows up asgithub-actions[bot]
How it works:
- Diffs the PR's changed files (three-dot, against the merge base) and keeps only common text/code file types — no lockfiles, no vendored/build directories, no binaries.
- Runs
scripts/teaparty/replace.pyagainst that file list, using the curated dictionary atscripts/teaparty/dictionary.yml(checked out from this repo). Case is preserved (Colour/COLOUR/backgroundColourall resolve correctly), and the dictionary intentionally leaves out ambiguous words (see the exclusion notes at the top of the file) to avoid the false positives it's designed to prevent. - If anything changed, commits and pushes straight to the PR branch. No changes found -> no commit -> nothing happens, so it's naturally idempotent and safe to run on every push.
Teaparty rewrites bytes in place, so before enabling it on a repo, work out
which of that repo's files must not be rewritten even when the spelling
genuinely is British, and list them in exclude_paths. The workflow has no way
to infer these — the built-in exclusions only cover the universal cases
(node_modules/, vendor/, target/, dist/, build/, lockfiles).
Typical categories:
- Vendored upstream sources. A specification or manual checked in as the
authoritative reference. Rewriting
amongst->amongthere is a silent edit to a source of record, and it breaks any review that re-verifies a quotation against the file's exact text. - Frozen legal or filing snapshots. Anything that must byte-match what was submitted elsewhere (a filed patent provisional, a signed attestation).
- Golden / expected-output fixtures. Rewriting an
.expected.jsonmakes a test pass by moving the goalpost instead of fixing the code. - Files with deliberate misspellings. Typo-recovery tables, mangled-input corpora, and docs whose examples are misspelled on purpose.
A good starting point is the repo's existing spell-check exclusions
(_typos.toml's extend-exclude, .codespellrc, ...) — those lists exist for
substantially the same reason. Keeping the two in sync is the intended
practice; marque-dev's caller is the worked example.
Teaparty edits only the files in a diff, so renaming an identifier breaks the
references to it that live in files the diff didn't touch — and it cannot tell
an identifier from prose. The dictionary is therefore curated to leave out words
that are plausibly identifiers: cancelled is excluded because
JoinError::is_cancelled (Tokio) and CancellationToken (.NET) are API
surface, so rewriting them is a compile error rather than a spelling fix.
If you hit a word in this category, the fix is to drop it from the dictionary
with a note in the exclusion list at the top of dictionary.yml — not to add
the affected file to exclude_paths. A path exclusion goes stale the moment a
new file uses the same identifier.
Any file that documents the fixer names the spellings it fixes, which means
teaparty rewrites it into nonsense. The caller workflow is the common case —
its header comment lists example British spellings, and a run turns that into
"British spellings (color, initialize, behavior)", which no longer says
anything. examples/teaparty.yml therefore ships with itself already in
exclude_paths; keep that entry when you copy it.
The same applies to this repository. There is deliberately no teaparty caller
in knitli/.github — only the reusable workflow — and adding one without
excluding scripts/teaparty/ would be self-destructive rather than merely
untidy: a run rewrites every key in dictionary.yml into an identity mapping
(colour: color -> color: color, 564 of them). That result is still valid
YAML and still runs, so teaparty would silently stop fixing anything rather
than fail. If a caller is ever added here, it must exclude at minimum:
exclude_paths: |
^scripts/teaparty/
^\.github/workflows/teaparty\.yml$
^\.github/workflows/README\.md$
^examples/teaparty\.yml$Note: this is not a Claude persona — there's no model call and no prompt.
It's a small, fully deterministic spelling fix, nothing else. It also refuses
to push directly to a repository's default branch even if a caller wires it
to a push trigger.
Every claude-* workflow above starts with a sanitize job that the agent job
declares needs: on. It scans the untrusted text that reaches the agent for
ANSI/Unicode echoback attack vectors and, on finding one, fails — which skips
the agent job. A red check explains why.
Two surfaces are scanned:
- Comments on the triggering issue or PR (conversation and review comments).
- The pull request diff, for the four workflows that can be invoked on a PR.
claude-issue-triageacts only on real issues, so it has no diff to scan.
The obvious design is to strip the threat out of the comment and let the agent read the cleaned text. That does not work, and it is worth understanding why before anyone tries to "improve" this into a filter.
claude-code-action deliberately reads the most attacker-controllable inputs
from the immutable webhook payload, not from the API:
| Agent input | Source | Effect of a post-trigger edit |
|---|---|---|
Trigger comment (@knitli-review …) |
create-prompt: context.payload.comment.body |
None — payload snapshot |
| Issue / PR body | extractOriginalBody() reads payload.issue.body |
None — payload snapshot |
| Earlier thread comments | live GraphQL fetch | comment is dropped from context by filterCommentsToTriggerTime() |
Those functions exist specifically to defeat TOCTOU attacks — the upstream
comment says extractOriginalBody prevents "TOCTOU attacks where an attacker
edits the body after the trigger but before the action reads it." A sanitizer
edits a comment after the trigger, so from upstream's perspective it is
indistinguishable from the attacker that defense was built to stop, and its
edits are discarded or cause the comment to be filtered out entirely.
A gate does not have this problem: it is a precondition, not a mutation, so it does not matter whether the agent later reads the payload or the API. It also composes with upstream's TOCTOU defense instead of fighting it.
The gate fails only on exit code 77 from distill-strip-ansi — a genuine
echoback vector. Content that merely contains escape sequences (a pasted
terminal capture with colour codes) is reported as stripped, not threat, and
does not block the agent. The sanitize preset is used rather than dumb
for the same reason.
gh pr diff returns exactly the bytes the agent itself reads when it runs
gh pr diff, so the gate scans the artifact the agent actually consumes rather
than an approximation of it. Two consequences worth keeping:
- No binary filter to get wrong. Git's diff format renders a changed binary
as
Binary files a/x and b/y differ, never its content. Verified against knitli/marque-dev#1656: 48 changed binaries, zero escape bytes in the diff. A "scan the changed files on disk" design has to classify binary itself, and that is easy to botch — a naive NUL-byte-in-first-8-KiB filter let all 110 of that repo's escape-bearing PDFs, fonts and images straight through, any one of which would have tripped a fail-closed gate and blocked review. - No checkout at all. The gate needs no
contentsscope and never places untrusted code on the runner.
A diff too large for the API makes the step exit non-zero, which fails the gate closed rather than scanning a truncated diff.
The gate does not scan:
- file content outside the diff. The agents hold
Read,GrepandGlob, so an agent can read a file the PR never touched. Only the diff is gated; whole-repository scanning is not practical on an interactive path.
That is the whole list. It is a real limit rather than an oversight, so don't describe the gate as total coverage either.
The issue/PR body and title gap this section used to describe is closed
(knitli/strip-ansi-action#8,
via scan-event-payload). How it is closed matters more than that it is:
Those fields are read from the immutable webhook payload, not re-fetched from
the API. The payload is a snapshot taken when the workflow triggered and cannot be
edited afterwards; a live fetch has no such property, and that difference is
exploitable — trigger on hostile text, then edit it clean, and a re-fetching scan
sees the sanitised version and passes while the agent reads the hostile original
out of the payload. Since claude-code-action deliberately reads the trigger
comment and the issue/PR body from the payload as its own TOCTOU defence, a gate
that re-fetches is not gating the same text the model reads.
Which means the payload scan also closed a fail-open in the comment surface this README previously described as sound: the triggering comment is now covered by the snapshot, not only by a live fetch that a post-trigger edit can launder. The comment inputs stay switched on regardless, because the payload carries only the triggering comment — earlier thread comments, which the agent also reads, still come from the API.
The payload scan needs no token and no permissions: grant; it reads a file the
runner already has.
Reading a PR's conversation comments requires pull-requests, even though
the endpoint is /issues/{n}/comments; reading a real issue's comments
requires issues. Granting only one of them and triggering the other context
403s, and because the gate is fail-closed a 403 blocks the agent.
So a caller job must grant the read scopes for whichever contexts it triggers
on. Each sanitize job declares only the scopes its workflow actually reaches,
and the examples/ callers are already correct. If you add an issues: trigger
to a caller that previously only fired on PRs, add issues: read alongside it.
This is not hypothetical: knitli/marque-dev ran a comment scanner with
issues: write but no pull-requests scope for 27 consecutive runs, and all 27
failed 403 on a PR thread without anyone noticing, because the file-scanning
half of the same workflow was green.
All three personas above share one GitHub App and one set of org secrets:
-
Register a GitHub App
knitli-agent(webhook disabled) with the union of permissions the personas down-scope from:- Pull requests: Read & write
- Contents: Read & write (reviewer/security narrow to read)
- Issues: Read & write (triage uses write; others read)
- Checks: Read
- Code scanning alerts: Read (security
security-events) - Metadata: Read (mandatory)
Install it on the org (all or selected repos).
-
Generate a private key (
.pem). -
Add org-level Actions secrets, granted to the target repos:
KNITLI_AGENT_CLIENT_ID(the App's Client ID, e.g.Iv23li...)KNITLI_AGENT_PRIVATE_KEYCLAUDE_CODE_OAUTH_TOKEN(already exists org-wide)ANTHROPIC_API_KEY(optional; preferred by the reviewer when present)
When creating new reusable workflows:
-
Use
workflow_calltrigger:on: workflow_call: inputs: # Define inputs here secrets: # Define required secrets
-
Add SPDX headers for licensing compliance
-
Document inputs and usage in this README
-
Test thoroughly before using in production repos
-
Version with tags for stability (optional):
uses: knitli/.github/.github/workflows/[email protected]
- ✅ Single source of truth: Update logic once, applies everywhere
- ✅ Consistency: All repos use identical, tested workflows
- ✅ Maintainability: Easier to fix bugs and add features
- ✅ DRY principle: Don't repeat workflow code across repos
If organization members are routinely being told they need to sign the CLA, there is an authentication issue with the CLA_ACCESS_TOKEN preventing the action from accurately retrieving organization membership.
Root Causes:
- The
CLA_ACCESS_TOKEN(a Personal Access Token) lacks theread:orgscope required to check organization membership. - The Knitli organization enforces SAML Single Sign-On (SSO), and the
CLA_ACCESS_TOKENhas not been explicitly authorized for SSO. Without SSO authorization, the GitHub API returns302/403/404errors for organization membership checks, even if the token owner is an admin.
How to Fix:
- Go to your GitHub Settings -> Developer settings -> Personal access tokens (classic).
- Generate a new token with the
read:organdreposcopes (or update the existing one). - Crucial Step: Next to your token, click "Configure SSO" and authorize it for the Knitli organization.
- Update the
CLA_ACCESS_TOKENorganization secret with this new, authorized token value.