shared forgejo workflows
Find a file
S.Grinschgl 8a8f41571c fix: decouple mutation steps from each other's failures; add job-level timeout
- 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.
2026-07-29 09:25:12 +02:00
.forgejo/workflows fix: decouple mutation steps from each other's failures; add job-level timeout 2026-07-29 09:25:12 +02:00
examples/callers/issue-triage feat: bake default model into issue-triage workflow; make model optional 2026-07-27 15:51:18 +02:00
prompts/issue-triage feat: add is_invalid to triage schema; make webhook notification best-effort 2026-07-27 15:19:43 +02:00
AGENTS.md docs: recommend repo variables for model input over hardcoding 2026-07-27 14:28:20 +02:00
README.md feat: bake default model into issue-triage workflow; make model optional 2026-07-27 15:51:18 +02:00

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 via OPENCODE_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 with jq, 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_PERMISSION to deny bash, webfetch, websearch, task, and external_directory, and restricts edit to only the triage result file. This matters even with --auto, which auto-approves anything not explicitly denied — including permissions that default to "ask" (like external_directory), which would otherwise be silently upgraded to allowed. Without this, a crafted issue body could attempt to make the agent exfiltrate AI_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).
  • jq and curl in 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_TOKEN is auto-provided by the runner on non-fork events — no action needed. If your runner suppresses the default token, add a PAT with repo scope 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. The library_ref: v1 input 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_context is 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 the webhook_url payload 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.