Files
gadfly/.gitea/workflows/review-reusable.yml
T
steveandClaude Opus 5 0f40b21d79
Build & push image / build-and-push (pull_request) Successful in 14s
Gadfly review (reusable) / review (pull_request) Successful in 8m46s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m46s
feat(qwen): let Qwen (and Kimi) join the swarm
majordomo now ships qwen and kimi as built-ins that ARE the openai client at
their own base URL, so "qwen/qwen3.8-max" works as a GADFLY_MODELS entry once
the key reaches the container. This wires up the parts that key has to pass
through.

Two provider switches had to learn the names, not one. resolveModel's
GADFLY_BASE_URL override was the obvious one; endpointProvider's
GADFLY_ENDPOINT_* parser is its sibling, and I fixed the first and missed the
second on the first pass — a config that resolves one way and errors the other
for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths
now asserts both from one table so the pair fails together; break-checked in
both directions.

QWEN_API_KEY (and KIMI_API_KEY) are declared as workflow_call secrets and
forwarded to the container, with gadfly's own stub forwarding QWEN_API_KEY so a
qwen entry can join the default swarm by editing GADFLY_DEFAULT_MODELS alone —
no workflow edit, no re-release.

The run.sh credential pre-flight is now a provider→variable table instead of an
ollama-cloud special case. Without it a forgotten key surfaces as five
identical per-lens agent failures naming no variable, and the operator reads a
stack trace to find out which secret they missed. Google stays out of the table
on purpose: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, and a one-var
entry would wrongly skip a correctly-configured run. Verified across 17
provider x key-state combinations, including that a wrong-provider key never
satisfies qwen (majordomo refuses cross-provider fallback) and that unkeyed
providers are never blocked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 16:30:29 -04:00

181 lines
12 KiB
YAML

# Gadfly — REUSABLE adversarial-review workflow (Gitea `workflow_call`).
#
# Centralizes the ~90-line consumer stub so a repo can subscribe to Gadfly with
# a tiny caller. A consumer workflow does:
#
# jobs:
# review:
# if: ... # actor gate for the comment trigger
# uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1
# secrets: # forward ONLY what the reviewer needs
# OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
# CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# with: { allowed_users: "..." } # config inputs are optional (see below)
#
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME from,
# in order: a consumer's `with:` input → the owner's user-scope GADFLY_DEFAULT_*
# variable → the image's built-in default. Because variables are injected per-run
# (not part of this cached file), the owner retunes the whole fleet by editing ONE
# variable — see the inputs block and README "Central config via variables".
# Secrets are DECLARED below (workflow_call.secrets) so a
# caller forwards only the credentials the reviewer actually uses — least
# privilege — rather than `secrets: inherit`, which leaks every caller secret
# (registry/deploy/db creds) into this workflow. `secrets: inherit` still works
# if you accept that exposure; the explicit form is recommended. GITEA_TOKEN is
# the automatic job token (no need to forward it).
#
# Advisory only — never blocks a merge. The image is pinned to an immutable
# :sha- tag here (act_runner caches :latest); bump it per Gadfly release.
# Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate
# releases, so central tuning here propagates without per-consumer edits — or a
# full `@<sha>` for an immutable pin. Avoid `@main` (moves on every push).
name: Gadfly review (reusable)
on:
workflow_call:
# The swarm config (models, specialists, concurrency) is resolved at RUNTIME,
# in priority order: a consumer's explicit `with:` input → the owner's
# user/org-level variable (GADFLY_DEFAULT_*) → the image's built-in default.
# Variables are injected per-run by Gitea (not baked into this file), so the
# owner can retune the whole fleet by editing ONE variable — it propagates even
# though long-lived act_runners CACHE this workflow file by ref (a moved tag is
# NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache).
#
# Owner-set user-scope variables (see README "Central config via variables"):
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
# GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY (the provider-wide lens budget),
# GADFLY_ENDPOINT_NETHERSTORM (the local GPU box endpoint).
# An unset variable + no input → the image default (one model, default suite),
# so a public consumer with neither still gets a sane minimal review.
inputs:
models: { type: string, default: "" } # GADFLY_MODELS — empty falls back to user var GADFLY_DEFAULT_MODELS, then the image default
specialists: { type: string, default: "" } # GADFLY_SPECIALISTS — empty falls back to user var GADFLY_DEFAULT_SPECIALISTS
provider: { type: string, default: "" } # GADFLY_PROVIDER
base_url: { type: string, default: "" } # GADFLY_BASE_URL
provider_concurrency: { type: string, default: "" } # DEPRECATED / ignored — the per-provider MODEL cap was removed; the lens budget below is the single throttle. Kept so existing callers don't error.
provider_lens_concurrency: { type: string, default: "" } # GADFLY_PROVIDER_LENS_CONCURRENCY — the per-provider lens budget (shared across the provider's models); empty falls back to user var GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
timeout_secs: { type: string, default: "600" } # GADFLY_TIMEOUT_SECS (per lens)
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS (consumer-specific; set in your stub)
# Gitea >= 1.27 does not propagate the CALLER's workflow_dispatch inputs into a
# called workflow's github.event, so a manual "review PR #N" dispatch arrived
# here with an empty PR and died at the entrypoint's "PR required" check. The
# caller stub must thread it explicitly: `pr_number: ${{ github.event.inputs.pr_number }}`.
pr_number: { type: string, default: "" }
trigger_phrase: { type: string, default: "" } # GADFLY_TRIGGER_PHRASE
consolidate: { type: string, default: "" } # GADFLY_CONSOLIDATE — "" => auto (one consensus comment for >=2 models); "0" => one comment per model
inline_review: { type: string, default: "" } # GADFLY_INLINE_REVIEW — "" => on (post a COMMENT-state PR review with inline comments on changed lines); "0" => off
# Job wall-clock cap. 90 as a default: the 5-lens suite across a slow lane
# (claude-code with extended thinking) over two passes can run long.
timeout_minutes: { type: number, default: 90 }
# Declared so callers can forward ONLY the secrets the reviewer needs
# (least privilege) instead of `secrets: inherit`, which would hand this
# workflow every secret in the caller's repo (registry/deploy/db creds the
# review never touches). All optional — an unset/unpassed secret resolves to
# empty, harmless for the providers a given consumer doesn't use. GITEA_TOKEN
# is the automatic job token and need not be declared/forwarded. Named
# endpoints (GADFLY_ENDPOINT_*) come from user/org VARS now, not secrets.
secrets:
OLLAMA_CLOUD_API_KEY: { required: false }
OPENAI_API_KEY: { required: false }
ANTHROPIC_API_KEY: { required: false }
GOOGLE_API_KEY: { required: false }
# Alibaba Model Studio (Qwen), for GADFLY_MODELS entries like
# "qwen/qwen3.8-max". NOT interchangeable with OPENAI_API_KEY: majordomo's
# qwen built-in reads QWEN_API_KEY only and deliberately refuses to fall
# back to the OpenAI key, so an unforwarded secret is a 401, not a
# mis-billed OpenAI call.
QWEN_API_KEY: { required: false }
# Moonshot (Kimi) over its own API — distinct from the ollama-cloud
# "kimi-k2.6:cloud" entry, which is keyed by OLLAMA_CLOUD_API_KEY.
KIMI_API_KEY: { required: false }
GADFLY_API_KEY: { required: false }
CLAUDE_CODE_OAUTH_TOKEN: { required: false }
GADFLY_FINDINGS_URL: { required: false }
GADFLY_FINDINGS_TOKEN: { required: false }
# The reusable job posts the review comment, so it needs issues/PR write. Gitea
# caps these by the caller's granted permissions; declaring them here is explicit.
permissions:
contents: read
issues: write
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
steps:
# Pin the reviewer image to an immutable sha (act_runner caches :latest, so a
# moved :latest is often NOT re-pulled). sha-b37cd09 adds the provider-wide
# lens budget (PR #27: one shared lens-permit pool per provider, the model cap
# removed) on top of the opencode CLI engine (PR #26) and the Gitea >= 1.27
# workflow_call reclassification. Bump per Gadfly release.
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-b37cd09
env:
# --- event context (from the CALLER's github.*) -------------------
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
# github.token is the auto job token from the github CONTEXT (not a
# secret), so it's present even without `secrets: inherit`. Using
# secrets.GITEA_TOKEN here would be empty under explicit secret
# forwarding, since the auto token isn't a forwarded workflow_call secret.
GITEA_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR: ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number || inputs.pr_number }}
PR_BRANCH: ${{ github.head_ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ID: ${{ github.event.comment.id }}
ACTOR: ${{ github.actor }}
# --- provider auth (forwarded workflow_call secrets; empty if the caller doesn't forward it) -
# OLLAMA_CLOUD_API_KEY powers both the ollama-cloud majordomo path AND
# the opencode engine (GADFLY_MODELS entry "opencode/<model>").
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
# Qwen (Alibaba Model Studio) and Kimi (Moonshot) over their own APIs,
# for GADFLY_MODELS entries like "qwen/qwen3.8-max". Each built-in
# reads ONLY its own variable — no cross-provider fallback — so a
# missing line here is a clean 401, never a silently mis-keyed call.
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Named LAN endpoints, defined in user/org vars (format
# "<provider>|<base-url>[|<key>]"). Adding a NEW name still needs a line
# here — a reusable workflow can't enumerate arbitrary vars.GADFLY_ENDPOINT_*.
# NB: vars are NOT masked like secrets — if an endpoint embeds an auth
# token, keep that one a secret instead.
GADFLY_ENDPOINT_M1: ${{ vars.GADFLY_ENDPOINT_M1 }}
GADFLY_ENDPOINT_M5: ${{ vars.GADFLY_ENDPOINT_M5 }}
# netherstorm = the local GPU box via its llama-swap proxy. Defined in the
# user var GADFLY_ENDPOINT_NETHERSTORM (format "<provider>|<base-url>[|<key>]",
# e.g. "llamaswap|https://llama-swap.netherstorm...") so the URL can change
# without editing this file; the matching model is netherstorm/qwen3.6-27b in
# GADFLY_DEFAULT_MODELS. NB: use the un-hyphenated `llamaswap` provider
# spelling in the var — the pinned image needs it. Without this line the var
# is silently dropped at the workflow_call boundary ('unknown provider').
GADFLY_ENDPOINT_NETHERSTORM: ${{ vars.GADFLY_ENDPOINT_NETHERSTORM }}
# --- findings telemetry (optional) --------------------------------
GADFLY_FINDINGS_URL: ${{ secrets.GADFLY_FINDINGS_URL }}
GADFLY_FINDINGS_TOKEN: ${{ secrets.GADFLY_FINDINGS_TOKEN }}
# --- config (from inputs; empty => image default) -----------------
GADFLY_MODELS: ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}
GADFLY_SPECIALISTS: ${{ inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS }}
GADFLY_PROVIDER: ${{ inputs.provider }}
GADFLY_BASE_URL: ${{ inputs.base_url }}
# NB: GADFLY_PROVIDER_CONCURRENCY (the old model cap) is intentionally no
# longer forwarded — entrypoint.sh ignores it. The lens budget is the one
# throttle now, shared across a provider's models.
GADFLY_PROVIDER_LENS_CONCURRENCY: ${{ inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY }}
GADFLY_TIMEOUT_SECS: ${{ inputs.timeout_secs }}
GADFLY_MAX_STEPS: ${{ inputs.max_steps }}
GADFLY_WORKER_MODEL: ${{ inputs.worker_model }}
GADFLY_ALLOWED_USERS: ${{ inputs.allowed_users }}
GADFLY_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
GADFLY_CONSOLIDATE: ${{ inputs.consolidate }}
GADFLY_INLINE_REVIEW: ${{ inputs.inline_review }}