- Apply labels, Post summary comment, and Notify external system now guard with always() && steps.parse.outcome == 'success' && <condition> so a transient API failure in Close issue no longer silently skips all downstream steps (Forgejo/GitHub Actions implicitly ANDs success() onto bare if: conditions). - Removing bare guards also fixes a crash in the Notify step when triage-result.json is absent — the step now correctly skips when Parse didn't succeed. - Add timeout-minutes: 25 at the job level as defense-in-depth; only the opencode run step was previously time-boxed (via timeout 15m). - Document the always() pattern in the header comment so future editors don't simplify it back to the buggy form. |
||
|---|---|---|
| .forgejo/workflows | ||
| examples/callers/issue-triage | ||
| prompts/issue-triage | ||
| AGENTS.md | ||
| README.md | ||
signup/forgejo-public-workflows
Reusable Forgejo Actions workflows for self-hosted Forgejo instances.
Currently ships one workflow: AI-driven issue triage using opencode in headless mode.
What it does
The issue-triage reusable workflow is triggered when an issue is opened or reopened in a consumer (caller) repo. It:
- Reads the issue title and body from the event payload, plus a best-effort list of recent open issue titles (for duplicate detection — the agent has no issue-search tool of its own).
- Runs opencode headless (
opencode run --auto) against the caller's checked-out source so the agent can validate the issue's claims against the actual code. Tool access is locked down viaOPENCODE_PERMISSION— no shell exec, no network fetches, no edits outside the one result file, no reads/writes outside the repo checkout (see "Security model" below). - Writes a structured JSON decision to
./triage-result.json(file-write strategy — robust to multi-segment stdout), then parses it withjq, enforcing that all required keys are present (a truncated/malformed model response is treated as a failed run, not silently defaulted). - Applies the decision through the Forgejo REST API: labels the issue (resolving label names to IDs against the repo's real label set first — invented/nonexistent labels are dropped), posts a summary comment, closes spam/invalid issues, and optionally notifies an external system.
- Is a single self-contained job with all conditionals at the step level — the only robust design under Forgejo's reusable-workflow expansion (see the workflow header comment).
Security model
This workflow feeds untrusted-ish free text (the issue title/body) into an LLM prompt and lets the model drive real repo/API access. Two things bound the blast radius:
- Tool lockdown: the AI-analysis step sets
OPENCODE_PERMISSIONto denybash,webfetch,websearch,task, andexternal_directory, and restrictseditto only the triage result file. This matters even with--auto, which auto-approves anything not explicitly denied — including permissions that default to"ask"(likeexternal_directory), which would otherwise be silently upgraded to allowed. Without this, a crafted issue body could attempt to make the agent exfiltrateAI_PROVIDER_API_KEY(present in that step's environment) via a tool call, or read/write outside the repo. The prompt template also tells the model not to do this, but that's advisory only — the permission lockdown is what's actually enforced. - Output validation, not blind trust: labels the model suggests are checked against the repo's actual label set (by ID) before being applied; the JSON schema is checked for completeness before any field is trusted.
This workflow does not implement per-author rate limiting — it assumes caller repos restrict
issue creation to trusted accounts (e.g. private/internal repos). If you point this at a repo where
untrusted third parties can open issues, add your own gate (e.g. check forgejo.event.issue.user
against a collaborator list) before the AI-analysis step, since each triggered issue spends real
provider credits.
Requirements
- A self-hosted Forgejo instance with Actions enabled.
- Runners labeled
docker(registered under Settings → Actions → Runners). jqandcurlin the runner image (present in most base images).- This library repo (
signup/forgejo-public-workflows) public and on the same Forgejo instance as any consumer repo (workflow expansion needs both).
Quick start (cross-repo)
1. Add the caller workflow
Copy this file into your repo:
.forgejo/workflows/issue-triage.yml
With this content (see examples/callers/issue-triage/cross-repo-call.yml for the full annotated version):
on:
issues:
types: [opened, reopened]
jobs:
triage:
runs-on: docker
uses: signup/forgejo-public-workflows/.forgejo/workflows/issue-triage.yml@v1
with:
library_ref: v1
# Uses the workflow's built-in default model (openrouter/z-ai/glm-5.2).
# To override, uncomment and set a concrete provider/model ref:
# model: ${{ vars.AI_TRIAGE_MODEL }}
# …or a literal, e.g.:
# model: opencode-go/glm-5.2
secrets: inherit
2. Configure your repo
Set up the API key secret (required). The model is optional — the workflow ships a built-in default (openrouter/z-ai/glm-5.2), pinned by your @v1 tag. Override it only if you want a different provider.
| What | Where | Name | Value | Required |
|---|---|---|---|---|
| API key secret | Settings → Actions → Secrets → New secret | AI_PROVIDER_API_KEY |
your provider API key | yes |
| Model variable (optional override) | Settings → Actions → Variables → New variable | AI_TRIAGE_MODEL |
e.g. opencode-go/glm-5.2 |
no |
Get the API key from the matching provider console (see Supported providers below). The same AI_PROVIDER_API_KEY secret works for any provider — the workflow maps it to the env var opencode expects.
FORGEJO_TOKENis auto-provided by the runner on non-fork events — no action needed. If your runner suppresses the default token, add a PAT withreposcope under the same name.
3. Test the workflow
Open or reopen an issue. The PREFLIGHT step fails fast with setup guidance if anything is missing — no provider credits are spent until secrets are confirmed present.
Tag pinning: the caller pins the workflow YAML via
@v1. Thelibrary_ref: v1input pins the triage prompt separately. Bump both together when upgrading.
Secrets
Secrets are caller-scoped: secrets: inherit forwards the caller's secret set. The library
repo provides no secret material — a third party pointing at this workflow uses their own keys
and mutates their own issues.
| Secret | Required when | How to get it |
|---|---|---|
AI_PROVIDER_API_KEY |
Always (provider for opencode) | The workflow maps this one secret to the opencode env var expected for the model prefix. Get the key from the matching provider console (see "Supported providers" below). |
FORGEJO_TOKEN |
Always (API mutations) | Auto-provided by runner, or a PAT with repo scope added to repo secrets |
Inputs (with:)
| Input | Type | Default | Purpose |
|---|---|---|---|
model |
string | openrouter/z-ai/glm-5.2 |
Concrete provider/model ref forwarded to opencode --model. Optional — the default is baked into the workflow YAML, so it's pinned by your @v1 tag (reproducible, unlike library_ref). Override to use a different provider/key; auto is rejected by preflight. For multi-repo setups, overriding via ${{ vars.AI_TRIAGE_MODEL }} is still recommended so a single repo variable drives all callers. Examples: openrouter/z-ai/glm-5.2, opencode-go/glm-5.2. |
webhook_url |
string | "" |
External notification URL (POST JSON result). Empty = skip. |
extra_context |
string | "" |
Extra Markdown prepended to the prompt (project guidance, allowed label names, etc.) |
library_repo |
string | signup/forgejo-public-workflows |
Org/repo of this library, for the dual checkout of the triage prompt |
library_ref |
string | main |
Git ref of this library, used to fetch the triage prompt at runtime. Not the same as the @v1 on your uses: line — set this explicitly to match (e.g. library_ref: v1) or your prompt behavior can change even though your workflow is "pinned". Preflight emits a ::warning:: if left at the default. |
Supported providers
The preflight maps the model prefix to the opencode env var opencode autoloads for that
provider. The caller sets a single AI_PROVIDER_API_KEY secret; the workflow re-exports it
under the env var name opencode expects.
| Prefix | opencode env var | Where to get a key |
|---|---|---|
openrouter/* |
OPENROUTER_API_KEY |
https://openrouter.ai/keys |
opencode-go/* |
OPENCODE_API_KEY |
https://opencode.ai/auth (OpenCode Go subscription) |
anthropic/* |
ANTHROPIC_API_KEY |
https://console.anthropic.com/settings/keys |
openai/* |
OPENAI_API_KEY |
https://platform.openai.com/api-keys |
google/* |
GOOGLE_API_KEY |
https://aistudio.google.dev/apikey |
zai-coding-plan/*, zai/* |
ZHIPU_API_KEY |
https://open.bigmodel.cn/usercenter/apikeys (Z.AI) |
Models other than those listed fall through the map with a clear preflight error ("Provider ''
is not in the API-key map"). Extend the case block in .forgejo/workflows/issue-triage.yml (in
both the preflight and the AI-analysis step) to add a provider.
Customizing triage behavior
-
extra_contextis the per-project steering lever — pass allowed label names, severity calibration notes, or guidance about which parts of the codebase to inspect:with: extra_context: | Allowed labels: bug, feature, question, p0, p1, p2. This repo is a Go backend; triage performance issues against the handler packages. -
The triage prompt itself lives at
prompts/issue-triage/triage.md. To change the classification rules, validation methodology, or output schema, fork this library and edit that file (the workflow fetches it via dual checkout at runtime). -
The output schema is
{summary, severity, labels, is_spam, is_invalid, is_duplicate, duplicate_of, validation_report, suggested_comment}. If you're consuming thewebhook_urlpayload downstream, note the schema is versioned along with the workflow tag (@v1) — pin your consumer to a tag too if you depend on exact field names.
How it works
on.issues: [opened, reopened]
│
caller (this repo) owns the trigger, `uses: signup/forgejo-public-workflows/.forgejo/workflows/issue-triage.yml@v1`, secrets: inherit
│
issue-triage.yml one job, all conditionals step-level:
1. checkout caller repo → source opencode analyzes
2. checkout library → _triage_lib → fetch prompts/issue-triage/triage.md
3. preflight → fail fast if secrets missing; warn if library_ref unpinned
4. install opencode
5. fetch recent open issues → best-effort duplicate-detection context
6. build prompt → rm _triage_lib → clean source tree; issue body capped at 20k chars
7. opencode run --auto → tool-locked-down (no bash/webfetch/external dirs); writes ./triage-result.json
8. jq parse → validates required keys present; step outputs (is_spam, labels, ...)
9. close issue (if spam / invalid)
10. apply labels (if any) → resolved against the repo's real label IDs first
11. post summary comment (if any)
12. notify external system (if webhook_url set)
13. fallback comment (if parse failed — needs manual review)
Troubleshooting
| Symptom | Fix |
|---|---|
| Job stuck on "Waiting" | Add runs-on: docker to the caller job (known Forgejo expansion bug — see workflow header comment). |
| Secrets arrive empty | Define AI_PROVIDER_API_KEY and/or FORGEJO_TOKEN in the caller repo secrets, not in this library. |
| Preflight step fails | Read the ::error:: / ::group:: messages in the run log — they include the exact settings URL and setup steps. |
Preflight warns about library_ref |
Add library_ref: v1 (matching your uses: ...@v1) to your caller's with: block. |
opencode run timed out after 15m |
The 15m cap guards against runaway agent loops. Retry; if persistent, simplify the issue or raise the timeout in your fork. |
| Model arrives empty in preflight | If using ${{ vars.AI_TRIAGE_MODEL }}, ensure the variable is defined in the caller repo (Settings → Actions → Variables). ${{ secrets.* }} in a caller's with: does NOT work (known Forgejo expansion bug — secrets arrive empty). Use ${{ vars.* }} instead. |
| "triage failed" comment posted | opencode produced no/invalid/incomplete triage-result.json — check the run log for opencode error: lines or missing-key warnings; the issue needs manual review. |
::warning::None of the suggested labels ... exist |
The model suggested label names that don't exist on this repo — create them, or steer the model via extra_context listing your actual label set. |
License
MIT.
Contributing
PRs welcome. Please keep the single-job structure and step-level conditionals — splitting
into sub-workflow calls breaks under Forgejo's reusable-workflow expansion. See the workflow header
comment (.forgejo/workflows/issue-triage.yml) for the full rationale.