feat(reusable): runtime-variable swarm config (cache-immune, no more re-pinning to retune) #14

Merged
steve merged 1 commits from feat/vars-config into main 2026-06-28 06:00:18 +00:00
Owner

Moves the volatile swarm config out of the cached reusable file and into runtime-injected owner variables, so retuning the fleet no longer requires re-pinning consumers (the long-lived-runner ref-cache defeats moving @v1).

How

Config resolves at runtime: consumer with: input → owner user/org var → image default. Variables are injected per-run (like secrets), so they bypass the workflow-file cache.

env now reads
GADFLY_MODELS inputs.models || vars.GADFLY_DEFAULT_MODELS
GADFLY_SPECIALISTS inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS
GADFLY_PROVIDER_CONCURRENCY inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY
GADFLY_PROVIDER_LENS_CONCURRENCY inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY
GADFLY_ENDPOINT_RAGNAROS vars.GADFLY_ENDPOINT_RAGNAROS (the 4090 Ti)

Input defaults dropped to "", so the public file default is now generic (image default) — which also pulls the steve-specific swarm + the personal LAN endpoint out of the publicly-inherited default (addresses the earlier egress finding).

Rollout

This is a structural change to the reusable, so it needs one cache-bust: consumers pin to this PR's merge @<sha>. After that, all future config tuning is just editing the owner variables — no re-pin, no tag move.

🤖 Generated with Claude Code

Moves the volatile swarm config out of the cached reusable file and into **runtime-injected owner variables**, so retuning the fleet no longer requires re-pinning consumers (the long-lived-runner ref-cache defeats moving `@v1`). ### How Config resolves at runtime: **consumer `with:` input → owner user/org var → image default**. Variables are injected per-run (like secrets), so they bypass the workflow-file cache. | env | now reads | |---|---| | `GADFLY_MODELS` | `inputs.models \|\| vars.GADFLY_DEFAULT_MODELS` | | `GADFLY_SPECIALISTS` | `inputs.specialists \|\| vars.GADFLY_DEFAULT_SPECIALISTS` | | `GADFLY_PROVIDER_CONCURRENCY` | `inputs.provider_concurrency \|\| vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY` | | `GADFLY_PROVIDER_LENS_CONCURRENCY` | `inputs.provider_lens_concurrency \|\| vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | | `GADFLY_ENDPOINT_RAGNAROS` | `vars.GADFLY_ENDPOINT_RAGNAROS` (the 4090 Ti) | Input defaults dropped to `""`, so the **public** file default is now generic (image default) — which also pulls the steve-specific swarm + the personal LAN endpoint out of the publicly-inherited default (addresses the earlier egress finding). ### Rollout This is a *structural* change to the reusable, so it needs **one** cache-bust: consumers pin to this PR's merge `@<sha>`. After that, all future config tuning is just editing the owner variables — no re-pin, no tag move. 🤖 Generated with [Claude Code](https://claude.com/claude-code)

🪰 Gadfly — live review status

7/7 reviewers finished · updated 2026-06-28 06:11:32Z

claude-code/opus · claude-code — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — Minor issues
  • error-handling — No material issues found

claude-code/opus:max · claude-code — done

  • security — Minor issues
  • correctness — Minor issues
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

claude-code/sonnet · claude-code — done

  • security — No material issues found
  • correctness — Minor issues
  • maintainability — Blocking issues found
  • performance — Minor issues
  • error-handling — Minor issues

deepseek-v4-pro:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Blocking issues found
  • performance — No material issues found
  • error-handling — No material issues found

glm-5.2:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Blocking issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

minimax-m3:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Blocking issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

ragnaros/qwen3.6-27b · ragnaros — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • ⚠️ performance — could not complete
  • ⚠️ error-handling — could not complete

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 7/7 reviewers finished · updated 2026-06-28 06:11:32Z #### `claude-code/opus` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — Minor issues - ✅ **error-handling** — No material issues found #### `claude-code/opus:max` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **maintainability** — Blocking issues found - ✅ **performance** — Minor issues - ✅ **error-handling** — Minor issues #### `deepseek-v4-pro:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Blocking issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Blocking issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `minimax-m3:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Blocking issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `ragnaros/qwen3.6-27b` · ragnaros — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ⚠️ **performance** — could not complete - ⚠️ **error-handling** — could not complete <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

🪰 Gadfly review — glm-5.2:cloud (ollama-cloud)

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

Both findings verified against the actual source. Confirming:

  • Finding 1: review-reusable.yml:126 documents "<provider>|<base-url>[|<key>]" and :130 wires GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}, while :123-124 wire M1/M5 through secrets.*, and examples/endpoint-aliases.yml:16 annotates the token field (use a secret). Confirmed.
  • Finding 2: cmd/gadfly/model.go:207-220 shows endpointProvider rejects empty/<2-part values (returns error, does not register), so an unset vars.GADFLY_ENDPOINT_RAGNAROS yields no registered ragnaros provider → no default egress. Confirmed.

Verdict: Minor issues found

  • GADFLY_ENDPOINT_RAGNAROS is wired via vars.* (not secrets.*), yet its documented format includes an optional credential field — a credential-in-a-variable footgun. .gitea/workflows/review-reusable.yml:126 documents the format as "<provider>|<base-url>[|<key>]", and line 130 binds it from ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}. Gitea masks secrets.* in logs but does not mask vars.*. So if an owner ever populates the |<key> field (e.g. a bearer token for the llama-swap proxy), that credential would be stored in and potentially leaked through a plain variable — logs, UI, org-member visibility — rather than a secret. This directly contradicts the repo's own guidance, which wires GADFLY_ENDPOINT_M1/M5 through secrets.* (lines 123–124) and annotates examples/endpoint-aliases.yml:16 as (use a secret) for the token field. Verified by reading review-reusable.yml:120-130, examples/endpoint-aliases.yml:16, README.md:150-152, and cmd/gadfly/model.go:207-234 (which confirms the third |<key> field is consumed as a real token/API key). Suggested fix: either (a) wire GADFLY_ENDPOINT_RAGNAROS from a secret (declare it in workflow_call.secrets) the way M1/M5 are, or (b) at minimum drop the [|<key>] mention from the var's documented format and state explicitly that any credential for this endpoint must go in a secret, keeping the var URL-only.

  • Removal of the hardcoded personal LAN endpoint is a genuine security improvement. The previous GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa" baked a personal host into the reusable file, so any consumer (including public ones) inheriting the default swarm would egress reviewer-PR contents (the diff/code) to that host. Replacing it with ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} (empty when unset) closes that default-egress path. Verified against the diff and cmd/gadfly/model.go:207-220, which confirms an empty/unset endpoint value is rejected (returns an error, not silently registered), so the no-egress-by-default behavior holds.

🎯 Correctness — Blocking issues found

VERDICT: Blocking issues found

  • vars.GADFLY_DEFAULT_* in a reusable workflow resolves against the CALLER's repo/owner scope, not the reusable file's owner — defeating the PR's entire premise for cross-owner consumers. review-reusable.yml:135-140 does ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }} etc., and the inputs now default to "" (review-reusable.yml:52-53,56-57). In GitHub Actions (which Gitea Actions replicates), a called reusable workflow's vars/secrets contexts reference the calling repository's variables, not the repository that contains the called workflow file — this is exactly why secrets: inherit exists and why reusables must declare/forward secrets; variables are not forwarded at all. So when an external consumer (e.g. acme/widgets) calls steve/gadfly/.gitea/workflows/review-reusable.yml@… (the absolute path shown at examples/reusable.yml:54), vars.GADFLY_DEFAULT_MODELS resolves to acme's repo/org/user variables, not steve's. Steve setting GADFLY_DEFAULT_MODELS on his own account does nothing for acme's run: the expression is empty → GADFLY_MODELS is empty → entrypoint.sh:156 falls back to DEFAULT_MODELS="qwen3-coder:480b-cloud" (verified at entrypoint.sh:52, a single model). "Retune the whole fleet by editing ONE variable" only works for consumers owned by steve himself. Verified the call sites: the dogfooding caller .gitea/workflows/adversarial-review.yml:43 uses ./.gitea/workflows/review-reusable.yml (relative, same repo/owner steve), so it reads steve's vars and works — masking the bug during self-review. The actual third-party consumer path (examples/reusable.yml:54, absolute steve/gadfly/... ref) silently degrades to the generic image default instead of steve's tuned swarm. Suggested fix: this cannot be fixed purely with vars.* in the reusable; either (a) forward the owner values as explicit inputs from a thin caller the owner maintains, (b) document a caller-forwarded secret/var contract, or (c) acknowledge that fleet-tuning only applies to same-owner consumers and update the README/PR claims accordingly. (Caveat: caller-vs-callee scope resolution is the documented GitHub Actions model Gitea reproduces; I could not execute Gitea directly to confirm, but the diff's own rationale ("variables are injected per-run by Gitea") relies on this same model, and the asymmetry between the relative-path self-caller and the absolute-path external caller is verified in the repo.)

  • Stale consumer-facing docs now misstate the default swarm and pinning guidance. examples/reusable.yml:8-12 still says "the reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite," and examples/reusable.yml:64-67 overrides models: to a cloud-only set with the comment "delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)." After this PR the reusable ships no model/concurrency defaults (inputs default ""), so for an external consumer there is no 3-cloud+Claude swarm to inherit — deleting the override yields the single-model image default. Likewise .gitea/workflows/adversarial-review.yml:6-8 still asserts "It inherits the default swarm: 3 cloud models + Claude Code (sonnet, opus, opus:max), 5-lens suite," which is no longer baked into the reusable's inputs. Suggested fix: update these comments to reflect the runtime-var resolution and that an unset models yields the image default unless the owner sets GADFLY_DEFAULT_MODELS.

  • (Docs consistency, not behavioral) Pinning guidance contradicts the new README. review-reusable.yml:29-31 and examples/reusable.yml:17-20,52-53 still tell consumers to pin @v1 ("a curated release tag ... central tuning here propagates"), but the README now (README.md:316-319) states that long-lived act_runners cache the workflow file by ref so a moved @v1 tag is often not re-fetched, and recommends @<sha>. The @v1 guidance in the in-repo comments thus contradicts the cache rationale the PR introduces; update for consistency.

🧹 Code cleanliness & maintainability — Minor issues

Verdict: Minor issues

The core structural change (reusable inputs → runtime env resolution via inputs.X || vars.GADFLY_DEFAULT_*, CLAUDE.md architecture map, README narrative + new "Central config via variables" section) is clean and consistent across the three edited files. Verified by reading the touched regions. But the PR left consumer-facing examples/ and the header/closing bookends of the reusable file itself stale and self-contradictory — a maintainability hazard for the next editor who copy-pastes from them:

  • examples/reusable.yml:8-12, 17-20, 52-54, 64-67 — Still describes the old model: "The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite … delete that override (and forward the Claude token) to inherit the full default" (lines 8-12, 64-67), and recommends @v1 "for auto-updating stability" (lines 17-20, 52-54). Neither statement is true after this PR: the reusable's inputs now ship all "" defaults (no default swarm), the fleet comes from owner GADFLY_DEFAULT_* variables, and the README this PR just edited explicitly says @v1 is not re-fetched by long-lived runners and consumers "should pin … to an immutable @<sha>." Verified against README.md:316-319.

  • examples/README.md:9 — The reusable-stub row still sells "inherits its default swarm (3 cloud + Claude Code, 5-lens suite) … drop it + add the Claude token to get the full default," now contradicted by the README "Central config via variables" section this PR added. Verified.

  • examples/reusable.yml:67 — The override example hardcodes models: "minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud" with a comment calling it "inherit the full default swarm." Combined with the stale prose, this is the only place the old model list still lives as a recommended example; worth a one-line note that this is just an example cloud-only set, not an inherited default.

  • .gitea/workflows/review-reusable.yml:9, 29-31 — Same @v1 inconsistency inside a file the PR did edit: the header example (uses: …@v1, line 9) and the closing advice (lines 29-31: "Consumers should pin uses: …@v1 — a curated release tag … so central tuning here propagates") directly contradict the new on.workflow_call body comment (lines 40-43: "a moved tag is NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache") and the README. The PR rewrote the middle of this file's comments but left the bookends preaching the deprecated pinning model. Verified by reading lines 1-31 alongside 37-50.

Suggested fix (low churn, same lens): in the same pass that added the README "Central config via variables" section, update examples/reusable.yml + examples/README.md to say config is inherited from owner GADFLY_DEFAULT_* vars (not "ships a default swarm"), and update the pin advice in examples/reusable.yml and the reusable header (review-reusable.yml:9,29-31) to "pin to an immutable @<sha>; @v1/@main may be stale-cached" to match the new README.

Performance — No material issues found

VERDICT: No material issues found

Through the performance lens, this is a documentation/config-only change. It moves default values from workflow inputs defaults to runtime variable resolution (inputs.x || vars.X) and shifts the GADFLY_ENDPOINT_RAGNAROS literal to vars.GADFLY_ENDPOINT_RAGNAROS.

There are no loops, queries, allocations, or hot-path code introduced — the env var assignments are one-time per-job evaluations, and || short-circuit is negligible. No N+1, unbounded growth, pagination, or blocking concerns apply. Nothing material in my lane.

🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} resolves to an empty string when the owner var is unset (previously the hardcoded literal llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa). Confirmed in cmd/gadfly/model.go:207-210: endpointProvider does strings.SplitN(raw, "|", 3) on the empty value, yielding a single-element slice, so len(parts) < 2 → it returns an error. That error is only logged and skipped (model.go:47-49), so the ragnaros provider is never registered. If GADFLY_DEFAULT_MODELS (or a consumer override) references ragnaros/qwen3.6-27b, that model silently fails per-model in entrypoint.sh:183 (log "model $1 failed (continuing)") and the review proceeds without it — no crash, but a silently degraded swarm with no surfaced signal beyond stderr. The models-list-vs-endpoint-var coherence is now a runtime invariant that nothing validates. Suggested fix: either gate the ragnaros model entry on the endpoint being set, or emit a clearer log when a referenced provider has no registered endpoint.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 20s

<!-- gadfly-review:ollama:glm-5.2:cloud --> ### 🪰 Gadfly review — `glm-5.2:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Both findings verified against the actual source. Confirming: - **Finding 1**: `review-reusable.yml:126` documents `"<provider>|<base-url>[|<key>]"` and `:130` wires `GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`, while `:123-124` wire M1/M5 through `secrets.*`, and `examples/endpoint-aliases.yml:16` annotates the token field `(use a secret)`. Confirmed. - **Finding 2**: `cmd/gadfly/model.go:207-220` shows `endpointProvider` rejects empty/`<2`-part values (returns error, does not register), so an unset `vars.GADFLY_ENDPOINT_RAGNAROS` yields no registered `ragnaros` provider → no default egress. Confirmed. **Verdict: Minor issues found** - **`GADFLY_ENDPOINT_RAGNAROS` is wired via `vars.*` (not `secrets.*`), yet its documented format includes an optional credential field — a credential-in-a-variable footgun.** `.gitea/workflows/review-reusable.yml:126` documents the format as `"<provider>|<base-url>[|<key>]"`, and line 130 binds it from `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`. Gitea masks `secrets.*` in logs but does not mask `vars.*`. So if an owner ever populates the `|<key>` field (e.g. a bearer token for the llama-swap proxy), that credential would be stored in and potentially leaked through a plain variable — logs, UI, org-member visibility — rather than a secret. This directly contradicts the repo's own guidance, which wires `GADFLY_ENDPOINT_M1`/`M5` through `secrets.*` (lines 123–124) and annotates `examples/endpoint-aliases.yml:16` as `(use a secret)` for the token field. Verified by reading `review-reusable.yml:120-130`, `examples/endpoint-aliases.yml:16`, `README.md:150-152`, and `cmd/gadfly/model.go:207-234` (which confirms the third `|<key>` field is consumed as a real token/API key). Suggested fix: either (a) wire `GADFLY_ENDPOINT_RAGNAROS` from a secret (declare it in `workflow_call.secrets`) the way M1/M5 are, or (b) at minimum drop the `[|<key>]` mention from the var's documented format and state explicitly that any credential for this endpoint must go in a secret, keeping the var URL-only. - **Removal of the hardcoded personal LAN endpoint is a genuine security improvement.** The previous `GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"` baked a personal host into the reusable file, so any consumer (including public ones) inheriting the default swarm would egress reviewer-PR contents (the diff/code) to that host. Replacing it with `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` (empty when unset) closes that default-egress path. Verified against the diff and `cmd/gadfly/model.go:207-220`, which confirms an empty/unset endpoint value is rejected (returns an error, not silently registered), so the no-egress-by-default behavior holds. </details> <details><summary><b>🎯 Correctness</b> — Blocking issues found</summary> **VERDICT: Blocking issues found** - **`vars.GADFLY_DEFAULT_*` in a reusable workflow resolves against the CALLER's repo/owner scope, not the reusable file's owner — defeating the PR's entire premise for cross-owner consumers.** `review-reusable.yml:135-140` does `${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}` etc., and the inputs now default to `""` (`review-reusable.yml:52-53,56-57`). In GitHub Actions (which Gitea Actions replicates), a called reusable workflow's `vars`/`secrets` contexts reference the *calling* repository's variables, not the repository that contains the called workflow file — this is exactly why `secrets: inherit` exists and why reusables must declare/forward secrets; variables are not forwarded at all. So when an external consumer (e.g. `acme/widgets`) calls `steve/gadfly/.gitea/workflows/review-reusable.yml@…` (the absolute path shown at `examples/reusable.yml:54`), `vars.GADFLY_DEFAULT_MODELS` resolves to **acme's** repo/org/user variables, not steve's. Steve setting `GADFLY_DEFAULT_MODELS` on his own account does nothing for acme's run: the expression is empty → `GADFLY_MODELS` is empty → `entrypoint.sh:156` falls back to `DEFAULT_MODELS="qwen3-coder:480b-cloud"` (verified at `entrypoint.sh:52`, a single model). "Retune the whole fleet by editing ONE variable" only works for consumers owned by steve himself. Verified the call sites: the dogfooding caller `.gitea/workflows/adversarial-review.yml:43` uses `./.gitea/workflows/review-reusable.yml` (relative, same repo/owner steve), so it reads steve's vars and works — masking the bug during self-review. The actual third-party consumer path (`examples/reusable.yml:54`, absolute `steve/gadfly/...` ref) silently degrades to the generic image default instead of steve's tuned swarm. Suggested fix: this cannot be fixed purely with `vars.*` in the reusable; either (a) forward the owner values as explicit `inputs` from a thin caller the owner maintains, (b) document a caller-forwarded secret/var contract, or (c) acknowledge that fleet-tuning only applies to same-owner consumers and update the README/PR claims accordingly. *(Caveat: caller-vs-callee scope resolution is the documented GitHub Actions model Gitea reproduces; I could not execute Gitea directly to confirm, but the diff's own rationale ("variables are injected per-run by Gitea") relies on this same model, and the asymmetry between the relative-path self-caller and the absolute-path external caller is verified in the repo.)* - **Stale consumer-facing docs now misstate the default swarm and pinning guidance.** `examples/reusable.yml:8-12` still says "the reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite," and `examples/reusable.yml:64-67` overrides `models:` to a cloud-only set with the comment "delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)." After this PR the reusable ships *no* model/concurrency defaults (inputs default `""`), so for an external consumer there is no 3-cloud+Claude swarm to inherit — deleting the override yields the single-model image default. Likewise `.gitea/workflows/adversarial-review.yml:6-8` still asserts "It inherits the default swarm: 3 cloud models + Claude Code (sonnet, opus, opus:max), 5-lens suite," which is no longer baked into the reusable's inputs. Suggested fix: update these comments to reflect the runtime-var resolution and that an unset `models` yields the image default unless the owner sets `GADFLY_DEFAULT_MODELS`. - **(Docs consistency, not behavioral) Pinning guidance contradicts the new README.** `review-reusable.yml:29-31` and `examples/reusable.yml:17-20,52-53` still tell consumers to pin `@v1` ("a curated release tag ... central tuning here propagates"), but the README now (`README.md:316-319`) states that long-lived act_runners cache the workflow file by ref so a moved `@v1` tag is often *not* re-fetched, and recommends `@<sha>`. The `@v1` guidance in the in-repo comments thus contradicts the cache rationale the PR introduces; update for consistency. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **Verdict: Minor issues** The core structural change (reusable inputs → runtime env resolution via `inputs.X || vars.GADFLY_DEFAULT_*`, CLAUDE.md architecture map, README narrative + new "Central config via variables" section) is clean and consistent across the three edited files. Verified by reading the touched regions. But the PR left consumer-facing `examples/` and the header/closing bookends of the reusable file itself stale and self-contradictory — a maintainability hazard for the next editor who copy-pastes from them: - `examples/reusable.yml:8-12, 17-20, 52-54, 64-67` — Still describes the **old** model: "The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite … delete that override (and forward the Claude token) to inherit the full default" (lines 8-12, 64-67), and recommends `@v1` "for auto-updating stability" (lines 17-20, 52-54). Neither statement is true after this PR: the reusable's inputs now ship all `""` defaults (no default swarm), the fleet comes from owner `GADFLY_DEFAULT_*` variables, and the README this PR just edited explicitly says `@v1` is *not* re-fetched by long-lived runners and consumers "should pin … to an immutable `@<sha>`." Verified against `README.md:316-319`. - `examples/README.md:9` — The reusable-stub row still sells "inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite) … drop it + add the Claude token to get the full default," now contradicted by the README "Central config via variables" section this PR added. Verified. - `examples/reusable.yml:67` — The override example hardcodes `models: "minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud"` with a comment calling it "inherit the full default swarm." Combined with the stale prose, this is the only place the old model list still lives as a *recommended example*; worth a one-line note that this is just an example cloud-only set, not an inherited default. - `.gitea/workflows/review-reusable.yml:9, 29-31` — Same `@v1` inconsistency inside a file the PR *did* edit: the header example (`uses: …@v1`, line 9) and the closing advice (lines 29-31: "Consumers should pin `uses: …@v1` — a curated release tag … so central tuning here propagates") directly contradict the new `on.workflow_call` body comment (lines 40-43: "a moved tag is NOT re-fetched; only a runtime value or a fresh `@<sha>` bypasses the cache") and the README. The PR rewrote the middle of this file's comments but left the bookends preaching the deprecated pinning model. Verified by reading lines 1-31 alongside 37-50. Suggested fix (low churn, same lens): in the same pass that added the README "Central config via variables" section, update `examples/reusable.yml` + `examples/README.md` to say config is inherited from owner `GADFLY_DEFAULT_*` vars (not "ships a default swarm"), and update the pin advice in `examples/reusable.yml` and the reusable header (`review-reusable.yml:9,29-31`) to "pin to an immutable `@<sha>`; `@v1`/`@main` may be stale-cached" to match the new README. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> VERDICT: No material issues found Through the performance lens, this is a documentation/config-only change. It moves default values from workflow `inputs` defaults to runtime variable resolution (`inputs.x || vars.X`) and shifts the `GADFLY_ENDPOINT_RAGNAROS` literal to `vars.GADFLY_ENDPOINT_RAGNAROS`. There are no loops, queries, allocations, or hot-path code introduced — the env var assignments are one-time per-job evaluations, and `||` short-circuit is negligible. No N+1, unbounded growth, pagination, or blocking concerns apply. Nothing material in my lane. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **VERDICT: Minor issues** - `.gitea/workflows/review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` resolves to an empty string when the owner var is unset (previously the hardcoded literal `llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa`). Confirmed in `cmd/gadfly/model.go:207-210`: `endpointProvider` does `strings.SplitN(raw, "|", 3)` on the empty value, yielding a single-element slice, so `len(parts) < 2` → it returns an error. That error is only logged and skipped (`model.go:47-49`), so the `ragnaros` provider is never registered. If `GADFLY_DEFAULT_MODELS` (or a consumer override) references `ragnaros/qwen3.6-27b`, that model silently fails per-model in `entrypoint.sh:183` (`log "model $1 failed (continuing)"`) and the review proceeds without it — no crash, but a silently degraded swarm with no surfaced signal beyond stderr. The models-list-vs-endpoint-var coherence is now a runtime invariant that nothing validates. Suggested fix: either gate the ragnaros model entry on the endpoint being set, or emit a clearer log when a referenced provider has no registered endpoint. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 20s</sub>

🪰 Gadfly review — deepseek-v4-pro:cloud (ollama-cloud)

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

This change is a net security improvement through my lens. The key observation:

  • Hardcoded internal hostname removed from publicly readable file: The old GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa" (line 130 old) exposed an internal LAN hostname to anyone who could read the workflow file. Moving it to vars.GADFLY_ENDPOINT_RAGNAROS eliminates that information disclosure. The variable is still injected into the container's environment at runtime, but that's the same runtime exposure the old hardcoded value already had — the difference is the value is no longer sitting in plaintext in a publicly cloneable file.

  • vars context in a reusable workflow resolves to the owner, not the caller: In Gitea Actions, vars.GADFLY_DEFAULT_* and vars.GADFLY_ENDPOINT_RAGNAROS inside steve/gadfly's reusable workflow resolve to steve's user/org-level variables. A consumer calling this workflow cannot override them through their own variables — they can only override through the explicit with: inputs. So a malicious consumer can't inject arbitrary endpoints or config by setting same-named variables in their own scope.

  • No injection surface in the || fallback: The expressions like ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }} are evaluated by Gitea's expression engine, not by a shell. The resulting strings are set as literal environment variables. In entrypoint.sh, these values are parsed with read/IFS splitting (not eval), so there's no command injection path even if a consumer provides malicious with: input values.

  • GADFLY_ENDPOINT_RAGNAROS in vars vs secrets: The old hardcoded value had no credential (llamaswap|https://... — no |<key> segment). The new variable presumably has the same format. If a key were ever added, vars would be less protected than secrets (vars are not encrypted at rest in Gitea), but that's a hypothetical future concern, not a bug in this diff.

🎯 Correctness — No material issues found

Verdict: No material issues found

I traced the full resolution chain for every changed env var through the workflow expressions, the entrypoint.sh shell defaults, and the Go binary's env parsing. The || fallback logic is sound:

  • inputs.models || vars.GADFLY_DEFAULT_MODELS → empty string → entrypoint.sh:156 falls through to DEFAULT_MODELS="qwen3-coder:480b-cloud". Verified by reading entrypoint.sh:52,156.
  • inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS → empty string → specialists.go:136 (strings.TrimSpace("") != "" is false) → falls through to the default specialist selection. Verified by reading cmd/gadfly/specialists.go:136-137.
  • inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY → empty string → entrypoint.sh:169 reads "" into _caps as a single empty element → provider_cap loop skips it (no = in "") → falls through to DEFAULT_CONC (1). Verified by reading entrypoint.sh:157,167-176.
  • inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY → empty string → main.go:292 strings.Split("", ",") = [""] → no = → skipped → falls through to envInt("GADFLY_LENS_CONCURRENCY", defaultLensConcurrency). Verified by reading cmd/gadfly/main.go:281-299.
  • GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} → if unset, empty string → model.go:184 finds GADFLY_ENDPOINT_RAGNAROS= in environ → endpointProvider("ragnaros", "") returns an error (collected, non-fatal). The endpoint simply isn't registered. Verified by reading cmd/gadfly/model.go:179-202.

The provider and base_url inputs intentionally lack || vars. fallback — they remain consumer-specific, and entrypoint.sh:159 defaults bare models to ollama-cloud when GADFLY_PROVIDER is empty. This is consistent with the PR's stated scope.

No logic errors, no broken fallthrough chains, no type mismatches, no incorrect operator semantics. The change correctly implements the three-tier resolution described in the PR.

🧹 Code cleanliness & maintainability — Blocking issues found

Verdict: Blocking issues found

The diff itself is structurally clean — the YAML substitutions are straightforward and the README is well-updated. But the PR leaves multiple files with stale, contradictory documentation that will confuse anyone maintaining or consuming this workflow. These are concrete maintainability problems, not nits.


  • examples/reusable.yml is now misleading in three ways (verified by reading the file):

    • Line 8: "The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite." — the whole point of this PR is that the reusable no longer ships a hardcoded default. The swarm is now resolved at runtime from owner variables. This comment is now false.
    • Lines 17, 52, 54: Still recommends @v1 as the pinning strategy ("curated release tag, auto-updates on releases"). The new README explicitly says @v1 is unreliable because act_runners cache by ref and a moved tag is NOT re-fetched. The example stub should recommend @<sha> to match the new guidance.
    • Line 67: The models: override still hardcodes the old default models ("minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud"). This was the old "cloud-only override" that matched the old hardcoded default. Now that the default is variable-driven, this override is confusing — it looks like it's overriding something that no longer exists in the file. Should either be removed (let the variable flow through) or updated to explain the new resolution chain.
  • examples/README.md line 9 is stale (verified): "inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite)" — same problem. The reusable no longer ships that default; it resolves from owner variables. The table row needs updating.

  • CLAUDE.md lines 108–110 are stale (verified): "the **reusable workflow** (review-reusable.yml) deliberately ships a heavier opinionated default swarm (3 cloud + Claude Code, 5 lenses) for steve's own fleet, which consumers inherit or override per-input." — this is now inaccurate. The reusable no longer ships that swarm; it resolves from variables. The CLAUDE.md was partially updated (the architecture block on lines 49–57 is correct) but this paragraph was missed.

  • review-reusable.yml header comment block contradicts the new guidance (verified, lines 27–31): "Consumers should pin uses: ...@v1 — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits" — this is the old caching model. The new README and the on: workflow_call: comment block (lines 40–43) correctly explain that @v1 is NOT re-fetched by act_runners. The header block was not updated and now directly contradicts the body of the same file. Also, the example on line 9 still shows @v1.

  • review-reusable.yml line 134 comment is incomplete (verified): "# --- config (from inputs; empty => image default)" — the actual behavior is now inputs || vars.GADFLY_DEFAULT_*, so "empty => image default" is missing the middle step. The comment should say "empty => user var => image default" to match the actual resolution chain documented everywhere else in the file.


Suggested fix: Update examples/reusable.yml, examples/README.md, CLAUDE.md lines 108–110, and the header comment block in review-reusable.yml (lines 1–31) to consistently describe the new runtime-variable resolution model and the @<sha> pinning recommendation. These are all documentation that the PR's own README update renders stale — leaving them contradictory is a real maintainability hazard.

Performance — No material issues found

No material issues found.

This diff is purely a config-plumbing change: it moves default values from workflow inputs: defaults (baked into the cached YAML) to runtime Gitea vars.* resolution. From a performance standpoint:

  • No hot-path impact. The ${{ inputs.X || vars.GADFLY_DEFAULT_X }} expressions are evaluated once by the Gitea Actions engine before the job container starts — they don't add any per-model or per-lens overhead. The resolved strings are passed as env vars, identical in format to what the container already consumed.
  • No new allocations, copies, or loops. The entrypoint.sh model loop (run_lane, provider_cap, review_one) and the Go binary's os.Getenv("GADFLY_PROVIDER_LENS_CONCURRENCY") / registerEnvProviders() path are untouched — they process the same CSV and provider=N,... strings regardless of origin.
  • registerEnvProviders() still scans os.Environ() once at startup — same O(env-count) cost as before; the number of GADFLY_ENDPOINT_* vars doesn't change, and this is a one-time init, not a hot loop.
  • No N+1, no quadratic behavior, no unbounded growth, no blocking I/O introduced. The change is entirely in the workflow expression layer, not in the review execution path.
🧯 Error handling & edge cases — No material issues found

Verdict: No material issues found

The fallback chain (input → var → image default) is well-constructed and degrades gracefully at every level I can verify:

  • Empty GADFLY_MODELS: || treats "" as falsy → falls through to vars.GADFLY_DEFAULT_MODELS. If that's also unset/empty, the env is "", and entrypoint.sh:156 catches it with ${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}} → the image's built-in qwen3-coder:480b-cloud. If all three are empty, MODEL_LIST ends up empty, PROVIDERS is empty, the script logs "providers: none" and exits 0 — no hang, no crash.

  • Empty concurrency vars: entrypoint.sh:169 does "${GADFLY_PROVIDER_CONCURRENCY:-}" — empty string → no caps parsed → provider_cap() falls through to $DEFAULT_CONC (which is 1). Malformed values like "ollama-cloud=abc" are caught by [ "$cap" -ge 1 ] 2>/dev/null || cap=1 at line 208.

  • Unset vars.GADFLY_ENDPOINT_RAGNAROS: Resolves to ""registerEnvProviders() in cmd/gadfly/model.go:184 picks up GADFLY_ENDPOINT_RAGNAROS= from the environment, calls endpointProvider("ragnaros", ""), which returns a non-fatal error (collected in errs, not a panic). The ragnaros provider simply isn't registered.

  • GADFLY_SPECIALISTS empty: Passed through to the binary; the PR states the image default kicks in. I did not trace the Go binary's handling of this (unverified), but the diff doesn't change the binary — only how the env var is set — so any behavior here is pre-existing.

No swallowed errors, no missing cleanup/rollback, no panics on bad input, no nil/null/zero/negative/overflow conditions introduced by this diff.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 10m 2s

<!-- gadfly-review:ollama:deepseek-v4-pro:cloud --> ### 🪰 Gadfly review — `deepseek-v4-pro:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** This change is a net security improvement through my lens. The key observation: - **Hardcoded internal hostname removed from publicly readable file**: The old `GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"` (line 130 old) exposed an internal LAN hostname to anyone who could read the workflow file. Moving it to `vars.GADFLY_ENDPOINT_RAGNAROS` eliminates that information disclosure. The variable is still injected into the container's environment at runtime, but that's the same runtime exposure the old hardcoded value already had — the difference is the value is no longer sitting in plaintext in a publicly cloneable file. - **`vars` context in a reusable workflow resolves to the owner, not the caller**: In Gitea Actions, `vars.GADFLY_DEFAULT_*` and `vars.GADFLY_ENDPOINT_RAGNAROS` inside `steve/gadfly`'s reusable workflow resolve to steve's user/org-level variables. A consumer calling this workflow cannot override them through their own variables — they can only override through the explicit `with:` inputs. So a malicious consumer can't inject arbitrary endpoints or config by setting same-named variables in their own scope. - **No injection surface in the `||` fallback**: The expressions like `${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}` are evaluated by Gitea's expression engine, not by a shell. The resulting strings are set as literal environment variables. In `entrypoint.sh`, these values are parsed with `read`/`IFS` splitting (not `eval`), so there's no command injection path even if a consumer provides malicious `with:` input values. - **`GADFLY_ENDPOINT_RAGNAROS` in `vars` vs `secrets`**: The old hardcoded value had no credential (`llamaswap|https://...` — no `|<key>` segment). The new variable presumably has the same format. If a key were ever added, `vars` would be less protected than `secrets` (vars are not encrypted at rest in Gitea), but that's a hypothetical future concern, not a bug in this diff. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> ## Verdict: No material issues found I traced the full resolution chain for every changed env var through the workflow expressions, the entrypoint.sh shell defaults, and the Go binary's env parsing. The `||` fallback logic is sound: - **`inputs.models || vars.GADFLY_DEFAULT_MODELS`** → empty string → `entrypoint.sh:156` falls through to `DEFAULT_MODELS="qwen3-coder:480b-cloud"`. Verified by reading `entrypoint.sh:52,156`. - **`inputs.specialists || vars.GADFLY_DEFAULT_SPECIALISTS`** → empty string → `specialists.go:136` (`strings.TrimSpace("") != ""` is false) → falls through to the default specialist selection. Verified by reading `cmd/gadfly/specialists.go:136-137`. - **`inputs.provider_concurrency || vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY`** → empty string → `entrypoint.sh:169` reads `""` into `_caps` as a single empty element → `provider_cap` loop skips it (no `=` in `""`) → falls through to `DEFAULT_CONC` (1). Verified by reading `entrypoint.sh:157,167-176`. - **`inputs.provider_lens_concurrency || vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY`** → empty string → `main.go:292` `strings.Split("", ",")` = `[""]` → no `=` → skipped → falls through to `envInt("GADFLY_LENS_CONCURRENCY", defaultLensConcurrency)`. Verified by reading `cmd/gadfly/main.go:281-299`. - **`GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`** → if unset, empty string → `model.go:184` finds `GADFLY_ENDPOINT_RAGNAROS=` in environ → `endpointProvider("ragnaros", "")` returns an error (collected, non-fatal). The endpoint simply isn't registered. Verified by reading `cmd/gadfly/model.go:179-202`. The `provider` and `base_url` inputs intentionally lack `|| vars.` fallback — they remain consumer-specific, and `entrypoint.sh:159` defaults bare models to `ollama-cloud` when `GADFLY_PROVIDER` is empty. This is consistent with the PR's stated scope. No logic errors, no broken fallthrough chains, no type mismatches, no incorrect operator semantics. The change correctly implements the three-tier resolution described in the PR. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Blocking issues found</summary> ## Verdict: Blocking issues found The diff itself is structurally clean — the YAML substitutions are straightforward and the README is well-updated. But the PR leaves **multiple files with stale, contradictory documentation** that will confuse anyone maintaining or consuming this workflow. These are concrete maintainability problems, not nits. --- - **`examples/reusable.yml` is now misleading in three ways** (verified by reading the file): - **Line 8**: `"The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite."` — the whole point of this PR is that the reusable **no longer ships a hardcoded default**. The swarm is now resolved at runtime from owner variables. This comment is now false. - **Lines 17, 52, 54**: Still recommends `@v1` as the pinning strategy ("curated release tag, auto-updates on releases"). The new README explicitly says `@v1` is **unreliable** because act_runners cache by ref and a moved tag is NOT re-fetched. The example stub should recommend `@<sha>` to match the new guidance. - **Line 67**: The `models:` override still hardcodes the old default models (`"minimax-m3:cloud,glm-5.2:cloud,deepseek-v4-pro:cloud"`). This was the old "cloud-only override" that matched the old hardcoded default. Now that the default is variable-driven, this override is confusing — it looks like it's overriding something that no longer exists in the file. Should either be removed (let the variable flow through) or updated to explain the new resolution chain. - **`examples/README.md` line 9 is stale** (verified): `"inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite)"` — same problem. The reusable no longer ships that default; it resolves from owner variables. The table row needs updating. - **`CLAUDE.md` lines 108–110 are stale** (verified): `"the **reusable workflow** (`review-reusable.yml`) deliberately ships a heavier opinionated default swarm (3 cloud + Claude Code, 5 lenses) for steve's own fleet, which consumers inherit or override per-input."` — this is now inaccurate. The reusable no longer ships that swarm; it resolves from variables. The CLAUDE.md was partially updated (the architecture block on lines 49–57 is correct) but this paragraph was missed. - **`review-reusable.yml` header comment block contradicts the new guidance** (verified, lines 27–31): `"Consumers should pin uses: ...@v1 — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits"` — this is the **old** caching model. The new README and the `on: workflow_call:` comment block (lines 40–43) correctly explain that `@v1` is NOT re-fetched by act_runners. The header block was not updated and now directly contradicts the body of the same file. Also, the example on line 9 still shows `@v1`. - **`review-reusable.yml` line 134 comment is incomplete** (verified): `"# --- config (from inputs; empty => image default)"` — the actual behavior is now `inputs || vars.GADFLY_DEFAULT_*`, so "empty => image default" is missing the middle step. The comment should say `"empty => user var => image default"` to match the actual resolution chain documented everywhere else in the file. --- **Suggested fix**: Update `examples/reusable.yml`, `examples/README.md`, `CLAUDE.md` lines 108–110, and the header comment block in `review-reusable.yml` (lines 1–31) to consistently describe the new runtime-variable resolution model and the `@<sha>` pinning recommendation. These are all documentation that the PR's own README update renders stale — leaving them contradictory is a real maintainability hazard. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **No material issues found.** This diff is purely a config-plumbing change: it moves default values from workflow `inputs:` defaults (baked into the cached YAML) to runtime Gitea `vars.*` resolution. From a performance standpoint: - **No hot-path impact.** The `${{ inputs.X || vars.GADFLY_DEFAULT_X }}` expressions are evaluated once by the Gitea Actions engine before the job container starts — they don't add any per-model or per-lens overhead. The resolved strings are passed as env vars, identical in format to what the container already consumed. - **No new allocations, copies, or loops.** The entrypoint.sh model loop (`run_lane`, `provider_cap`, `review_one`) and the Go binary's `os.Getenv("GADFLY_PROVIDER_LENS_CONCURRENCY")` / `registerEnvProviders()` path are untouched — they process the same CSV and `provider=N,...` strings regardless of origin. - **`registerEnvProviders()` still scans `os.Environ()` once at startup** — same O(env-count) cost as before; the number of `GADFLY_ENDPOINT_*` vars doesn't change, and this is a one-time init, not a hot loop. - **No N+1, no quadratic behavior, no unbounded growth, no blocking I/O introduced.** The change is entirely in the workflow expression layer, not in the review execution path. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> ## Verdict: No material issues found The fallback chain (`input → var → image default`) is well-constructed and degrades gracefully at every level I can verify: - **Empty `GADFLY_MODELS`**: `||` treats `""` as falsy → falls through to `vars.GADFLY_DEFAULT_MODELS`. If that's also unset/empty, the env is `""`, and `entrypoint.sh:156` catches it with `${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}` → the image's built-in `qwen3-coder:480b-cloud`. If all three are empty, `MODEL_LIST` ends up empty, `PROVIDERS` is empty, the script logs `"providers: none"` and exits 0 — no hang, no crash. - **Empty concurrency vars**: `entrypoint.sh:169` does `"${GADFLY_PROVIDER_CONCURRENCY:-}"` — empty string → no caps parsed → `provider_cap()` falls through to `$DEFAULT_CONC` (which is `1`). Malformed values like `"ollama-cloud=abc"` are caught by `[ "$cap" -ge 1 ] 2>/dev/null || cap=1` at line 208. - **Unset `vars.GADFLY_ENDPOINT_RAGNAROS`**: Resolves to `""` → `registerEnvProviders()` in `cmd/gadfly/model.go:184` picks up `GADFLY_ENDPOINT_RAGNAROS=` from the environment, calls `endpointProvider("ragnaros", "")`, which returns a non-fatal error (collected in `errs`, not a panic). The ragnaros provider simply isn't registered. - **`GADFLY_SPECIALISTS` empty**: Passed through to the binary; the PR states the image default kicks in. I did not trace the Go binary's handling of this (unverified), but the diff doesn't change the binary — only how the env var is set — so any behavior here is pre-existing. No swallowed errors, no missing cleanup/rollback, no panics on bad input, no nil/null/zero/negative/overflow conditions introduced by this diff. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 10m 2s</sub>

🪰 Gadfly review — claude-code/opus:max (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

Both findings check out against the actual code:

  • review-reusable.yml:130 does change GADFLY_ENDPOINT_RAGNAROS from a committed literal to ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}, and model.go:207-234 confirms this value becomes a provider base URL honored verbatim (the egress sink) and that the |<key> field is parsed and applied as a token. M1/M5 are sourced from secrets (lines 123-124) while RAGNAROS now comes from vars (line 130). Both observations are accurate.

VERDICT: Minor issues

This diff is config plumbing for a Gitea reusable workflow — it moves swarm config and one endpoint URL from committed input-defaults into runtime-injected variables. No authn/authz logic, injection sink, or deserialization path is introduced. The actor gate (allowed_users) and the least-privilege secret-forwarding model are untouched. Two security-relevant observations, both around the new vars-sourced egress endpoint:

  • .gitea/workflows/review-reusable.yml:130 — an egress destination is downgraded from committed code to an unmasked variable. GADFLY_ENDPOINT_RAGNAROS was a literal (llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa) in a git-tracked, code-reviewed file; it's now ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}. Confirmed in cmd/gadfly/model.go:207 (endpointProvider) that this value becomes a provider base URL "honored verbatim (http or https)" with no scheme/host validation — i.e. it's where the PR's diff and file contents get transmitted for review. Changing where (potentially private) source code is sent now requires only variable-write in the owner scope, not a reviewed commit, and leaves no git trail. That's a real trust-boundary reduction for a data-egress sink. Impact is modest (it's the owner's own fleet, and a malicious consumer setting their own var only redirects their own repo's review), but worth noting. Suggest: keep the egress endpoint pinned in the reviewed file, or at minimum document that GADFLY_ENDPOINT_* variables are an exfiltration-capable control and restrict who can edit org/user variables.

  • .gitea/workflows/review-reusable.yml:130 — credential-in-variable footgun (secret-leakage). The endpoint format is "<provider>|<base-url>[|<key>]" (verified at model.go:164,208-217 — the third |-delimited field is parsed and applied as a provider token). The sibling endpoints GADFLY_ENDPOINT_M1/M5 that use this same |key-capable format are sourced from secrets (masked in logs); RAGNAROS is now sourced from vars, which Gitea does not mask. Today it's keyless, but the schema invites a future maintainer to append |<token> to the variable — which would then be printed in plaintext in CI logs and the variable UI. Suggest: a comment explicitly warning "no credential in this var — it is not log-masked; use a secret if a key is ever needed," or route it through a secret like M1/M5.

Both are Minor. I found no injection, no cross-tenant secret leak (reusable-workflow secrets come from the caller), no authz change.

Note on confidence: the parsing/egress behavior is code-verified. Gitea's exact masking behavior for vars vs secrets was not independently confirmed against code — the leakage point rests on the well-established convention that variables are unmasked while secrets are; treat that sub-claim as standard-behavior, not code-verified.

Outside my lens: the PR's core premise — that vars.GADFLY_DEFAULT_* resolves to the owner's variables — is questionable, since vars in a workflow_call reusable typically resolves in the caller's context; if so the central config silently no-ops for external consumers. That's a correctness/docs concern for another reviewer, flagged here only because it undercuts the rollout claims.

🎯 Correctness — Minor issues

I've verified the findings against the actual files. Let me confirm the key facts:

  • review-reusable.yml:135-140 use ${{ inputs.X || vars.GADFLY_DEFAULT_X }} with inputs now defaulting to "" (lines 52-57). The deleted note documented "" as an opt-out to the image default — confirmed in the diff.
  • adversarial-review.yml:4, :6-8, :52-54 still assert the default swarm lives "from review-reusable.yml" — but the swarm is no longer in that file's input defaults; it now comes from owner vars. Confirmed stale.
  • The Gitea vars resolution-scope claim is, by the draft's own admission, unverifiable from the codebase — and it is. As a verification gate I cannot positively confirm it, so it must be dropped.

VERDICT: Minor issues

Correctness lens findings:

  • review-reusable.yml:135-140 — lost "force image default" escape hatch. The config lines now read ${{ inputs.X || vars.GADFLY_DEFAULT_X }}. Because || returns the second operand when the first is falsy (empty string is falsy — same semantics the existing PR: chain at line 107 relies on), a consumer passing an explicit models: "" (or specialists/provider_concurrency/provider_lens_concurrency: "") no longer reaches the image default — they silently get the owner's vars.GADFLY_DEFAULT_*. The previous code (${{ inputs.models }}) plus the now-deleted note "Set any input to '' to fall back to the image/entrypoint built-in default" made "" a real opt-out. After this change the image default is reachable only when both the input and the owner var are empty; a consumer can no longer override a set owner var back down to the image default. Impact: small/niche, but a genuine, undocumented loss of a previously-documented behavior. Fix: document the removal, or keep a sentinel (e.g. treat models: "image-default" specially in entrypoint.sh).

  • adversarial-review.yml:4, :6-8, :52-54 — stale comments after the swarm moved out of the reusable. These still assert the default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5-lens suite) is held "from review-reusable.yml". After this change the reusable no longer carries the swarm as input defaults (they are now ""); the swarm comes from the owner's vars.GADFLY_DEFAULT_*, which this caller does not set (it passes only allowed_users). The comments are now misleading: the actual swarm depends on owner vars existing, and if they are unset the run silently collapses to the image default (one model, default suite — entrypoint.sh:156, specialists.go:67). Impact: documentation-accuracy / silent-degradation risk for the dogfooding path. Fix: update the comments to point at the owner-var central config (per CLAUDE.md's "keep docs current" rule).

🧹 Code cleanliness & maintainability — Minor issues

All three findings are confirmed against the actual files. The YAML header (lines 27-31, untouched by the diff) and examples/reusable.yml (untouched by the diff) both still recommend @v1, contradicting the new README guidance, and the inline config comment at line 134 is now imprecise.


VERDICT: Minor issues

This PR is docs/config only (YAML + two markdown files). Through the maintainability lens the change is mostly clean, but it updated README.md while leaving the other consumer-facing docs asserting the exact guidance this PR exists to discredit. The repo now gives contradictory advice about how to pin.

Findings

  • Contradictory pin guidance left in the same workflow file being edited.gitea/workflows/review-reusable.yml:29-31 (header comment, untouched by the diff) still says: "Consumers should pin uses: ...@v1 — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits." The new README.md says the opposite — a moved tag (@v1) is cached by long-lived act_runners and silently runs a stale copy, and @<sha> is "the only reliable way." With this PR, central tuning now propagates via runtime variables regardless of the pinned ref, so tying propagation to @v1 is exactly the misconception the PR corrects. The @v1 example at line 9 is the same issue. A maintainer reading the file header gets the wrong model. Fix: update the header block (and the line 9 example) to the @<sha> guidance.

  • examples/reusable.yml not kept in sync (CLAUDE.md maintenance rule) — the file is untouched by this PR, yet:

    • Lines 17-19 and 52-53 still recommend @v1 as a curated tag "for auto-updating stability" — the same contradiction as above; CLAUDE.md explicitly requires examples/ be updated in the same change as consumer-stub/behavior changes.
    • Lines 8-12 still describe the reusable as shipping "a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" (also echoed at lines 64-66). This PR dropped the input defaults to "", so with no owner vars the file default is now the image default ("one model, default suite"). README.md's intro was updated to "default swarm is set centrally via owner variables"; this example was not. Fix: align the example header with the runtime-variable model.
  • Stale inline comment after the config block.gitea/workflows/review-reusable.yml:134: # --- config (from inputs; empty => image default) ---. For GADFLY_MODELS/SPECIALISTS/PROVIDER_CONCURRENCY/PROVIDER_LENS_CONCURRENCY (lines 135-140), an empty input now falls back to a user var, then the image default — not directly to the image default. Low-churn fix: e.g. # --- config (input → GADFLY_DEFAULT_* var → image default) ---.

Not a problem (checked, deliberately not reported)

  • The || vars.GADFLY_DEFAULT_* fallback is applied to the four swarm knobs but not to provider/base_url/timeout_secs/max_steps/worker_model (lines 137-143). This is consistent with the PR's stated scope (volatile swarm config) and is a reasonable boundary, not an inconsistency to fix.

All findings are documentation/comment consistency — none affect runtime behavior, hence "Minor." The first two undercut the PR's own goal (consumers told to pin @v1 will keep silently running stale config), so they're worth fixing before merge.

Performance — No material issues found

VERDICT: No material issues found

This PR is a configuration/documentation change to the reusable Gitea workflow (review-reusable.yml), plus matching edits to CLAUDE.md and README.md. It relocates swarm-config defaults from input literals into runtime-resolved owner variables (inputs.X || vars.GADFLY_DEFAULT_X). Through the performance lens there is nothing material to flag:

  • No new work on any hot path. The || fallbacks (inputs.models || vars.GADFLY_DEFAULT_MODELS, etc.) are evaluated once per job at workflow-expression time by the runner, not in any loop. They add no per-iteration cost to the review fleet's specialists × models × 2 passes execution.
  • Default-cost direction is neutral-to-better, not a regression. Previously the public file baked in a heavy default (7 models / 5 lenses). Now an unset var + no input falls back to the image default (one model, default suite) per the diff comment and CLAUDE.md. A public consumer with neither now does less work, not more — so this can't be an efficiency regression for the inherited path.
  • Concurrency knobs dropping to "" (provider_concurrency / provider_lens_concurrency) means an unset owner var yields the entrypoint default of 1 (per CLAUDE.md's per-provider concurrency note). That lowers parallelism in the absence of config — slower wall-clock, but it's a deliberate conservative default, not an unbounded-growth or quadratic-behavior problem.
  • No N+1, no added allocations/copies, no unbounded accumulation, no missing pagination, no new blocking calls introduced by this diff.

Efficiency-relevant magic numbers in the old defaults (the 3×5=15 peak-concurrency arithmetic) were removed here and pushed into variables, so there is no constant left in this diff to re-derive.

Nothing in my lane is wrong. (The substantive concerns this PR raises — e.g. whether the || short-circuit treats an explicitly-empty input the way the comments claim, or the cache-bust rollout correctness — are correctness/workflow-semantics matters another lens covers.)

🧯 Error handling & edge cases — Minor issues

I've verified each finding against the actual source. Let me confirm the findings hold.

Finding 1 (empty config → graceful): Confirmed clean. provider_cap() (entrypoint.sh:167–176) reads an empty _caps from an empty GADFLY_PROVIDER_CONCURRENCY and returns DEFAULT_CONC; empty GADFLY_MODELS/GADFLY_SPECIALISTS fall through to defaults. No finding.

Finding 2 (unset GADFLY_ENDPOINT_RAGNAROS logs a parse error): Confirmed. An empty value still matches the GADFLY_ENDPOINT_ prefix in registerEnvProviders (model.go:184), endpointProvider("ragnaros", "") does SplitN("", "|", 3) → 1 element → returns the want "<provider>|..." error (model.go:208–210), logged non-fatally at model.go:47–49. Accurate, non-fatal.

Finding 3 (decoupled model/endpoint → silent partial coverage): Confirmed. A ragnaros/* model with no registered ragnaros provider fails majordomo.Parse (buildSpec passes the /-bearing spec verbatim, model.go:133–137), and review_one swallows it at entrypoint.sh:183 (|| log "model $1 failed (continuing)"). The README table added in the diff lists the two vars independently with no pairing note.

Both minor findings survive.


VERDICT: Minor issues

I focused on the new unhappy paths this diff makes reachable: empty inputs falling through to vars, and the RAGNAROS endpoint now sourced from a possibly-unset variable.

  • Empty config values all degrade gracefully — verified clean (no finding). With both inputs.* and vars.GADFLY_DEFAULT_* unset, the env vars arrive as "": GADFLY_PROVIDER_CONCURRENCY=""provider_cap() reads an empty _caps, matches nothing, returns DEFAULT_CONC (1) (entrypoint.sh:167-176); GADFLY_MODELS="" / GADFLY_SPECIALISTS="" → image/entrypoint defaults. These were previously always non-empty from the input defaults, so the empty case is newly reachable, but it's handled.

  • Minor — unset GADFLY_ENDPOINT_RAGNAROS logs a parse error every run (.gitea/workflows/review-reusable.yml:130). When the var is undefined, Gitea injects GADFLY_ENDPOINT_RAGNAROS="". registerEnvProviders (cmd/gadfly/model.go:184) still matches the GADFLY_ENDPOINT_ prefix, calls endpointProvider("ragnaros", ""), and SplitN("", "|", 3) yields one element → returns the want "<provider>|<base-url>..." error (model.go:208-210). It's non-fatal — best-effort, logged to stderr and skipped (model.go:47-49) — and the M1/M5 secret-sourced endpoints already exhibit the same empty-value behavior, so the pattern is pre-existing; the diff newly applies it to ragnaros (previously a hardcoded valid value), adding a spurious "ignoring bad endpoint/alias" line whenever the var is unset. Cosmetic, not blocking.

  • Minor — the change decouples the ragnaros/* model from its endpoint into two separate variables, creating a silent partial-coverage path. Previously ragnaros/qwen3.6-27b and GADFLY_ENDPOINT_RAGNAROS lived in the same file and were always consistent. Now if the owner sets GADFLY_DEFAULT_MODELS to include ragnaros/* but leaves GADFLY_ENDPOINT_RAGNAROS unset (or vice-versa), the ragnaros provider never registers, majordomo.Parse fails to resolve that model (the /-bearing spec is passed through verbatim by buildSpec, model.go:133-137), and review_one swallows it (entrypoint.sh:183|| log "model $1 failed (continuing)"). The run still reports success with that lens silently missing. This is graceful (advisory invariant preserved), but it's a new operability footgun: the README's new "Central config via variables" table lists the two vars independently with no note that they must be set as a pair. Suggest a one-line caveat that ragnaros/* in GADFLY_DEFAULT_MODELS requires GADFLY_ENDPOINT_RAGNAROS.

Nothing in this diff can crash the binary or fail a consumer's CI on the new paths — the advisory-only invariant holds. Both findings are low-severity; no blocking error-handling defect.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 5s

<!-- gadfly-review:ollama:claude-code/opus:max --> ### 🪰 Gadfly review — `claude-code/opus:max` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Both findings check out against the actual code: - `review-reusable.yml:130` does change `GADFLY_ENDPOINT_RAGNAROS` from a committed literal to `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`, and `model.go:207-234` confirms this value becomes a provider base URL honored verbatim (the egress sink) and that the `|<key>` field is parsed and applied as a token. M1/M5 are sourced from `secrets` (lines 123-124) while RAGNAROS now comes from `vars` (line 130). Both observations are accurate. --- **VERDICT: Minor issues** This diff is config plumbing for a Gitea reusable workflow — it moves swarm config and one endpoint URL from committed input-defaults into runtime-injected variables. No authn/authz logic, injection sink, or deserialization path is introduced. The actor gate (`allowed_users`) and the least-privilege secret-forwarding model are untouched. Two security-relevant observations, both around the new `vars`-sourced **egress endpoint**: - **`.gitea/workflows/review-reusable.yml:130` — an egress destination is downgraded from committed code to an unmasked variable.** `GADFLY_ENDPOINT_RAGNAROS` was a literal (`llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa`) in a git-tracked, code-reviewed file; it's now `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`. Confirmed in `cmd/gadfly/model.go:207` (`endpointProvider`) that this value becomes a provider base URL "honored verbatim (http or https)" with no scheme/host validation — i.e. it's where the PR's diff and file contents get transmitted for review. Changing where (potentially private) source code is sent now requires only **variable-write** in the owner scope, not a reviewed commit, and leaves no git trail. That's a real trust-boundary reduction for a data-egress sink. Impact is modest (it's the owner's own fleet, and a malicious consumer setting their own var only redirects their own repo's review), but worth noting. Suggest: keep the egress endpoint pinned in the reviewed file, or at minimum document that `GADFLY_ENDPOINT_*` variables are an exfiltration-capable control and restrict who can edit org/user variables. - **`.gitea/workflows/review-reusable.yml:130` — credential-in-variable footgun (secret-leakage).** The endpoint format is `"<provider>|<base-url>[|<key>]"` (verified at `model.go:164,208-217` — the third `|`-delimited field is parsed and applied as a provider token). The sibling endpoints `GADFLY_ENDPOINT_M1`/`M5` that use this same `|key`-capable format are sourced from **`secrets`** (masked in logs); RAGNAROS is now sourced from **`vars`**, which Gitea does **not** mask. Today it's keyless, but the schema invites a future maintainer to append `|<token>` to the variable — which would then be printed in plaintext in CI logs and the variable UI. Suggest: a comment explicitly warning "no credential in this var — it is not log-masked; use a secret if a key is ever needed," or route it through a secret like M1/M5. Both are Minor. I found no injection, no cross-tenant secret leak (reusable-workflow secrets come from the caller), no authz change. Note on confidence: the parsing/egress behavior is code-verified. Gitea's exact masking behavior for `vars` vs `secrets` was not independently confirmed against code — the leakage point rests on the well-established convention that variables are unmasked while secrets are; treat that sub-claim as standard-behavior, not code-verified. Outside my lens: the PR's core premise — that `vars.GADFLY_DEFAULT_*` resolves to *the owner's* variables — is questionable, since `vars` in a `workflow_call` reusable typically resolves in the **caller's** context; if so the central config silently no-ops for external consumers. That's a correctness/docs concern for another reviewer, flagged here only because it undercuts the rollout claims. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> I've verified the findings against the actual files. Let me confirm the key facts: - `review-reusable.yml:135-140` use `${{ inputs.X || vars.GADFLY_DEFAULT_X }}` with inputs now defaulting to `""` (lines 52-57). The deleted note documented `""` as an opt-out to the image default — confirmed in the diff. - `adversarial-review.yml:4`, `:6-8`, `:52-54` still assert the default swarm lives "from review-reusable.yml" — but the swarm is no longer in that file's input defaults; it now comes from owner vars. Confirmed stale. - The Gitea `vars` resolution-scope claim is, by the draft's own admission, unverifiable from the codebase — and it is. As a verification gate I cannot positively confirm it, so it must be dropped. ## VERDICT: Minor issues **Correctness lens findings:** - **`review-reusable.yml:135-140` — lost "force image default" escape hatch.** The config lines now read `${{ inputs.X || vars.GADFLY_DEFAULT_X }}`. Because `||` returns the second operand when the first is falsy (empty string is falsy — same semantics the existing `PR:` chain at line 107 relies on), a consumer passing an explicit `models: ""` (or `specialists`/`provider_concurrency`/`provider_lens_concurrency: ""`) no longer reaches the image default — they silently get the owner's `vars.GADFLY_DEFAULT_*`. The previous code (`${{ inputs.models }}`) plus the now-deleted note *"Set any input to '' to fall back to the image/entrypoint built-in default"* made `""` a real opt-out. After this change the image default is reachable only when *both* the input and the owner var are empty; a consumer can no longer override a set owner var back down to the image default. Impact: small/niche, but a genuine, undocumented loss of a previously-documented behavior. Fix: document the removal, or keep a sentinel (e.g. treat `models: "image-default"` specially in `entrypoint.sh`). - **`adversarial-review.yml:4`, `:6-8`, `:52-54` — stale comments after the swarm moved out of the reusable.** These still assert the default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5-lens suite) is held "from review-reusable.yml". After this change the reusable no longer carries the swarm as input defaults (they are now `""`); the swarm comes from the owner's `vars.GADFLY_DEFAULT_*`, which this caller does not set (it passes only `allowed_users`). The comments are now misleading: the actual swarm depends on owner vars existing, and if they are unset the run silently collapses to the image default (one model, default suite — `entrypoint.sh:156`, `specialists.go:67`). Impact: documentation-accuracy / silent-degradation risk for the dogfooding path. Fix: update the comments to point at the owner-var central config (per CLAUDE.md's "keep docs current" rule). </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> All three findings are confirmed against the actual files. The YAML header (lines 27-31, untouched by the diff) and `examples/reusable.yml` (untouched by the diff) both still recommend `@v1`, contradicting the new README guidance, and the inline config comment at line 134 is now imprecise. --- **VERDICT: Minor issues** This PR is docs/config only (YAML + two markdown files). Through the maintainability lens the change is mostly clean, but it updated `README.md` while leaving the *other* consumer-facing docs asserting the exact guidance this PR exists to discredit. The repo now gives contradictory advice about how to pin. ### Findings - **Contradictory pin guidance left in the same workflow file being edited** — `.gitea/workflows/review-reusable.yml:29-31` (header comment, untouched by the diff) still says: *"Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits."* The new `README.md` says the opposite — a moved tag (`@v1`) is cached by long-lived act_runners and silently runs a stale copy, and `@<sha>` is "the only reliable way." With this PR, central tuning now propagates via runtime **variables** regardless of the pinned ref, so tying propagation to `@v1` is exactly the misconception the PR corrects. The `@v1` example at line 9 is the same issue. A maintainer reading the file header gets the wrong model. Fix: update the header block (and the line 9 example) to the `@<sha>` guidance. - **`examples/reusable.yml` not kept in sync (CLAUDE.md maintenance rule)** — the file is untouched by this PR, yet: - Lines 17-19 and 52-53 still recommend `@v1` as a curated tag "for auto-updating stability" — the same contradiction as above; CLAUDE.md explicitly requires `examples/` be updated in the *same* change as consumer-stub/behavior changes. - Lines 8-12 still describe the reusable as shipping *"a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite"* (also echoed at lines 64-66). This PR dropped the input defaults to `""`, so with no owner vars the file default is now the image default ("one model, default suite"). `README.md`'s intro was updated to "default swarm is set centrally via owner variables"; this example was not. Fix: align the example header with the runtime-variable model. - **Stale inline comment after the config block** — `.gitea/workflows/review-reusable.yml:134`: `# --- config (from inputs; empty => image default) ---`. For `GADFLY_MODELS`/`SPECIALISTS`/`PROVIDER_CONCURRENCY`/`PROVIDER_LENS_CONCURRENCY` (lines 135-140), an empty input now falls back to a user `var`, *then* the image default — not directly to the image default. Low-churn fix: e.g. `# --- config (input → GADFLY_DEFAULT_* var → image default) ---`. ### Not a problem (checked, deliberately not reported) - The `|| vars.GADFLY_DEFAULT_*` fallback is applied to the four swarm knobs but not to `provider`/`base_url`/`timeout_secs`/`max_steps`/`worker_model` (lines 137-143). This is consistent with the PR's stated scope (volatile *swarm* config) and is a reasonable boundary, not an inconsistency to fix. All findings are documentation/comment consistency — none affect runtime behavior, hence "Minor." The first two undercut the PR's own goal (consumers told to pin `@v1` will keep silently running stale config), so they're worth fixing before merge. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** This PR is a configuration/documentation change to the reusable Gitea workflow (`review-reusable.yml`), plus matching edits to `CLAUDE.md` and `README.md`. It relocates swarm-config defaults from input literals into runtime-resolved owner variables (`inputs.X || vars.GADFLY_DEFAULT_X`). Through the **performance** lens there is nothing material to flag: - **No new work on any hot path.** The `||` fallbacks (`inputs.models || vars.GADFLY_DEFAULT_MODELS`, etc.) are evaluated once per job at workflow-expression time by the runner, not in any loop. They add no per-iteration cost to the review fleet's `specialists × models × 2 passes` execution. - **Default-cost direction is neutral-to-better, not a regression.** Previously the public file baked in a heavy default (7 models / 5 lenses). Now an unset var + no input falls back to the image default (one model, default suite) per the diff comment and CLAUDE.md. A public consumer with neither now does *less* work, not more — so this can't be an efficiency regression for the inherited path. - **Concurrency knobs dropping to `""`** (`provider_concurrency` / `provider_lens_concurrency`) means an unset owner var yields the entrypoint default of `1` (per CLAUDE.md's per-provider concurrency note). That lowers parallelism in the absence of config — slower wall-clock, but it's a deliberate conservative default, not an unbounded-growth or quadratic-behavior problem. - No N+1, no added allocations/copies, no unbounded accumulation, no missing pagination, no new blocking calls introduced by this diff. Efficiency-relevant magic numbers in the old defaults (the `3×5=15` peak-concurrency arithmetic) were *removed* here and pushed into variables, so there is no constant left in this diff to re-derive. Nothing in my lane is wrong. (The substantive concerns this PR raises — e.g. whether the `||` short-circuit treats an explicitly-empty input the way the comments claim, or the cache-bust rollout correctness — are correctness/workflow-semantics matters another lens covers.) </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> I've verified each finding against the actual source. Let me confirm the findings hold. **Finding 1 (empty config → graceful):** Confirmed clean. `provider_cap()` (entrypoint.sh:167–176) reads an empty `_caps` from an empty `GADFLY_PROVIDER_CONCURRENCY` and returns `DEFAULT_CONC`; empty `GADFLY_MODELS`/`GADFLY_SPECIALISTS` fall through to defaults. No finding. **Finding 2 (unset `GADFLY_ENDPOINT_RAGNAROS` logs a parse error):** Confirmed. An empty value still matches the `GADFLY_ENDPOINT_` prefix in `registerEnvProviders` (model.go:184), `endpointProvider("ragnaros", "")` does `SplitN("", "|", 3)` → 1 element → returns the `want "<provider>|..."` error (model.go:208–210), logged non-fatally at model.go:47–49. Accurate, non-fatal. **Finding 3 (decoupled model/endpoint → silent partial coverage):** Confirmed. A `ragnaros/*` model with no registered `ragnaros` provider fails `majordomo.Parse` (buildSpec passes the `/`-bearing spec verbatim, model.go:133–137), and `review_one` swallows it at entrypoint.sh:183 (`|| log "model $1 failed (continuing)"`). The README table added in the diff lists the two vars independently with no pairing note. Both minor findings survive. --- **VERDICT: Minor issues** I focused on the new unhappy paths this diff makes reachable: empty inputs falling through to vars, and the `RAGNAROS` endpoint now sourced from a possibly-unset variable. - **Empty config values all degrade gracefully — verified clean (no finding).** With both `inputs.*` and `vars.GADFLY_DEFAULT_*` unset, the env vars arrive as `""`: `GADFLY_PROVIDER_CONCURRENCY=""` → `provider_cap()` reads an empty `_caps`, matches nothing, returns `DEFAULT_CONC` (1) (`entrypoint.sh:167-176`); `GADFLY_MODELS=""` / `GADFLY_SPECIALISTS=""` → image/entrypoint defaults. These were previously always non-empty from the input defaults, so the empty case is newly reachable, but it's handled. - **Minor — unset `GADFLY_ENDPOINT_RAGNAROS` logs a parse error every run** (`.gitea/workflows/review-reusable.yml:130`). When the var is undefined, Gitea injects `GADFLY_ENDPOINT_RAGNAROS=""`. `registerEnvProviders` (`cmd/gadfly/model.go:184`) still matches the `GADFLY_ENDPOINT_` prefix, calls `endpointProvider("ragnaros", "")`, and `SplitN("", "|", 3)` yields one element → returns the `want "<provider>|<base-url>..."` error (`model.go:208-210`). It's **non-fatal** — best-effort, logged to stderr and skipped (`model.go:47-49`) — and the `M1`/`M5` secret-sourced endpoints already exhibit the same empty-value behavior, so the *pattern* is pre-existing; the diff newly applies it to `ragnaros` (previously a hardcoded valid value), adding a spurious "ignoring bad endpoint/alias" line whenever the var is unset. Cosmetic, not blocking. - **Minor — the change decouples the `ragnaros/*` model from its endpoint into two separate variables, creating a silent partial-coverage path.** Previously `ragnaros/qwen3.6-27b` and `GADFLY_ENDPOINT_RAGNAROS` lived in the same file and were always consistent. Now if the owner sets `GADFLY_DEFAULT_MODELS` to include `ragnaros/*` but leaves `GADFLY_ENDPOINT_RAGNAROS` unset (or vice-versa), the `ragnaros` provider never registers, `majordomo.Parse` fails to resolve that model (the `/`-bearing spec is passed through verbatim by `buildSpec`, `model.go:133-137`), and `review_one` swallows it (`entrypoint.sh:183` — `|| log "model $1 failed (continuing)"`). The run still reports success with that lens silently missing. This is graceful (advisory invariant preserved), but it's a new operability footgun: the README's new "Central config via variables" table lists the two vars independently with no note that they must be set as a pair. Suggest a one-line caveat that `ragnaros/*` in `GADFLY_DEFAULT_MODELS` requires `GADFLY_ENDPOINT_RAGNAROS`. Nothing in this diff can crash the binary or fail a consumer's CI on the new paths — the advisory-only invariant holds. Both findings are low-severity; no blocking error-handling defect. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 5s</sub>

🪰 Gadfly review — claude-code/opus (claude-code)

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

Both facts in the finding are confirmed against the code:

  • review-reusable.yml:80-81 and :123-124GADFLY_ENDPOINT_M1/M5 are declared under workflow_call.secrets and wired from ${{ secrets.* }}.
  • review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS now resolves from ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}.
  • model.go:207-260 (endpointProvider) parses the "<provider>|<base-url>[|<key>]" form and applies the optional third field as a provider credential (ollama.WithToken, llamaswap.WithToken, openai.WithAPIKey, anthropic.WithAPIKey, google.WithAPIKey).

The finding survives.


VERDICT: Minor issues

This change is almost entirely documentation plus config plumbing. Through the security lens, the data being relocated (GADFLY_DEFAULT_*, GADFLY_ENDPOINT_RAGNAROS) is owner/consumer-controlled, not attacker (PR-author)-controlled, so it opens no authz/injection/SSRF path that the consumer didn't already have via with: inputs. The change actually reduces exposure by pulling the hardcoded internal LAN host out of the public file. One real, concrete concern:

  • .gitea/workflows/review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS is moved from a hardcoded value into a vars.* (variable), but other members of the GADFLY_ENDPOINT_* family are treated as secrets. GADFLY_ENDPOINT_M1/M5 are declared under workflow_call.secrets (lines 80–81) and wired from ${{ secrets.* }} (lines 123–124), whereas ragnaros now comes from ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}. The endpoint format is "<provider>|<base-url>[|<key>]", and cmd/gadfly/model.go:207-260 (endpointProvider) actively parses and applies the optional [|<key>] as a provider credential. Impact: Gitea vars are stored in plaintext in repo/user settings and are not masked in CI logs the way secrets are. The ragnaros URL is credential-less today, so there's no leak right now — but this creates an insecure-by-design slot: the moment anyone follows the documented format and appends a |<key> to this variable (or reuses the slot for an authenticated endpoint), that credential is exposed in plaintext settings and unmasked in workflow logs, diverging from how M1/M5 are protected. Fix: either keep credential-bearing endpoint definitions in secrets (declare GADFLY_ENDPOINT_RAGNAROS as a forwarded secret like M1/M5), or explicitly document in the var's comment/README that this variable must remain credential-less (URL only) precisely because variables aren't masked.
🎯 Correctness — No material issues found

I've verified the key correctness concerns against the actual code.

Review — 🎯 Correctness lens

VERDICT: No material issues found

The change is almost entirely YAML config-resolution plumbing plus docs. I checked the substantive correctness risks:

  • || fallback operator is supported by act_runner — verified: || is already used in this very file (review-reusable.yml:107, PR: ${{ ... || ... }}) and across every example workflow. So the new inputs.X || vars.GADFLY_DEFAULT_X expressions (lines 135–136, 139–140) will evaluate, not be passed through literally. The CLAUDE.md "weak expression support" warning is about complex expressions; simple || short-circuit is clearly in use already.

  • Fallback precedence is correctinputs.models || vars.GADFLY_DEFAULT_MODELS: empty string is falsy in Actions expressions, so an empty input yields the var; an unset var yields ''. Combined with the env-consumer in entrypoint.sh:156 (MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}", where bash :- triggers on empty too) and entrypoint.sh:169 (empty GADFLY_PROVIDER_CONCURRENCY → no caps → default), the documented chain input → var → image default holds end-to-end. I verified the consumer side reads empty as "use default".

  • No stale default left behind — the dropped input defaults (model CSV, concurrency strings, hardcoded RAGNAROS URL) are not referenced anywhere else as a fallback; the only fallback path is the var → bash default chain above. The hardcoded GADFLY_ENDPOINT_RAGNAROS (line 130) now correctly reads from vars, matching the ragnaros/* model entry expectation, so models and endpoint move together (no half-migrated state where the model is defined but the endpoint isn't).

Unverified caveat (not a finding, platform behavior I can't confirm with repo tools): the entire feature hinges on vars.GADFLY_DEFAULT_* being resolvable inside a workflow_call reusable job under Gitea's act_runner. If act_runner does not expose the vars context in a called reusable workflow, every fallback silently collapses to '' → image default, and central tuning would no-op without error. This is the one place I'd smoke-test on a real run before relying on it; I could not verify it from the checked-out code. Worth a one-time validation run on rollout, which the PR's @<sha> cache-bust step already provides an opportunity for.

Nothing within the correctness lens blocks this.

🧹 Code cleanliness & maintainability — Minor issues

Both findings verified against the actual code. Line 134 of review-reusable.yml confirmed to read # --- config (from inputs; empty => image default) --- while lines 135–140 now resolve through vars.GADFLY_DEFAULT_*. The example comments in examples/reusable.yml and .gitea/workflows/adversarial-review.yml confirmed to still describe the baked-in default swarm that the new default: "" inputs no longer provide.

VERDICT: Minor issues

Reviewed through the code cleanliness & maintainability lens. The reusable-workflow edits themselves are tidy and the inline comments around the changed lines were carefully rewritten — but two pieces of explanatory text were left behind, now describing behavior the PR deleted.

Findings

  • .gitea/workflows/review-reusable.yml:134 — stale section header. The line reads # --- config (from inputs; empty => image default) ---, but the four config envs immediately below it now resolve inputs.X || vars.GADFLY_DEFAULT_X → image default (lines 135–140). The other comments in this file were updated to spell out the new vars fallback (e.g. the runtime-resolution header and the GADFLY_ENDPOINT_RAGNAROS block at 125–130), so this header is conspicuously inconsistent and now understates the resolution chain. Fix: # --- config (inputs → user vars → image default) ---, or at minimum drop the misleading "from inputs" framing.

  • examples/reusable.yml:9-10,64-66 and .gitea/workflows/adversarial-review.yml:6-8,52-54 — example comments still describe the old baked-in default swarm. These say the reusable "ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" and that omitting/deleting the override "inherit[s] the full default swarm (3 cloud + Claude Code…, 5 lenses)." After this PR the input defaults are "", so omitting with: no longer inherits that swarm from the inputs block — it inherits the owner GADFLY_DEFAULT_* var (or the image's one-model default). The PR updated README.md and CLAUDE.md but not examples/. CLAUDE.md's maintenance rule explicitly requires defaults changes to be reflected in examples/ in the same change ("stale docs are a bug"). Fix: update both example headers to describe the runtime-variable resolution, matching the README's new "Central config via variables" section.

Nothing else in my lens: the inputs.X || vars.Y pattern is applied consistently across exactly the four "swarm" knobs and left off the non-central ones (provider, base_url, worker_model, …) by deliberate design — that asymmetry reads cleanly. No dead code, duplication, or naming problems in the changed lines.

Performance — Minor issues

Both mechanics are now fully confirmed against the code:

  • entrypoint.sh:157,167-176,206-217 — empty GADFLY_PROVIDER_CONCURRENCYprovider_cap returns DEFAULT_CONC = ${GADFLY_CONCURRENCY:-1} = 1 → run_lane runs models serially.
  • cmd/gadfly/main.go:99defaultLensConcurrency = 1; empty GADFLY_PROVIDER_LENS_CONCURRENCY (and unset GADFLY_LENS_CONCURRENCY) → lensConcurrency() = 1 → serial lenses. This resolves the one hedge in the draft, confirming the claim.

The fallback chain in the diff (inputs.x || vars.GADFLY_DEFAULT_x) matches the described partial-config scenario. The finding holds.


VERDICT: Minor issues

Finding — decoupling models from concurrency can silently serialize the swarm
.gitea/workflows/review-reusable.yml:55-58 (the provider_concurrency / provider_lens_concurrency inputs now default to "").

Before this PR, the heavy swarm and its tuned concurrency shipped together as coupled input defaults (models: "…7 models…" and provider_concurrency: "ollama-cloud=3,claude-code=3,ragnaros=1"). This PR splits them into independent runtime variables: GADFLY_DEFAULT_MODELS and GADFLY_DEFAULT_PROVIDER_CONCURRENCY are now set separately by the owner.

The fallback in entrypoint.sh:157,167-176: if GADFLY_PROVIDER_CONCURRENCY resolves empty, provider_cap() returns DEFAULT_CONC = ${GADFLY_CONCURRENCY:-1} = 1. Lane execution at run_lane() (entrypoint.sh:206-217) then runs that provider's models strictly one-at-a-time.

Concrete impact: an owner who sets GADFLY_DEFAULT_MODELS (the 3-claude + cloud + ragnaros fleet) but doesn't also set GADFLY_DEFAULT_PROVIDER_CONCURRENCY gets cap=1 — the claude-code lane runs its 3 models serially instead of 3 concurrent, roughly tripling that lane's wall-clock per pass, ×2 passes. The same applies to provider_lens_concurrency: it is consumed in the binary, where defaultLensConcurrency = 1 (cmd/gadfly/main.go:99,281-285), so an empty value serializes the lenses within each model too. Previously this misconfiguration was impossible because the values were a single coupled default; the restructure makes a partial owner-var config silently degrade throughput rather than fail loudly.

Suggested fix: either document that GADFLY_DEFAULT_PROVIDER_CONCURRENCY / _LENS_CONCURRENCY must be set whenever GADFLY_DEFAULT_MODELS defines a multi-model swarm, or keep a non-empty sane default for the two concurrency inputs (they aren't egress-sensitive like the models list / LAN endpoint, so they don't need to be emptied for the public-default-genericization goal that motivated this PR). This is a wall-clock/throughput risk, not a correctness break — hence Minor.

Nothing else in this diff is performance-relevant — it's otherwise YAML default values and prose.

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

I reviewed this diff strictly through the error handling & edge cases lens. The change is almost entirely YAML config-resolution + docs; its only behavioral effect is that several env vars (GADFLY_MODELS, GADFLY_SPECIALISTS, GADFLY_PROVIDER_CONCURRENCY, GADFLY_PROVIDER_LENS_CONCURRENCY, GADFLY_ENDPOINT_RAGNAROS) can now arrive empty at the container where they previously always had a concrete literal default. I traced every new empty/unhappy path to its handler:

  • Empty GADFLY_ENDPOINT_RAGNAROS (was a hardcoded URL, now ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}"" when the var is unset): the prefix branch in cmd/gadfly/model.go:184 still matches the (empty-valued) key and calls endpointProvider(name, ""), which returns an error at model.go:209-210. That error is best-effortresolveModel only logs it to stderr and skips (model.go:47-49, "a malformed entry is logged and skipped rather than failing the whole review"). Verified by reading the function and its sole caller. This is also not new behavior: GADFLY_ENDPOINT_M1/M5 already arrive empty when not forwarded, so the empty-endpoint path was already exercised.
  • Empty GADFLY_MODELS (new — inputs.models || vars.… can resolve to ""): handled at entrypoint.sh:156 via ${GADFLY_MODELS:-…:-$DEFAULT_MODELS}, where :- triggers on empty and unset, so it lands on the image default. Verified.
  • Empty GADFLY_PROVIDER_CONCURRENCY: provider_cap reads ${GADFLY_PROVIDER_CONCURRENCY:-} (entrypoint.sh:169); an empty override map yields an empty array and falls through to the default cap. Verified.
  • Split config (ragnaros/* listed in GADFLY_DEFAULT_MODELS but GADFLY_ENDPOINT_RAGNAROS unset) — newly possible because this diff decouples the two variables: degrades gracefully. The unresolved provider only fails that model; lanes run independently and are wait-ed in parallel (entrypoint.sh:248-252), consistent with the advisory-only / per-lens-isolation design. Not fatal to the suite.

No swallowed errors, missing cleanup, panics on empty input, or boundary mistakes introduced. The || fallbacks are well-behaved because empty string is falsy in Gitea/GitHub expressions, and every downstream consumer already treats empty-or-unset identically. The diff is clean in my lane.


A note on process: I was placed in "plan mode," but the actual task here is an adversarial code review, which is fully read-only — I used only read tools and produced findings, making no edits. There's no implementation to plan, so the review above is the deliverable rather than a plan file.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 8s

<!-- gadfly-review:ollama:claude-code/opus --> ### 🪰 Gadfly review — `claude-code/opus` (claude-code) **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Both facts in the finding are confirmed against the code: - `review-reusable.yml:80-81` and `:123-124` — `GADFLY_ENDPOINT_M1`/`M5` are declared under `workflow_call.secrets` and wired from `${{ secrets.* }}`. - `review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS` now resolves from `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`. - `model.go:207-260` (`endpointProvider`) parses the `"<provider>|<base-url>[|<key>]"` form and applies the optional third field as a provider credential (`ollama.WithToken`, `llamaswap.WithToken`, `openai.WithAPIKey`, `anthropic.WithAPIKey`, `google.WithAPIKey`). The finding survives. --- **VERDICT: Minor issues** This change is almost entirely documentation plus config plumbing. Through the security lens, the data being relocated (`GADFLY_DEFAULT_*`, `GADFLY_ENDPOINT_RAGNAROS`) is **owner/consumer-controlled, not attacker (PR-author)-controlled**, so it opens no authz/injection/SSRF path that the consumer didn't already have via `with:` inputs. The change actually *reduces* exposure by pulling the hardcoded internal LAN host out of the public file. One real, concrete concern: - **`.gitea/workflows/review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS` is moved from a hardcoded value into a `vars.*` (variable), but other members of the `GADFLY_ENDPOINT_*` family are treated as secrets.** `GADFLY_ENDPOINT_M1`/`M5` are declared under `workflow_call.secrets` (lines 80–81) and wired from `${{ secrets.* }}` (lines 123–124), whereas ragnaros now comes from `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`. The endpoint format is `"<provider>|<base-url>[|<key>]"`, and `cmd/gadfly/model.go:207-260` (`endpointProvider`) actively parses and applies the optional `[|<key>]` as a provider credential. **Impact:** Gitea `vars` are stored in plaintext in repo/user settings and are **not** masked in CI logs the way `secrets` are. The ragnaros URL is credential-less *today*, so there's no leak right now — but this creates an insecure-by-design slot: the moment anyone follows the documented format and appends a `|<key>` to this variable (or reuses the slot for an authenticated endpoint), that credential is exposed in plaintext settings and unmasked in workflow logs, diverging from how M1/M5 are protected. **Fix:** either keep credential-bearing endpoint definitions in `secrets` (declare `GADFLY_ENDPOINT_RAGNAROS` as a forwarded secret like M1/M5), or explicitly document in the var's comment/README that this variable must remain credential-less (URL only) precisely because variables aren't masked. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> I've verified the key correctness concerns against the actual code. ## Review — 🎯 Correctness lens **VERDICT: No material issues found** The change is almost entirely YAML config-resolution plumbing plus docs. I checked the substantive correctness risks: - **`||` fallback operator is supported by act_runner** — verified: `||` is already used in this very file (`review-reusable.yml:107`, `PR: ${{ ... || ... }}`) and across every example workflow. So the new `inputs.X || vars.GADFLY_DEFAULT_X` expressions (lines 135–136, 139–140) will evaluate, not be passed through literally. The CLAUDE.md "weak expression support" warning is about complex expressions; simple `||` short-circuit is clearly in use already. - **Fallback precedence is correct** — `inputs.models || vars.GADFLY_DEFAULT_MODELS`: empty string is falsy in Actions expressions, so an empty input yields the var; an unset var yields `''`. Combined with the env-consumer in `entrypoint.sh:156` (`MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"`, where bash `:-` triggers on empty too) and `entrypoint.sh:169` (empty `GADFLY_PROVIDER_CONCURRENCY` → no caps → default), the documented chain **input → var → image default** holds end-to-end. I verified the consumer side reads empty as "use default". - **No stale default left behind** — the dropped input defaults (model CSV, concurrency strings, hardcoded RAGNAROS URL) are not referenced anywhere else as a fallback; the only fallback path is the var → bash default chain above. The hardcoded `GADFLY_ENDPOINT_RAGNAROS` (line 130) now correctly reads from `vars`, matching the `ragnaros/*` model entry expectation, so models and endpoint move together (no half-migrated state where the model is defined but the endpoint isn't). **Unverified caveat (not a finding, platform behavior I can't confirm with repo tools):** the entire feature hinges on `vars.GADFLY_DEFAULT_*` being resolvable inside a `workflow_call` reusable job under Gitea's act_runner. If act_runner does *not* expose the `vars` context in a called reusable workflow, every fallback silently collapses to `''` → image default, and central tuning would no-op without error. This is the one place I'd smoke-test on a real run before relying on it; I could not verify it from the checked-out code. Worth a one-time validation run on rollout, which the PR's `@<sha>` cache-bust step already provides an opportunity for. Nothing within the correctness lens blocks this. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> Both findings verified against the actual code. Line 134 of `review-reusable.yml` confirmed to read `# --- config (from inputs; empty => image default) ---` while lines 135–140 now resolve through `vars.GADFLY_DEFAULT_*`. The example comments in `examples/reusable.yml` and `.gitea/workflows/adversarial-review.yml` confirmed to still describe the baked-in default swarm that the new `default: ""` inputs no longer provide. ## VERDICT: Minor issues Reviewed through the **code cleanliness & maintainability** lens. The reusable-workflow edits themselves are tidy and the inline comments around the changed lines were carefully rewritten — but two pieces of explanatory text were left behind, now describing behavior the PR deleted. ### Findings - **`.gitea/workflows/review-reusable.yml:134` — stale section header.** The line reads `# --- config (from inputs; empty => image default) ---`, but the four config envs immediately below it now resolve `inputs.X || vars.GADFLY_DEFAULT_X` → image default (lines 135–140). The other comments in this file were updated to spell out the new vars fallback (e.g. the runtime-resolution header and the `GADFLY_ENDPOINT_RAGNAROS` block at 125–130), so this header is conspicuously inconsistent and now understates the resolution chain. Fix: `# --- config (inputs → user vars → image default) ---`, or at minimum drop the misleading "from inputs" framing. - **`examples/reusable.yml:9-10,64-66` and `.gitea/workflows/adversarial-review.yml:6-8,52-54` — example comments still describe the old baked-in default swarm.** These say the reusable "ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" and that omitting/deleting the override "inherit[s] the full default swarm (3 cloud + Claude Code…, 5 lenses)." After this PR the input defaults are `""`, so omitting `with:` no longer inherits that swarm from the inputs block — it inherits the owner `GADFLY_DEFAULT_*` var (or the image's one-model default). The PR updated `README.md` and `CLAUDE.md` but not `examples/`. CLAUDE.md's maintenance rule explicitly requires defaults changes to be reflected in `examples/` in the *same* change ("stale docs are a bug"). Fix: update both example headers to describe the runtime-variable resolution, matching the README's new "Central config via variables" section. Nothing else in my lens: the `inputs.X || vars.Y` pattern is applied consistently across exactly the four "swarm" knobs and left off the non-central ones (`provider`, `base_url`, `worker_model`, …) by deliberate design — that asymmetry reads cleanly. No dead code, duplication, or naming problems in the changed lines. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> Both mechanics are now fully confirmed against the code: - `entrypoint.sh:157,167-176,206-217` — empty `GADFLY_PROVIDER_CONCURRENCY` → `provider_cap` returns `DEFAULT_CONC` = `${GADFLY_CONCURRENCY:-1}` = 1 → `run_lane` runs models serially. - `cmd/gadfly/main.go:99` — `defaultLensConcurrency = 1`; empty `GADFLY_PROVIDER_LENS_CONCURRENCY` (and unset `GADFLY_LENS_CONCURRENCY`) → `lensConcurrency()` = 1 → serial lenses. This resolves the one hedge in the draft, confirming the claim. The fallback chain in the diff (`inputs.x || vars.GADFLY_DEFAULT_x`) matches the described partial-config scenario. The finding holds. --- ## VERDICT: Minor issues **Finding — decoupling models from concurrency can silently serialize the swarm** `.gitea/workflows/review-reusable.yml:55-58` (the `provider_concurrency` / `provider_lens_concurrency` inputs now default to `""`). Before this PR, the heavy swarm and its tuned concurrency shipped together as coupled input defaults (`models: "…7 models…"` *and* `provider_concurrency: "ollama-cloud=3,claude-code=3,ragnaros=1"`). This PR splits them into **independent** runtime variables: `GADFLY_DEFAULT_MODELS` and `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` are now set separately by the owner. The fallback in `entrypoint.sh:157,167-176`: if `GADFLY_PROVIDER_CONCURRENCY` resolves empty, `provider_cap()` returns `DEFAULT_CONC` = `${GADFLY_CONCURRENCY:-1}` = **1**. Lane execution at `run_lane()` (`entrypoint.sh:206-217`) then runs that provider's models strictly one-at-a-time. Concrete impact: an owner who sets `GADFLY_DEFAULT_MODELS` (the 3-claude + cloud + ragnaros fleet) but doesn't *also* set `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` gets cap=1 — the claude-code lane runs its 3 models serially instead of 3 concurrent, roughly tripling that lane's wall-clock per pass, ×2 passes. The same applies to `provider_lens_concurrency`: it is consumed in the binary, where `defaultLensConcurrency = 1` (`cmd/gadfly/main.go:99,281-285`), so an empty value serializes the lenses within each model too. Previously this misconfiguration was impossible because the values were a single coupled default; the restructure makes a partial owner-var config silently degrade throughput rather than fail loudly. Suggested fix: either document that `GADFLY_DEFAULT_PROVIDER_CONCURRENCY` / `_LENS_CONCURRENCY` must be set whenever `GADFLY_DEFAULT_MODELS` defines a multi-model swarm, or keep a non-empty sane default for the two concurrency inputs (they aren't egress-sensitive like the models list / LAN endpoint, so they don't need to be emptied for the public-default-genericization goal that motivated this PR). This is a wall-clock/throughput risk, not a correctness break — hence Minor. Nothing else in this diff is performance-relevant — it's otherwise YAML default values and prose. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> ## VERDICT: No material issues found I reviewed this diff strictly through the **error handling & edge cases** lens. The change is almost entirely YAML config-resolution + docs; its only behavioral effect is that several env vars (`GADFLY_MODELS`, `GADFLY_SPECIALISTS`, `GADFLY_PROVIDER_CONCURRENCY`, `GADFLY_PROVIDER_LENS_CONCURRENCY`, `GADFLY_ENDPOINT_RAGNAROS`) can now arrive **empty** at the container where they previously always had a concrete literal default. I traced every new empty/unhappy path to its handler: - **Empty `GADFLY_ENDPOINT_RAGNAROS`** (was a hardcoded URL, now `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` → `""` when the var is unset): the prefix branch in `cmd/gadfly/model.go:184` still matches the (empty-valued) key and calls `endpointProvider(name, "")`, which returns an error at `model.go:209-210`. That error is **best-effort** — `resolveModel` only logs it to stderr and skips (`model.go:47-49`, "a malformed entry is logged and skipped rather than failing the whole review"). Verified by reading the function and its sole caller. This is also not new behavior: `GADFLY_ENDPOINT_M1/M5` already arrive empty when not forwarded, so the empty-endpoint path was already exercised. - **Empty `GADFLY_MODELS`** (new — `inputs.models || vars.…` can resolve to `""`): handled at `entrypoint.sh:156` via `${GADFLY_MODELS:-…:-$DEFAULT_MODELS}`, where `:-` triggers on empty *and* unset, so it lands on the image default. Verified. - **Empty `GADFLY_PROVIDER_CONCURRENCY`**: `provider_cap` reads `${GADFLY_PROVIDER_CONCURRENCY:-}` (`entrypoint.sh:169`); an empty override map yields an empty array and falls through to the default cap. Verified. - **Split config (`ragnaros/*` listed in `GADFLY_DEFAULT_MODELS` but `GADFLY_ENDPOINT_RAGNAROS` unset)** — newly possible because this diff decouples the two variables: degrades gracefully. The unresolved provider only fails *that* model; lanes run independently and are `wait`-ed in parallel (`entrypoint.sh:248-252`), consistent with the advisory-only / per-lens-isolation design. Not fatal to the suite. No swallowed errors, missing cleanup, panics on empty input, or boundary mistakes introduced. The `||` fallbacks are well-behaved because empty string is falsy in Gitea/GitHub expressions, and every downstream consumer already treats empty-or-unset identically. The diff is clean in my lane. --- A note on process: I was placed in "plan mode," but the actual task here is an adversarial code review, which is fully read-only — I used only read tools and produced findings, making no edits. There's no implementation to plan, so the review above is the deliverable rather than a plan file. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 8s</sub>

🪰 Gadfly review — claude-code/sonnet (claude-code)

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

Reviewed the PR through the security lens. Here's what I checked and what I found:

Trust model for vars.* and inputs.* (verified by reading workflow structure)

  • vars.GADFLY_DEFAULT_* values require repository/org admin access to set — the same access level previously needed to edit the YAML defaults directly. No expansion of the attack surface.
  • inputs.* in workflow_call are supplied by the caller's workflow file, not by PR contributors. A PR author cannot inject malicious model specs or specialists strings without push access to the consuming repo's default branch.

|| expression evaluation (${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }})

This is environment-variable assignment, not shell interpolation inside a run: step. No expression-injection risk — the value lands as a string in the container's environment.

Shell use of these values in entrypoint.sh (verified by reading the file)

Variables are properly quoted throughout. Concurrency-map parsing (provider_cap()) trims whitespace but does no eval or glob expansion. MODEL_LIST is built into an array. No unquoted expansions in shell commands.

GADFLY_ENDPOINT_RAGNAROS moved from hardcoded to vars.* (verified by reading model.go)

  • The format supports an optional API key (<provider>|<base-url>|<key>). Since this is a vars value (not secrets), any key embedded here would appear unmasked in workflow logs — unlike GADFLY_ENDPOINT_M1 / GADFLY_ENDPOINT_M5, which are correctly wired as secrets.
  • However: the existing use is a credential-free LAN endpoint, the intent is documented as "no credential," and the README example only shows the keyless form. This is a latent trap for a future maintainer, not a current vulnerability. If the LAN endpoint ever gains an auth requirement, the value must be moved to a secret.

Egress regression check (net improvement)

Hardcoding GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa" previously caused all public consumers to attempt outbound connections to a personal LAN host. Moving it to a var that unset consumers simply won't have is a net security improvement.

🎯 Correctness — Minor issues

The finding is confirmed — empty GADFLY_ENDPOINT_RAGNAROS reaches endpointProvider with no guard, producing a stderr warning. The draft's cited error message text is wrong though (it says missing base URL in "" but the actual error for an empty string, which fails the len(parts) < 2 check at line 209, is want "<provider>|<base-url>[|<key>]", got ""). Also the draft incorrectly cites main.go for the stderr print — it's model.go:47–48. The core finding stands.


Minor issues

  • review-reusable.yml line 130 / model.go endpoint registration — spurious warning on every run when the var is unset

    GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}
    

    When vars.GADFLY_ENDPOINT_RAGNAROS is not configured — which is the normal state for any public consumer — the expression evaluates to "". The env var GADFLY_ENDPOINT_RAGNAROS="" is still passed to the binary. In model.go's registerEnvProviders() (lines 179–202), the endpoint branch has no guard for empty values (the alias branch at lines 192–199 does). endpointProvider("ragnaros", "") is called; strings.SplitN("", "|", 3) returns a one-element slice, so len(parts) < 2 triggers and returns an error. model.go:47–48 then prints to stderr on every run:

    gadfly: ignoring bad endpoint/alias: GADFLY_ENDPOINT_RAGNAROS: want "<provider>|<base-url>[|<key>]", got ""
    

    Impact: Every consumer who doesn't have GADFLY_ENDPOINT_RAGNAROS set sees a spurious warning mentioning "RAGNAROS" — an unfamiliar internal name — polluting their review log on every run. Noise, not failure, but incorrect behavior.

    Fix: Add a val == "" guard in registerEnvProviders() before the endpointProvider(name, val) call, parallel to the alias branch. One line change in cmd/gadfly/model.go around line 185:

    if val == "" {
        continue
    }
    

    Alternatively, condition the env injection in the YAML: but YAML can't easily omit an env: key, so the Go fix is cleaner.

🧹 Code cleanliness & maintainability — Blocking issues found

I've read all three relevant files. Now I can verify each finding.

Finding 1 (examples/reusable.yml stale comments): Confirmed. Lines 8-12 and 64-66 of examples/reusable.yml still describe "3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" as the swarm you inherit by deleting the models: override. But the PR changed review-reusable.yml so that input defaults are now "" (empty), resolved at runtime from vars.GADFLY_DEFAULT_MODELS. Deleting the models: override now gives you whatever the owner's variable is set to (or the image one-model default), not the hard-coded swarm the comment promises. The examples/reusable.yml file was not updated in this PR.

Finding 2 (contradictory pinning guidance): Confirmed. review-reusable.yml lines 29-31 say "Consumers should pin uses: ...@v1" as the primary recommendation, with @<sha> as an alternative. README.md lines 316-318 say the opposite: "Reusable-workflow consumers should pin the workflow ref to an immutable review-reusable.yml@<sha>" and explicitly warns that @v1 "silently runs a stale copy." The two documents give directly contradictory primary advice to the same audience.

Finding 3 (duplicate explanation in review-reusable.yml): Confirmed. Lines 15-19 and lines 37-50 both explain the resolution chain (input → owner var → image default). The second block does add specific variable names and the cache-eviction caveat, but the core explanation is repeated. The first block even ends with "see the inputs block" — a forward pointer to the second explanation — suggesting the first block could be condensed.

All three findings survive verification.


Blocking issues found

  • examples/reusable.yml:8-12,64-66 — stale comments actively mislead consumers

    The example file was not updated to match the new config model. Lines 8-12 still tell consumers: "The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite." Lines 64-66 still say: "Delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)."

    After this PR, deleting the models: override passes "" to review-reusable.yml, which then resolves to vars.GADFLY_DEFAULT_MODELS (the owner's runtime variable) or the image's one-model default — not the hard-coded swarm the comment promises. A consumer following this example literally will get a result that contradicts the instructions.

    The CLAUDE.md maintenance rule is explicit: "Any change to defaults MUST be reflected in README.md and the relevant files under examples/ in the same change." This was missed.

    Fix: Update the comment block in examples/reusable.yml to describe the new resolution chain (input → owner var → image default) and remove the stale references to a specific bundled swarm.


  • review-reusable.yml:29-31 vs README.md:316-318 — contradictory pinning guidance

    The workflow file header (lines 29-31) says "Consumers should pin uses: ...@v1" as the primary recommendation, with @<sha> as an optional alternative. The README (lines 316-318) says the opposite: "Reusable-workflow consumers should pin the workflow ref to an immutable review-reusable.yml@<sha>" and explicitly warns that @v1 "silently runs a stale copy."

    The two files now give directly contradictory primary advice to the same audience on the same question.

    Fix: Update the header comment in review-reusable.yml (lines 29-31) to align with the README's @<sha> guidance, or at minimum demote @v1 from the primary recommendation to an acknowledged-tradeoff alternative.


  • Duplicate "runtime resolution" explanation within review-reusable.ymlminor

    The resolution chain (input → owner var → image default) is explained twice: once in the top-level header (lines 15-19) and again in the workflow_call: inputs block (lines 37-50). The second block adds the specific variable names and the cache-eviction caveat, but the core explanation is repeated. Line 19 already ends with "see the inputs block" — a forward pointer to lines 37-50 — which suggests the header block could be condensed to just that pointer, leaving the full explanation in one place.

Performance — Minor issues

The finding is confirmed. Both fallback paths are verified:

  • entrypoint.sh:157DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}", and provider_cap() at line 169 reads ${GADFLY_PROVIDER_CONCURRENCY:-} which expands to "" when unset → falls through to DEFAULT_CONC (1).
  • cmd/gadfly/main.go:99defaultLensConcurrency = 1; lensConcurrency() falls back to this when GADFLY_PROVIDER_LENS_CONCURRENCY is empty.
  • No warning exists in entrypoint.sh when these vars are empty after resolution.

The before/after concurrency defaults from the diff are accurate, and the silent degradation path is real.


Minor issues


Finding: Silent throughput regression when owner variables are absent

Verified by reading entrypoint.sh (line 157: DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}") and cmd/gadfly/main.go (line 99: const defaultLensConcurrency = 1).

Before this PR:

Input Hardcoded default
provider_concurrency "ollama-cloud=3,claude-code=3,ragnaros=1"
provider_lens_concurrency "ollama-cloud=3,claude-code=5,ragnaros=1"

Peak claude-code throughput: 3 models × 5 lenses = up to 15 concurrent processes per pass.

After this PR, both default to "". The fallback chain is:

"" → vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY → image default (1)
"" → vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY → image default (1)

If the owner hasn't created those variables yet when consumers re-pin to the new SHA, both concurrency knobs land at 1×1 = 1 sequential process. A full claude-code swarm review that previously ran at peak concurrency now serializes, potentially running ~15× slower — silently, with no error or warning anywhere in the pipeline.

Impact: Wall-clock review time on the first few runs after consumers re-pin could balloon significantly. Since the PR calls out "consumers pin to this PR's merge @<sha>" as the rollout step, there's a window where variables may not be set, and the degradation won't be obvious from the logs.

Suggested fix: Either (a) ensure the owner variables are set before the PR merges and consumers re-pin, or (b) add a guard in entrypoint.sh that emits a warning (not an error, to preserve advisory-only) when GADFLY_PROVIDER_CONCURRENCY / GADFLY_PROVIDER_LENS_CONCURRENCY are empty after the fallback, e.g.:

if [ -z "${GADFLY_PROVIDER_CONCURRENCY:-}" ]; then
  echo "⚠️  GADFLY_PROVIDER_CONCURRENCY unset; defaulting to serial (cap=1). Set GADFLY_DEFAULT_PROVIDER_CONCURRENCY to restore concurrency." >&2
fi

This is bounded to the transition window but is the exact scenario a consumer following the rollout instructions will hit.

🧯 Error handling & edge cases — Minor issues

The code confirms the finding. registerEnvProviders() at line 184 matches any key with prefix GADFLY_ENDPOINT_ and nonzero suffix, calls endpointProvider(name, "") for an empty value, which at line 208–210 returns an error because strings.SplitN("", "|", 3) yields a one-element slice. That error is logged at line 48. The alias path already guards empty values (lines 195–198) but the endpoint path does not.

Minor issues

  • cmd/gadfly/model.go:184-188 — empty GADFLY_ENDPOINT_RAGNAROS triggers a logged (non-fatal) error on every run when the owner var is unset

    Previously hardcoded in the YAML to "llamaswap|https://...", the value is now ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}. When that Gitea variable is not configured, the expression evaluates to "" and the env var is set to an empty string. In registerEnvProviders(), the key matches the prefix filter at line 184 (len("GADFLY_ENDPOINT_RAGNAROS") > len("GADFLY_ENDPOINT_")), so endpointProvider("ragnaros", "") is called. strings.SplitN("", "|", 3) returns a one-element slice, len(parts) < 2 fires, and the error is returned and logged at line 48:

    gadfly: ignoring bad endpoint/alias: GADFLY_ENDPOINT_RAGNAROS: want "<provider>|<base-url>[|<key>]", got ""
    

    This appears in CI logs on every run for every consumer who hasn't configured vars.GADFLY_ENDPOINT_RAGNAROS. It is non-fatal and the endpoint is silently skipped, but it is a noisy regression. The alias path already has an early-skip for empty values (lines 195–198); the endpoint path lacks the same guard.

    Suggested fix — add an early-skip for empty values in the endpoint case of registerEnvProviders:

    case strings.HasPrefix(key, "GADFLY_ENDPOINT_") && len(key) > len("GADFLY_ENDPOINT_"):
        if strings.TrimSpace(val) == "" {
            continue
        }
        ...
    

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 5m 19s

<!-- gadfly-review:ollama:claude-code/sonnet --> ### 🪰 Gadfly review — `claude-code/sonnet` (claude-code) **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** Reviewed the PR through the security lens. Here's what I checked and what I found: **Trust model for `vars.*` and `inputs.*`** (verified by reading workflow structure) - `vars.GADFLY_DEFAULT_*` values require repository/org admin access to set — the same access level previously needed to edit the YAML defaults directly. No expansion of the attack surface. - `inputs.*` in `workflow_call` are supplied by the *caller's workflow file*, not by PR contributors. A PR author cannot inject malicious model specs or specialists strings without push access to the consuming repo's default branch. **`|| ` expression evaluation** (`${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}`) This is environment-variable assignment, not shell interpolation inside a `run:` step. No expression-injection risk — the value lands as a string in the container's environment. **Shell use of these values in `entrypoint.sh`** (verified by reading the file) Variables are properly quoted throughout. Concurrency-map parsing (`provider_cap()`) trims whitespace but does no `eval` or glob expansion. `MODEL_LIST` is built into an array. No unquoted expansions in shell commands. **`GADFLY_ENDPOINT_RAGNAROS` moved from hardcoded to `vars.*`** (verified by reading `model.go`) - The format supports an optional API key (`<provider>|<base-url>|<key>`). Since this is a `vars` value (not `secrets`), any key embedded here would appear **unmasked** in workflow logs — unlike `GADFLY_ENDPOINT_M1` / `GADFLY_ENDPOINT_M5`, which are correctly wired as `secrets`. - However: the existing use is a credential-free LAN endpoint, the intent is documented as "no credential," and the README example only shows the keyless form. This is a latent trap for a future maintainer, not a current vulnerability. If the LAN endpoint ever gains an auth requirement, the value must be moved to a secret. **Egress regression check** (net improvement) Hardcoding `GADFLY_ENDPOINT_RAGNAROS: "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"` previously caused all public consumers to attempt outbound connections to a personal LAN host. Moving it to a var that unset consumers simply won't have is a net security improvement. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> The finding is confirmed — empty `GADFLY_ENDPOINT_RAGNAROS` reaches `endpointProvider` with no guard, producing a stderr warning. The draft's cited error message text is wrong though (it says `missing base URL in ""` but the actual error for an empty string, which fails the `len(parts) < 2` check at line 209, is `want "<provider>|<base-url>[|<key>]", got ""`). Also the draft incorrectly cites `main.go` for the stderr print — it's `model.go:47–48`. The core finding stands. --- **Minor issues** - **`review-reusable.yml` line 130 / `model.go` endpoint registration — spurious warning on every run when the var is unset** ```yaml GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} ``` When `vars.GADFLY_ENDPOINT_RAGNAROS` is not configured — which is the normal state for any public consumer — the expression evaluates to `""`. The env var `GADFLY_ENDPOINT_RAGNAROS=""` is still passed to the binary. In `model.go`'s `registerEnvProviders()` (lines 179–202), the endpoint branch has **no guard for empty values** (the alias branch at lines 192–199 does). `endpointProvider("ragnaros", "")` is called; `strings.SplitN("", "|", 3)` returns a one-element slice, so `len(parts) < 2` triggers and returns an error. `model.go:47–48` then prints to stderr on every run: ``` gadfly: ignoring bad endpoint/alias: GADFLY_ENDPOINT_RAGNAROS: want "<provider>|<base-url>[|<key>]", got "" ``` **Impact:** Every consumer who doesn't have `GADFLY_ENDPOINT_RAGNAROS` set sees a spurious warning mentioning "RAGNAROS" — an unfamiliar internal name — polluting their review log on every run. Noise, not failure, but incorrect behavior. **Fix:** Add a `val == ""` guard in `registerEnvProviders()` before the `endpointProvider(name, val)` call, parallel to the alias branch. One line change in `cmd/gadfly/model.go` around line 185: ```go if val == "" { continue } ``` Alternatively, condition the env injection in the YAML: but YAML can't easily omit an `env:` key, so the Go fix is cleaner. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Blocking issues found</summary> I've read all three relevant files. Now I can verify each finding. **Finding 1 (`examples/reusable.yml` stale comments):** Confirmed. Lines 8-12 and 64-66 of `examples/reusable.yml` still describe "3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" as the swarm you inherit by deleting the `models:` override. But the PR changed `review-reusable.yml` so that input defaults are now `""` (empty), resolved at runtime from `vars.GADFLY_DEFAULT_MODELS`. Deleting the `models:` override now gives you whatever the owner's variable is set to (or the image one-model default), not the hard-coded swarm the comment promises. The `examples/reusable.yml` file was not updated in this PR. **Finding 2 (contradictory pinning guidance):** Confirmed. `review-reusable.yml` lines 29-31 say "Consumers should pin `uses: ...@v1`" as the primary recommendation, with `@<sha>` as an alternative. `README.md` lines 316-318 say the opposite: "Reusable-workflow consumers should **pin the workflow ref to an immutable `review-reusable.yml@<sha>`**" and explicitly warns that `@v1` "silently runs a stale copy." The two documents give directly contradictory primary advice to the same audience. **Finding 3 (duplicate explanation in `review-reusable.yml`):** Confirmed. Lines 15-19 and lines 37-50 both explain the resolution chain (input → owner var → image default). The second block does add specific variable names and the cache-eviction caveat, but the core explanation is repeated. The first block even ends with "see the inputs block" — a forward pointer to the second explanation — suggesting the first block could be condensed. All three findings survive verification. --- **Blocking issues found** - **`examples/reusable.yml:8-12,64-66` — stale comments actively mislead consumers** The example file was not updated to match the new config model. Lines 8-12 still tell consumers: *"The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite."* Lines 64-66 still say: *"Delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)."* After this PR, deleting the `models:` override passes `""` to `review-reusable.yml`, which then resolves to `vars.GADFLY_DEFAULT_MODELS` (the owner's runtime variable) or the image's one-model default — not the hard-coded swarm the comment promises. A consumer following this example literally will get a result that contradicts the instructions. The CLAUDE.md maintenance rule is explicit: *"Any change to defaults MUST be reflected in README.md and the relevant files under `examples/` in the same change."* This was missed. **Fix:** Update the comment block in `examples/reusable.yml` to describe the new resolution chain (input → owner var → image default) and remove the stale references to a specific bundled swarm. --- - **`review-reusable.yml:29-31` vs `README.md:316-318` — contradictory pinning guidance** The workflow file header (lines 29-31) says *"Consumers should pin `uses: ...@v1`"* as the primary recommendation, with `@<sha>` as an optional alternative. The README (lines 316-318) says the opposite: *"Reusable-workflow consumers should pin the workflow ref to an immutable `review-reusable.yml@<sha>`"* and explicitly warns that `@v1` *"silently runs a stale copy."* The two files now give directly contradictory primary advice to the same audience on the same question. **Fix:** Update the header comment in `review-reusable.yml` (lines 29-31) to align with the README's `@<sha>` guidance, or at minimum demote `@v1` from the primary recommendation to an acknowledged-tradeoff alternative. --- - **Duplicate "runtime resolution" explanation within `review-reusable.yml`** — *minor* The resolution chain (input → owner var → image default) is explained twice: once in the top-level header (lines 15-19) and again in the `workflow_call:` inputs block (lines 37-50). The second block adds the specific variable names and the cache-eviction caveat, but the core explanation is repeated. Line 19 already ends with "see the inputs block" — a forward pointer to lines 37-50 — which suggests the header block could be condensed to just that pointer, leaving the full explanation in one place. </details> <details><summary><b>⚡ Performance</b> — Minor issues</summary> The finding is confirmed. Both fallback paths are verified: - `entrypoint.sh:157` — `DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"`, and `provider_cap()` at line 169 reads `${GADFLY_PROVIDER_CONCURRENCY:-}` which expands to `""` when unset → falls through to `DEFAULT_CONC` (1). - `cmd/gadfly/main.go:99` — `defaultLensConcurrency = 1`; `lensConcurrency()` falls back to this when `GADFLY_PROVIDER_LENS_CONCURRENCY` is empty. - No warning exists in `entrypoint.sh` when these vars are empty after resolution. The before/after concurrency defaults from the diff are accurate, and the silent degradation path is real. --- **Minor issues** --- ### Finding: Silent throughput regression when owner variables are absent **Verified** by reading `entrypoint.sh` (line 157: `DEFAULT_CONC="${GADFLY_CONCURRENCY:-1}"`) and `cmd/gadfly/main.go` (line 99: `const defaultLensConcurrency = 1`). **Before this PR:** | Input | Hardcoded default | |---|---| | `provider_concurrency` | `"ollama-cloud=3,claude-code=3,ragnaros=1"` | | `provider_lens_concurrency` | `"ollama-cloud=3,claude-code=5,ragnaros=1"` | Peak claude-code throughput: 3 models × 5 lenses = **up to 15 concurrent processes per pass**. **After this PR**, both default to `""`. The fallback chain is: ``` "" → vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY → image default (1) "" → vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY → image default (1) ``` If the owner hasn't created those variables yet when consumers re-pin to the new SHA, both concurrency knobs land at **1×1 = 1 sequential process**. A full claude-code swarm review that previously ran at peak concurrency now serializes, potentially running **~15× slower** — silently, with no error or warning anywhere in the pipeline. **Impact:** Wall-clock review time on the first few runs after consumers re-pin could balloon significantly. Since the PR calls out "consumers pin to this PR's merge `@<sha>`" as the rollout step, there's a window where variables may not be set, and the degradation won't be obvious from the logs. **Suggested fix:** Either (a) ensure the owner variables are set *before* the PR merges and consumers re-pin, or (b) add a guard in `entrypoint.sh` that emits a warning (not an error, to preserve advisory-only) when `GADFLY_PROVIDER_CONCURRENCY` / `GADFLY_PROVIDER_LENS_CONCURRENCY` are empty after the fallback, e.g.: ```bash if [ -z "${GADFLY_PROVIDER_CONCURRENCY:-}" ]; then echo "⚠️ GADFLY_PROVIDER_CONCURRENCY unset; defaulting to serial (cap=1). Set GADFLY_DEFAULT_PROVIDER_CONCURRENCY to restore concurrency." >&2 fi ``` This is bounded to the transition window but is the exact scenario a consumer following the rollout instructions will hit. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> The code confirms the finding. `registerEnvProviders()` at line 184 matches any key with prefix `GADFLY_ENDPOINT_` and nonzero suffix, calls `endpointProvider(name, "")` for an empty value, which at line 208–210 returns an error because `strings.SplitN("", "|", 3)` yields a one-element slice. That error is logged at line 48. The alias path already guards empty values (lines 195–198) but the endpoint path does not. **Minor issues** - **`cmd/gadfly/model.go:184-188` — empty `GADFLY_ENDPOINT_RAGNAROS` triggers a logged (non-fatal) error on every run when the owner var is unset** Previously hardcoded in the YAML to `"llamaswap|https://..."`, the value is now `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}`. When that Gitea variable is not configured, the expression evaluates to `""` and the env var is set to an empty string. In `registerEnvProviders()`, the key matches the prefix filter at line 184 (`len("GADFLY_ENDPOINT_RAGNAROS") > len("GADFLY_ENDPOINT_")`), so `endpointProvider("ragnaros", "")` is called. `strings.SplitN("", "|", 3)` returns a one-element slice, `len(parts) < 2` fires, and the error is returned and logged at line 48: ``` gadfly: ignoring bad endpoint/alias: GADFLY_ENDPOINT_RAGNAROS: want "<provider>|<base-url>[|<key>]", got "" ``` This appears in CI logs on every run for every consumer who hasn't configured `vars.GADFLY_ENDPOINT_RAGNAROS`. It is non-fatal and the endpoint is silently skipped, but it is a noisy regression. The alias path already has an early-skip for empty values (lines 195–198); the endpoint path lacks the same guard. **Suggested fix** — add an early-skip for empty values in the endpoint case of `registerEnvProviders`: ```go case strings.HasPrefix(key, "GADFLY_ENDPOINT_") && len(key) > len("GADFLY_ENDPOINT_"): if strings.TrimSpace(val) == "" { continue } ... ``` </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 5m 19s</sub>

🪰 Gadfly review — minimax-m3:cloud (ollama-cloud)

Verdict: Blocking issues found — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

All claims in the draft review are confirmed against the actual code. Both findings survive verification. The first is a real concern with a clear, verifiable risk path (var → env → endpointProviderWithToken); the second is explicitly self-rated as "not blocking" and the reviewer's verification of the "no shell-injection" claim is correct (the value flows as an env var, not a CLI arg).

VERDICT: Minor issues

Findings (🔒 Security lens):

  • .gitea/workflows/review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS var format invites secret-injection into a non-secret store. Confirmed: cmd/gadfly/model.go:164 documents GADFLY_ENDPOINT_<NAME> = "<provider>|<base-url>[|<key>]" and endpointProvider at :207-217 honors an optional 3rd positional |key field, threading it into llamaswap.WithToken(key) / ollama.WithToken(key) etc. at :222-257. The reusable now sources this value from ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} and the README example at README.md:334 is keyless, but the format itself supports a token — and unlike secrets.*, a vars.* value is not masked in workflow logs and is intended for non-credential config. If an owner (or copy-pasted example) puts a key in the third segment, it is (1) stored as a plaintext user/org variable, (2) exported as a container env, and (3) passed verbatim to the provider's auth. The previous hardcoded literal "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa" was keyless by construction, so this PR regresses the threat model from "cannot accidentally embed a credential here" to "format silently permits it; storage is the wrong place". Fix: either (a) validate at the workflow level that the var contains exactly two pipe-separated fields and fail loudly otherwise, or (b) explicitly document and enforce in the inline comment that the |key third field is rejected for this var, and document a separate secret-backed path (e.g. GADFLY_RAGNAROS_KEY) as the bearer route. The README table at README.md:328-334 should call out "no credential — use a separate secret if auth is needed".

  • .gitea/workflows/review-reusable.yml:135-140inputs.X || vars.Y precedence is silent on falsy non-empty values (correctness-adjacent). Confirmed: Gitea Actions inherits GitHub Actions expression semantics where || is logical-OR on truthy/falsy, so "" falls through to the var (the intended default-fallback path) but so does "0" or "false". No exploit path — the resolved value is plumbed via GADFLY_MODELS / GADFLY_SPECIALISTS / GADFLY_PROVIDER_CONCURRENCY / GADFLY_PROVIDER_LENS_CONCURRENCY as env vars and consumed by entrypoint.sh / scripts/run.sh as env, not shell-interpolated command arguments (verified entrypoint.sh:181 and scripts/run.sh:165). Flagged for documentation only; not blocking.

  • No other issues in lens. workflow_call.secrets is unchanged and still least-privilege (only the credentials the reviewer actually uses are declared, no secrets: inherit). The removal of the hardcoded LAN URL from the public default is the correct direction — no new SSRF surface is introduced; owners now point at their own endpoints by design. GADFLY_API_KEY and provider key envs continue to flow only through declared secrets.*, never vars.* (the only vars.* references introduced are the four GADFLY_DEFAULT_* config inputs and GADFLY_ENDPOINT_RAGNAROS — the latter is the one that supports an optional key in its format and is the subject of the first finding).

🎯 Correctness — Blocking issues found

VERDICT: Blocking issues found

  • .gitea/workflows/review-reusable.yml:130,135-140vars.* resolves in the CALLER's scope, not the reusable's. Verified against examples/reusable.yml:54 (uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1, external consumer) and .gitea/workflows/adversarial-review.yml:43 (uses: ./.gitea/workflows/review-reusable.yml, dogfood self-caller). For the dogfood caller ./., vars.GADFLY_DEFAULT_MODELS etc. happen to resolve in steve/gadfly's own user/org scope — so the dogfood path works. But for every external consumer, vars.GADFLY_DEFAULT_MODELS, vars.GADFLY_DEFAULT_SPECIALISTS, vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY, vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY, and vars.GADFLY_ENDPOINT_RAGNAROS all resolve in the consumer's user/org scope, not steve/gadfly's. The PR's headline claim — "the owner retunes the whole fleet by editing ONE variable" — cannot be realized by an external consumer; that consumer must set each GADFLY_DEFAULT_* / GADFLY_ENDPOINT_RAGNAROS variable themselves. This contradicts the README "Central config via variables" section (README.md:321-337) and the CLAUDE.md description (CLAUDE.md:49-52), which both promise owner-controlled central config. The "central config via variables" mechanism is structurally broken as advertised for every non-self consumer.

  • .gitea/workflows/review-reusable.yml:130 — same scope bug for GADFLY_ENDPOINT_RAGNAROS is real, but the draft's supporting claim about the model is wrong. The ragnaros/qwen3.6-27b model is not in the new publicly-inherited default: the models: input default went from a 7-model csv to "". So a vanilla external consumer (no vars set) gets no ragnaros model and no ragnaros endpoint — consistent, not a runtime failure as the draft asserted. However, a consumer who follows the README and sets GADFLY_DEFAULT_MODELS to include ragnaros/qwen3.6-27b (the only way the ragnaros lane is reachable post-PR) but does not also set GADFLY_ENDPOINT_RAGNAROS will hit the failure the draft described: registerEnvProviders() in cmd/gadfly/model.go:179 skips the empty GADFLY_ENDPOINT_RAGNAROS entry (logs "missing base URL"), and majordomo.Parse("ragnaros/qwen3.6-27b") then fails because no ragnaros provider is registered. The two variables are coupled (one names the model, the other names the endpoint), but the workflow does not couple them and the docs do not warn about the coupling.

  • .gitea/workflows/review-reusable.yml:135-140 — fallback chain comment is misleading but not a bug. ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }} evaluates to the empty string when both sides are empty (not to the image default), and entrypoint.sh:156 (MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}") does the final ${VAR:-...} fallback to the in-image DEFAULT_MODELS="qwen3-coder:480b-cloud". The chain is unbroken; the inline comment on lines 52, 56, 57, 135-140 is misleading because it implies the workflow itself does the three-stage fallback. Worth a comment cross-reference to entrypoint.sh:156 but not a runtime bug.

🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/review-reusable.yml:27–31 — file's own header comment now contradicts the PR's headline guidance. The top-of-file comment still tells consumers "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)." But the PR's whole point (and the new "Rollout" paragraph in README.md:316–319) is that @v1 does not propagate because act_runners cache the file by ref, and consumers must pin @<sha>. The reusable's own doc block is now actively misleading — a consumer reading the top of the file will follow the wrong advice. Fix: collapse the header to the same @<sha>-only recommendation the README uses (or drop the old paragraph entirely since the new on: workflow_call comment block at lines 37–50 already covers it).

  • examples/reusable.yml:8–12,64–69 and examples/README.md:9 — stale "default swarm" copy in adjacent files. examples/reusable.yml still advertises that the reusable "ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite" (lines 8–12) and that "Cloud-only override so this works with just OLLAMA_CLOUD_API_KEY. Delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm" (lines 64–66). After this PR the input defaults are all "" (lines 52–57), so a consumer copying examples/reusable.yml and deleting the models: override (as the comment invites) will resolve through ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }} (line 135) → image default (one model, default suite), not the 7-model swarm the example promises. examples/README.md:9 makes the same claim in its table row. Both are now factually wrong about what "inheriting the default" means.

  • .gitea/workflows/adversarial-review.yml:6–8 and 51–54 — dogfood caller still describes the dropped default. Header comment (lines 6–8) says "It inherits the default swarm: 3 cloud models + Claude Code (sonnet, opus, opus:max), 5-lens suite" and the with: comment (lines 51–54) repeats "Inherit the default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5-lens suite) from review-reusable.yml". After this PR there is no such default in the reusable — it'd resolve to the image default. Worth updating for accuracy.

  • CLAUDE.md:50 — list mixes GADFLY_DEFAULT_* vars with GADFLY_ENDPOINT_RAGNAROS ambiguously. The line reads "owner user-scope var (GADFLY_DEFAULT_MODELS / _SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, + GADFLY_ENDPOINT_RAGNAROS) → image default", lumping a non-DEFAULT_ var in the middle without a separator. Confirmed against review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} is wired differently from the other four (no inputs.* fallback, only the user var), so a reader scanning CLAUDE.md can't tell that. README's table at lines 326–330 is clearer; a one-line rewrite to mirror it would suffice.

  • .gitea/workflows/review-reusable.yml:97 — image pin still references :sha-c342bdb. Not in the diff (pre-existing), and outside this PR's stated scope, but worth flagging since the PR explicitly says "consumers pin to this PR's merge @<sha>" while the reusable itself is pinned to a specific image sha that presumably also needs bumping for the new env-var resolution path to be exercised end-to-end. Makes the README's "central tuning propagates" claim unverifiable until the image rebuilds. Unverified on whether a rebuild is in flight.

Performance — No material issues found

I've verified the diff against the actual repository files: the workflow YAML, entrypoint.sh (provider-lane scheduling + provider_cap parsing), cmd/gadfly/main.go (lensConcurrency / providerOverride), cmd/gadfly/model.go (registerEnvProviders / endpointProvider), the examples stub, CLAUDE.md, and README.md. Here's the performance review.


VERDICT: No material issues found

Through the performance lens, this change is essentially neutral — possibly slightly positive — and introduces no new work in any hot path:

  • No new per-step / per-model / per-lens work. The GADFLY_MODELS/GADFLY_SPECIALISTS/GADFLY_PROVIDER_CONCURRENCY/GADFLY_PROVIDER_LENS_CONCURRENCY envs continue to flow into the same code paths in entrypoint.sh:156–176 and cmd/gadfly/main.go:281–302. provider_cap is still called once per provider lane (not per model), and lensConcurrency parses the env once per gadfly invocation (not per specialist). Nothing in the diff relocates parsing into a per-iteration site.
  • Gitea expressions inputs.X || vars.Y evaluate once per job render, not per step, so the new fallbacks don't add expression evaluation to a hot path.
  • The unset-var case is cheaper than before. When the consumer sets nothing and the owner sets nothing, GADFLY_PROVIDER_CONCURRENCY is empty; provider_cap short-circuits with DEFAULT_CONC=1 after a 1-token split (entrypoint.sh:169). Previously the same code parsed the hard-coded 3-entry "ollama-cloud=3,claude-code=3,ragnaros=1" map every run. Net: strictly fewer bash iterations in the public-consumer default path.
  • GADFLY_ENDPOINT_RAGNAROS empty-var path (model.go:181–202 + endpointProvider at model.go:207–220): an empty value trips the len(parts) < 2 branch, returns one error per binary invocation (logged to stderr), and the provider is never registered. This is a correctness/UX concern for the ragnaros/qwen3.6-27b model spec (it will fail to resolve), not a performance concern — and it's outside this lens anyway. Flagged here only because I noticed it; not a finding.
  • The "public default now runs sequentially" behavior (cap=1, lens=1) is the deliberate scope-reduction the PR description calls out ("a public consumer with neither still gets a sane minimal review"), not a perf regression — it's the intended policy. The owner regains the tuned swarm by setting the four GADFLY_DEFAULT_* vars, with no re-pin.
  • No allocation or copy shape changes in the loop bodies (run_lane in entrypoint.sh:206–218, runSpecialists in main.go:234–271). The arrays built from IFS=',' read -ra _raw are bounded by the configured model list; nothing in the diff enlarges that bound.

One adjacent note (not a finding, just verified-out): the input defaults were previously hard-coded csvs inside the workflow file. Moving them to owner vars does NOT change the parse cost in the hot path — it just changes who controls the string. So no N+1, no quadratic, no unbounded growth introduced.

🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

Findings (🧯 Error handling & edge cases lens):

  • .gitea/workflows/review-reusable.yml:135-140 — consumer can no longer force the image default. The new chain inputs.models || vars.GADFLY_DEFAULT_MODELS (and the analogues on lines 136/139/140) makes the owner var the second-priority tier. Once an owner sets GADFLY_DEFAULT_MODELS, a downstream consumer passing with: { models: "" } (or omitting with: entirely) silently gets the owner var rather than the image default (qwen3-coder:480b-cloud at entrypoint.sh:52, via the ${GADFLY_MODELS:-...$DEFAULT_MODELS} fallback at entrypoint.sh:156). The only escape hatch is for the consumer to pass a literal value. The README's "Central config via variables" section documents the priority order but doesn't flag this behavior change for consumers. A one-line note in that section ("with: { models: "" } now resolves to the owner var, not the image default — pass an explicit csv to override") would prevent surprise. (Verified at review-reusable.yml:135-140, entrypoint.sh:52 and :156, README L321-337.)

  • .gitea/workflows/review-reusable.yml:130 + 135GADFLY_DEFAULT_MODELS and GADFLY_ENDPOINT_RAGNAROS are two independent owner vars that must stay in sync. Before the PR, the ragnaros/qwen3.6-27b model and its GADFLY_ENDPOINT_RAGNAROS="llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa" lived together as a single literal in the workflow — moved together or not at all. They are now two separately-managed owner vars with no schema enforcing agreement. The failure mode I traced against the source: an owner sets GADFLY_DEFAULT_MODELS to include ragnaros/qwen3.6-27b but forgets GADFLY_ENDPOINT_RAGNAROS. Then:

    1. entrypoint.sh:156 launches the ragnaros/qwen3.6-27b lane unconditionally — no validation.
    2. In the binary, registerEnvProviders (cmd/gadfly/model.go:179-203) iterates os.Environ(). An unset vars.GADFLY_ENDPOINT_RAGNAROS is either absent from the environment or present-but-empty; either way endpointProvider("ragnaros", "") at model.go:207-220 returns the "missing base URL in \"\"" error from model.go:218-219. No provider is registered.
    3. That error is logged via fmt.Fprintln(os.Stderr, "gadfly: ignoring bad endpoint/alias:", err) at model.go:48 and swallowed — it doesn't fail the run.
    4. resolveModel then calls majordomo.Parse(buildSpec(provider, "ragnaros/qwen3.6-27b")). Since the model contains /, buildSpec (model.go:133-138) returns it verbatim, so majordomo sees ragnaros/qwen3.6-27b with no registered provider and returns an "unknown provider ragnaros" error.
    5. That error bubbles back through run.sh to entrypoint.sh:183 (|| log "model $1 failed (continuing)") — other lanes continue, but the ragnaros lens silently never runs and the swarm loses a model with no signal beyond a stderr line.

    Suggested mitigation: a small Gitea expression guard on review-reusable.yml:130/135 that fails the job when GADFLY_DEFAULT_MODELS contains ragnaros but vars.GADFLY_ENDPOINT_RAGNAROS is empty, or at minimum a README note in the table at L328-334 that these two vars must move together. Same "two values that must agree, no schema enforces it" class of bug, but the split is new in this PR. (Verified at review-reusable.yml:125-140, entrypoint.sh:144-218, cmd/gadfly/model.go:43-203. End-to-end behavior of vars.X expansion-to-empty-string under Gitea's runner was not exercised against a live runner, but the code path from os.Environ() through endpointProvider is unambiguous.)

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 6m 2s

<!-- gadfly-review:ollama:minimax-m3:cloud --> ### 🪰 Gadfly review — `minimax-m3:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> All claims in the draft review are confirmed against the actual code. Both findings survive verification. The first is a real concern with a clear, verifiable risk path (var → env → `endpointProvider` → `WithToken`); the second is explicitly self-rated as "not blocking" and the reviewer's verification of the "no shell-injection" claim is correct (the value flows as an env var, not a CLI arg). **VERDICT: Minor issues** Findings (🔒 Security lens): - **`.gitea/workflows/review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS` var format invites secret-injection into a non-secret store.** Confirmed: `cmd/gadfly/model.go:164` documents `GADFLY_ENDPOINT_<NAME> = "<provider>|<base-url>[|<key>]"` and `endpointProvider` at `:207-217` honors an optional 3rd positional `|key` field, threading it into `llamaswap.WithToken(key)` / `ollama.WithToken(key)` etc. at `:222-257`. The reusable now sources this value from `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` and the README example at `README.md:334` is keyless, but the *format itself* supports a token — and unlike `secrets.*`, a `vars.*` value is not masked in workflow logs and is intended for non-credential config. If an owner (or copy-pasted example) puts a key in the third segment, it is (1) stored as a plaintext user/org variable, (2) exported as a container env, and (3) passed verbatim to the provider's auth. The previous hardcoded literal `"llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"` was keyless by construction, so this PR regresses the threat model from "cannot accidentally embed a credential here" to "format silently permits it; storage is the wrong place". Fix: either (a) validate at the workflow level that the var contains exactly two pipe-separated fields and fail loudly otherwise, or (b) explicitly document and enforce in the inline comment that the `|key` third field is rejected for this var, and document a separate secret-backed path (e.g. `GADFLY_RAGNAROS_KEY`) as the bearer route. The README table at `README.md:328-334` should call out "no credential — use a separate secret if auth is needed". - **`.gitea/workflows/review-reusable.yml:135-140` — `inputs.X || vars.Y` precedence is silent on falsy non-empty values (correctness-adjacent).** Confirmed: Gitea Actions inherits GitHub Actions expression semantics where `||` is logical-OR on truthy/falsy, so `""` falls through to the var (the intended default-fallback path) but so does `"0"` or `"false"`. No exploit path — the resolved value is plumbed via `GADFLY_MODELS` / `GADFLY_SPECIALISTS` / `GADFLY_PROVIDER_CONCURRENCY` / `GADFLY_PROVIDER_LENS_CONCURRENCY` as env vars and consumed by `entrypoint.sh` / `scripts/run.sh` as env, not shell-interpolated command arguments (verified `entrypoint.sh:181` and `scripts/run.sh:165`). Flagged for documentation only; not blocking. - **No other issues in lens.** `workflow_call.secrets` is unchanged and still least-privilege (only the credentials the reviewer actually uses are declared, no `secrets: inherit`). The removal of the hardcoded LAN URL from the public default is the correct direction — no new SSRF surface is introduced; owners now point at their own endpoints by design. `GADFLY_API_KEY` and provider key envs continue to flow only through declared `secrets.*`, never `vars.*` (the only `vars.*` references introduced are the four `GADFLY_DEFAULT_*` config inputs and `GADFLY_ENDPOINT_RAGNAROS` — the latter is the one that supports an optional key in its format and is the subject of the first finding). </details> <details><summary><b>🎯 Correctness</b> — Blocking issues found</summary> **VERDICT: Blocking issues found** - **`.gitea/workflows/review-reusable.yml:130,135-140` — `vars.*` resolves in the CALLER's scope, not the reusable's.** Verified against `examples/reusable.yml:54` (`uses: steve/gadfly/.gitea/workflows/review-reusable.yml@v1`, external consumer) and `.gitea/workflows/adversarial-review.yml:43` (`uses: ./.gitea/workflows/review-reusable.yml`, dogfood self-caller). For the dogfood caller `./.`, `vars.GADFLY_DEFAULT_MODELS` etc. happen to resolve in `steve/gadfly`'s own user/org scope — so the dogfood path works. But for every external consumer, `vars.GADFLY_DEFAULT_MODELS`, `vars.GADFLY_DEFAULT_SPECIALISTS`, `vars.GADFLY_DEFAULT_PROVIDER_CONCURRENCY`, `vars.GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY`, and `vars.GADFLY_ENDPOINT_RAGNAROS` all resolve in the **consumer's** user/org scope, not `steve/gadfly`'s. The PR's headline claim — "the owner retunes the whole fleet by editing ONE variable" — cannot be realized by an external consumer; that consumer must set each `GADFLY_DEFAULT_*` / `GADFLY_ENDPOINT_RAGNAROS` variable themselves. This contradicts the README "Central config via variables" section (README.md:321-337) and the CLAUDE.md description (CLAUDE.md:49-52), which both promise owner-controlled central config. The "central config via variables" mechanism is structurally broken as advertised for every non-self consumer. - **`.gitea/workflows/review-reusable.yml:130` — same scope bug for `GADFLY_ENDPOINT_RAGNAROS` is real, but the draft's supporting claim about the model is wrong.** The `ragnaros/qwen3.6-27b` model is **not** in the new publicly-inherited default: the `models:` input default went from a 7-model csv to `""`. So a vanilla external consumer (no vars set) gets no ragnaros model and no ragnaros endpoint — consistent, not a runtime failure as the draft asserted. However, a consumer who follows the README and sets `GADFLY_DEFAULT_MODELS` to include `ragnaros/qwen3.6-27b` (the only way the ragnaros lane is reachable post-PR) but does **not** also set `GADFLY_ENDPOINT_RAGNAROS` will hit the failure the draft described: `registerEnvProviders()` in `cmd/gadfly/model.go:179` skips the empty `GADFLY_ENDPOINT_RAGNAROS` entry (logs "missing base URL"), and `majordomo.Parse("ragnaros/qwen3.6-27b")` then fails because no `ragnaros` provider is registered. The two variables are coupled (one names the model, the other names the endpoint), but the workflow does not couple them and the docs do not warn about the coupling. - **`.gitea/workflows/review-reusable.yml:135-140` — fallback chain comment is misleading but not a bug.** `${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}` evaluates to the empty string when both sides are empty (not to the image default), and `entrypoint.sh:156` (`MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"`) does the final `${VAR:-...}` fallback to the in-image `DEFAULT_MODELS="qwen3-coder:480b-cloud"`. The chain is unbroken; the inline comment on lines 52, 56, 57, 135-140 is misleading because it implies the workflow itself does the three-stage fallback. Worth a comment cross-reference to `entrypoint.sh:156` but not a runtime bug. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **VERDICT: Minor issues** - **`.gitea/workflows/review-reusable.yml:27–31` — file's own header comment now contradicts the PR's headline guidance.** The top-of-file comment still tells consumers *"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)."* But the PR's whole point (and the new "Rollout" paragraph in `README.md:316–319`) is that `@v1` does **not** propagate because act_runners cache the file by ref, and consumers must pin `@<sha>`. The reusable's own doc block is now actively misleading — a consumer reading the top of the file will follow the wrong advice. Fix: collapse the header to the same `@<sha>`-only recommendation the README uses (or drop the old paragraph entirely since the new `on: workflow_call` comment block at lines 37–50 already covers it). - **`examples/reusable.yml:8–12,64–69` and `examples/README.md:9` — stale "default swarm" copy in adjacent files.** `examples/reusable.yml` still advertises that the reusable *"ships a DEFAULT swarm: 3 cloud models + the Claude Code engine (sonnet/opus/opus:max), 5-lens suite"* (lines 8–12) and that *"Cloud-only override so this works with just OLLAMA_CLOUD_API_KEY. Delete this line (and forward CLAUDE_CODE_OAUTH_TOKEN above) to inherit the full default swarm"* (lines 64–66). After this PR the input defaults are all `""` (lines 52–57), so a consumer copying `examples/reusable.yml` and deleting the `models:` override (as the comment invites) will resolve through `${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}` (line 135) → image default (one model, default suite), not the 7-model swarm the example promises. `examples/README.md:9` makes the same claim in its table row. Both are now factually wrong about what "inheriting the default" means. - **`.gitea/workflows/adversarial-review.yml:6–8` and `51–54` — dogfood caller still describes the dropped default.** Header comment (lines 6–8) says *"It inherits the default swarm: 3 cloud models + Claude Code (sonnet, opus, opus:max), 5-lens suite"* and the `with:` comment (lines 51–54) repeats *"Inherit the default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5-lens suite) from review-reusable.yml"*. After this PR there is no such default in the reusable — it'd resolve to the image default. Worth updating for accuracy. - **`CLAUDE.md:50` — list mixes `GADFLY_DEFAULT_*` vars with `GADFLY_ENDPOINT_RAGNAROS` ambiguously.** The line reads *"owner user-scope var (GADFLY_DEFAULT_MODELS / _SPECIALISTS / _PROVIDER_CONCURRENCY / _PROVIDER_LENS_CONCURRENCY, + GADFLY_ENDPOINT_RAGNAROS) → image default"*, lumping a non-`DEFAULT_` var in the middle without a separator. Confirmed against `review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` is wired differently from the other four (no `inputs.*` fallback, only the user var), so a reader scanning CLAUDE.md can't tell that. README's table at lines 326–330 is clearer; a one-line rewrite to mirror it would suffice. - **`.gitea/workflows/review-reusable.yml:97` — image pin still references `:sha-c342bdb`.** Not in the diff (pre-existing), and outside this PR's stated scope, but worth flagging since the PR explicitly says "consumers pin to this PR's merge `@<sha>`" while the reusable itself is pinned to a specific image sha that presumably also needs bumping for the new env-var resolution path to be exercised end-to-end. Makes the README's "central tuning propagates" claim unverifiable until the image rebuilds. Unverified on whether a rebuild is in flight. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> I've verified the diff against the actual repository files: the workflow YAML, `entrypoint.sh` (provider-lane scheduling + `provider_cap` parsing), `cmd/gadfly/main.go` (`lensConcurrency` / `providerOverride`), `cmd/gadfly/model.go` (`registerEnvProviders` / `endpointProvider`), the examples stub, `CLAUDE.md`, and `README.md`. Here's the performance review. --- **VERDICT: No material issues found** Through the performance lens, this change is essentially neutral — possibly slightly positive — and introduces no new work in any hot path: - **No new per-step / per-model / per-lens work.** The `GADFLY_MODELS`/`GADFLY_SPECIALISTS`/`GADFLY_PROVIDER_CONCURRENCY`/`GADFLY_PROVIDER_LENS_CONCURRENCY` envs continue to flow into the same code paths in `entrypoint.sh:156–176` and `cmd/gadfly/main.go:281–302`. `provider_cap` is still called once per provider lane (not per model), and `lensConcurrency` parses the env once per `gadfly` invocation (not per specialist). Nothing in the diff relocates parsing into a per-iteration site. - **Gitea expressions `inputs.X || vars.Y` evaluate once per job render**, not per step, so the new fallbacks don't add expression evaluation to a hot path. - **The unset-var case is cheaper than before.** When the consumer sets nothing and the owner sets nothing, `GADFLY_PROVIDER_CONCURRENCY` is empty; `provider_cap` short-circuits with `DEFAULT_CONC=1` after a 1-token split (`entrypoint.sh:169`). Previously the same code parsed the hard-coded 3-entry `"ollama-cloud=3,claude-code=3,ragnaros=1"` map every run. Net: strictly fewer bash iterations in the public-consumer default path. - **`GADFLY_ENDPOINT_RAGNAROS` empty-var path** (`model.go:181–202` + `endpointProvider` at `model.go:207–220`): an empty value trips the `len(parts) < 2` branch, returns one error per binary invocation (logged to stderr), and the provider is never registered. This is a correctness/UX concern for the `ragnaros/qwen3.6-27b` model spec (it will fail to resolve), not a performance concern — and it's outside this lens anyway. Flagged here only because I noticed it; not a finding. - **The "public default now runs sequentially" behavior** (`cap=1, lens=1`) is the deliberate scope-reduction the PR description calls out ("a public consumer with neither still gets a sane minimal review"), not a perf regression — it's the intended policy. The owner regains the tuned swarm by setting the four `GADFLY_DEFAULT_*` vars, with no re-pin. - **No allocation or copy shape changes in the loop bodies** (`run_lane` in `entrypoint.sh:206–218`, `runSpecialists` in `main.go:234–271`). The arrays built from `IFS=',' read -ra _raw` are bounded by the configured model list; nothing in the diff enlarges that bound. One adjacent note (not a finding, just verified-out): the input defaults were previously hard-coded csvs inside the workflow file. Moving them to owner vars does NOT change the parse cost in the hot path — it just changes who controls the string. So no N+1, no quadratic, no unbounded growth introduced. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> ## VERDICT: Minor issues **Findings (🧯 Error handling & edge cases lens):** - **`.gitea/workflows/review-reusable.yml:135-140` — consumer can no longer force the image default.** The new chain `inputs.models || vars.GADFLY_DEFAULT_MODELS` (and the analogues on lines 136/139/140) makes the owner var the second-priority tier. Once an owner sets `GADFLY_DEFAULT_MODELS`, a downstream consumer passing `with: { models: "" }` (or omitting `with:` entirely) silently gets the owner var rather than the image default (`qwen3-coder:480b-cloud` at `entrypoint.sh:52`, via the `${GADFLY_MODELS:-...$DEFAULT_MODELS}` fallback at `entrypoint.sh:156`). The only escape hatch is for the consumer to pass a literal value. The README's "Central config via variables" section documents the priority order but doesn't flag this behavior change for consumers. A one-line note in that section ("`with: { models: "" }` now resolves to the owner var, not the image default — pass an explicit csv to override") would prevent surprise. (Verified at `review-reusable.yml:135-140`, `entrypoint.sh:52` and `:156`, README L321-337.) - **`.gitea/workflows/review-reusable.yml:130 + 135` — `GADFLY_DEFAULT_MODELS` and `GADFLY_ENDPOINT_RAGNAROS` are two independent owner vars that must stay in sync.** Before the PR, the `ragnaros/qwen3.6-27b` model and its `GADFLY_ENDPOINT_RAGNAROS="llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"` lived together as a single literal in the workflow — moved together or not at all. They are now two separately-managed owner vars with no schema enforcing agreement. The failure mode I traced against the source: an owner sets `GADFLY_DEFAULT_MODELS` to include `ragnaros/qwen3.6-27b` but forgets `GADFLY_ENDPOINT_RAGNAROS`. Then: 1. `entrypoint.sh:156` launches the `ragnaros/qwen3.6-27b` lane unconditionally — no validation. 2. In the binary, `registerEnvProviders` (`cmd/gadfly/model.go:179-203`) iterates `os.Environ()`. An unset `vars.GADFLY_ENDPOINT_RAGNAROS` is either absent from the environment or present-but-empty; either way `endpointProvider("ragnaros", "")` at `model.go:207-220` returns the `"missing base URL in \"\""` error from `model.go:218-219`. No provider is registered. 3. That error is logged via `fmt.Fprintln(os.Stderr, "gadfly: ignoring bad endpoint/alias:", err)` at `model.go:48` and **swallowed** — it doesn't fail the run. 4. `resolveModel` then calls `majordomo.Parse(buildSpec(provider, "ragnaros/qwen3.6-27b"))`. Since the model contains `/`, `buildSpec` (`model.go:133-138`) returns it verbatim, so majordomo sees `ragnaros/qwen3.6-27b` with no registered provider and returns an "unknown provider ragnaros" error. 5. That error bubbles back through `run.sh` to `entrypoint.sh:183` (`|| log "model $1 failed (continuing)"`) — other lanes continue, but the ragnaros lens silently never runs and the swarm loses a model with no signal beyond a stderr line. Suggested mitigation: a small Gitea expression guard on `review-reusable.yml:130/135` that fails the job when `GADFLY_DEFAULT_MODELS` contains `ragnaros` but `vars.GADFLY_ENDPOINT_RAGNAROS` is empty, or at minimum a README note in the table at L328-334 that these two vars must move together. Same "two values that must agree, no schema enforces it" class of bug, but the split is new in this PR. (Verified at `review-reusable.yml:125-140`, `entrypoint.sh:144-218`, `cmd/gadfly/model.go:43-203`. End-to-end behavior of `vars.X` expansion-to-empty-string under Gitea's runner was not exercised against a live runner, but the code path from `os.Environ()` through `endpointProvider` is unambiguous.) </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 6m 2s</sub>

🪰 Gadfly review — ragnaros/qwen3.6-27b (ragnaros)

Verdict: Minor issues · ⚠️ 2/5 lens(es) errored — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues found

Findings

1. GADFLY_ENDPOINT_RAGNAROS stored as vars. (not secrets.) can leak credentials in logs

  • .gitea/workflows/review-reusable.yml:130GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}

    The comment on line 126 documents the format as "<provider>|<base-url>[|<key>]" — meaning the variable can contain an API key/token. However, it is resolved from vars. (a Gitea variable), not secrets. Gitea variables are not masked in workflow logs the way secrets are. If the owner (or a consumer following the documented format) includes a |<key> component in this variable, that key will be printed in plain text in any workflow log that echoes or debug-prints env vars.

    Verified: cmd/gadfly/model.go:216 — the third pipe-separated component is parsed as key and passed to ollama.WithToken(key), llamaswap.WithToken(key), ollama.Foreman(..., key, ...), or openai.WithAPIKey(key) depending on provider. The code fully supports keys in this format.

    By contrast, GADFLY_ENDPOINT_M1 and GADFLY_ENDPOINT_M5 on lines 123–124 correctly use secrets., and examples/endpoint-aliases.yml:57 documents (use a secret) for endpoints with tokens. The RAGNAROS endpoint breaks this pattern.

    Suggested fix: If the endpoint value may ever contain a key, declare GADFLY_ENDPOINT_RAGNAROS in the secrets: block and use ${{ secrets.GADFLY_ENDPOINT_RAGNAROS }}. If it will never contain a key, document that explicitly to prevent future misuse.


2. No material issues outside the above.

The removal of the hardcoded LAN URL from the public reusable file (previously https://llama-swap.ragnaros.dudenhoeffer.casa at line 126 in the old code) is a security improvement — it prevents untrusted consumers from having a private LAN endpoint in their inherited defaults. The input-to-var-to-image-default cascade is properly scoped. Model/specialist inputs from consumers are self-targeted (a consumer attacking themselves via their own with: block is not a security boundary).

🎯 Correctness — No material issues found

VERDICT: No material issues found

Reviewed through the Correctness lens — focusing on logic bugs, incorrect behavior, and semantic/domain correctness of the change.

Verified findings (all clean):

  • Fallback chain correctness — Read entrypoint.sh lines 52, 156, 167–176. The resolution chain input → var → image default works correctly:

    • ${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }} — when both are empty (falsy), the env var is set to "".
    • In entrypoint.sh, MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}" — bash :- treats empty-as-unset, so it falls through to $DEFAULT_MODELS ("qwen3-coder:480b-cloud"). Confirmed at line 156.
    • provider_cap() at line 167: when GADFLY_PROVIDER_CONCURRENCY is empty, the IFS=',' read -ra produces one empty element; the loop finds no match and returns $DEFAULT_CONC (=1). Correct.
  • GADFLY_ENDPOINT_RAGNAROS removal of hardcoded value — Previously "llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa", now ${{ vars.GADFLY_ENDPOINT_RAGNAROS }} (line 130). No || fallback here — if the var is unset, the env is empty. This is intentional per the PR ("pulls the steve-specific swarm + the personal LAN endpoint out of the publicly-inherited default"), not a bug.

  • || operator in Gitea expressions — Verified existing usage at line 107 (github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number) already uses ||, confirming Gitea supports it and treats empty strings as falsy (standard expr behavior).

  • Documentation consistency — The README new section ("Central config via variables") matches the workflow comment block (lines 45–50) and the CLAUDE.md update. All describe the same resolution chain and the same five variables.

  • GADFLY_ENDPOINT_RAGNAROS format — Comment at line 126 states format "<provider>|<base-url>[|<key>]", matching the old hardcoded value's llamaswap|... format and the cmd/gadfly/model.go parser at line 184–185 (verified via grep). Consistent.

No logic bugs, incorrect fallbacks, or semantic mismatches detected in this change.

🧹 Code cleanliness & maintainability — Minor issues

Verdict: Minor issues found

All findings are documentation/comment consistency issues within the code cleanliness & maintainability lens. The examples and reusable header comments were not fully updated to match the new runtime-variable model, creating contradictory and misleading narratives.


  • .gitea/workflows/review-reusable.yml:29–31 — Stale header comment contradicts the PR's own body text.
    Lines 29–31 still say:

    Consumers should pin uses: ...@v1 — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits
    But lines 42–43 in the same file now say:
    a moved tag is NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache
    These two statements directly contradict each other. The whole point of this PR is that @v1 caching defeats retuning, yet the file header still recommends it. Fix: remove or rewrite lines 29–31 to align with the new guidance (pin @<sha> for structural changes).

  • examples/reusable.yml:8–12 — Outdated "DEFAULT swarm" header comments.
    Still says:

    The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine…
    After this PR, all config inputs default to "" (line 52–57). The swarm no longer "ships" from the reusable file — it comes from owner variables or the image default. Consumers copying this example will have a wrong mental model of how configuration works. Fix: rewrite the header to say the swarm is resolved at runtime from owner variables (mirroring the new reusable body comments on lines 37–50).

  • examples/reusable.yml:66 — Stale comment about the "full default swarm".
    Says:

    default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)
    This is no longer baked in — it's a variable-driven value. A consumer reading this won't know they need owner variables set to get this. Fix: update to reference the variable-driven resolution.

  • examples/README.md:9 — Table description still claims a hardcoded default swarm.
    Says:

    inherits its default swarm (3 cloud + Claude Code, 5-lens suite)
    Same staleness as above. Fix: update to say "config resolved at runtime from owner variables" (matching the new README section at line 321).

  • examples/reusable.yml:52–53 — Pinning guidance out of sync with README.
    Says @v1 is fine for "auto-updating stability," but the README (line 316–318) now explicitly warns that @v1 tags are not re-fetched by long-lived runners. Fix: update to recommend @<sha> for reliability, consistent with the new README guidance.

All five items verified by reading examples/reusable.yml (lines 1–70), examples/README.md (lines 1–27), and .gitea/workflows/review-reusable.yml (lines 25–50).

Performance⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 06:11:25.645)

🧯 Error handling & edge cases⚠️ could not complete

⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed
ragnaros/qwen3.6-27b: skipped (backed off until 06:11:25.645)

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 15m 22s

<!-- gadfly-review:ollama:ragnaros/qwen3.6-27b --> ### 🪰 Gadfly review — `ragnaros/qwen3.6-27b` (ragnaros) **Verdict: Minor issues · ⚠️ 2/5 lens(es) errored** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> ## VERDICT: Minor issues found ### Findings **1. `GADFLY_ENDPOINT_RAGNAROS` stored as `vars.` (not `secrets.`) can leak credentials in logs** - `.gitea/workflows/review-reusable.yml:130` — `GADFLY_ENDPOINT_RAGNAROS: ${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` The comment on line 126 documents the format as `"<provider>|<base-url>[|<key>]"` — meaning the variable *can* contain an API key/token. However, it is resolved from `vars.` (a Gitea variable), not `secrets.` Gitea variables are **not masked** in workflow logs the way secrets are. If the owner (or a consumer following the documented format) includes a `|<key>` component in this variable, that key will be printed in plain text in any workflow log that echoes or debug-prints env vars. Verified: `cmd/gadfly/model.go:216` — the third pipe-separated component is parsed as `key` and passed to `ollama.WithToken(key)`, `llamaswap.WithToken(key)`, `ollama.Foreman(..., key, ...)`, or `openai.WithAPIKey(key)` depending on provider. The code fully supports keys in this format. By contrast, `GADFLY_ENDPOINT_M1` and `GADFLY_ENDPOINT_M5` on lines 123–124 correctly use `secrets.`, and `examples/endpoint-aliases.yml:57` documents `(use a secret)` for endpoints with tokens. The `RAGNAROS` endpoint breaks this pattern. **Suggested fix**: If the endpoint value may ever contain a key, declare `GADFLY_ENDPOINT_RAGNAROS` in the `secrets:` block and use `${{ secrets.GADFLY_ENDPOINT_RAGNAROS }}`. If it will never contain a key, document that explicitly to prevent future misuse. --- **2. No material issues outside the above.** The removal of the hardcoded LAN URL from the public reusable file (previously `https://llama-swap.ragnaros.dudenhoeffer.casa` at line 126 in the old code) is a security *improvement* — it prevents untrusted consumers from having a private LAN endpoint in their inherited defaults. The input-to-var-to-image-default cascade is properly scoped. Model/specialist inputs from consumers are self-targeted (a consumer attacking themselves via their own `with:` block is not a security boundary). </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed through the **Correctness** lens — focusing on logic bugs, incorrect behavior, and semantic/domain correctness of the change. **Verified findings (all clean):** - **Fallback chain correctness** — Read `entrypoint.sh` lines 52, 156, 167–176. The resolution chain `input → var → image default` works correctly: - `${{ inputs.models || vars.GADFLY_DEFAULT_MODELS }}` — when both are empty (falsy), the env var is set to `""`. - In `entrypoint.sh`, `MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"` — bash `:-` treats empty-as-unset, so it falls through to `$DEFAULT_MODELS` (`"qwen3-coder:480b-cloud"`). Confirmed at line 156. - `provider_cap()` at line 167: when `GADFLY_PROVIDER_CONCURRENCY` is empty, the `IFS=',' read -ra` produces one empty element; the loop finds no match and returns `$DEFAULT_CONC` (=1). Correct. - **`GADFLY_ENDPOINT_RAGNAROS` removal of hardcoded value** — Previously `"llamaswap|https://llama-swap.ragnaros.dudenhoeffer.casa"`, now `${{ vars.GADFLY_ENDPOINT_RAGNAROS }}` (line 130). No `||` fallback here — if the var is unset, the env is empty. This is intentional per the PR ("pulls the steve-specific swarm + the personal LAN endpoint out of the publicly-inherited default"), not a bug. - **`||` operator in Gitea expressions** — Verified existing usage at line 107 (`github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number`) already uses `||`, confirming Gitea supports it and treats empty strings as falsy (standard `expr` behavior). - **Documentation consistency** — The README new section ("Central config via variables") matches the workflow comment block (lines 45–50) and the CLAUDE.md update. All describe the same resolution chain and the same five variables. - **`GADFLY_ENDPOINT_RAGNAROS` format** — Comment at line 126 states format `"<provider>|<base-url>[|<key>]"`, matching the old hardcoded value's `llamaswap|...` format and the `cmd/gadfly/model.go` parser at line 184–185 (verified via grep). Consistent. No logic bugs, incorrect fallbacks, or semantic mismatches detected in this change. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **Verdict: Minor issues found** All findings are documentation/comment consistency issues within the **code cleanliness & maintainability** lens. The examples and reusable header comments were not fully updated to match the new runtime-variable model, creating contradictory and misleading narratives. --- - **`.gitea/workflows/review-reusable.yml:29–31` — Stale header comment contradicts the PR's own body text.** Lines 29–31 still say: > *Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate releases, so central tuning here propagates without per-consumer edits* But lines 42–43 in the same file now say: > *a moved tag is NOT re-fetched; only a runtime value or a fresh `@<sha>` bypasses the cache* These two statements directly contradict each other. The whole point of this PR is that `@v1` caching defeats retuning, yet the file header still recommends it. **Fix:** remove or rewrite lines 29–31 to align with the new guidance (pin `@<sha>` for structural changes). - **`examples/reusable.yml:8–12` — Outdated "DEFAULT swarm" header comments.** Still says: > *The reusable ships a DEFAULT swarm: 3 cloud models + the Claude Code engine…* After this PR, all config inputs default to `""` (line 52–57). The swarm no longer "ships" from the reusable file — it comes from owner variables or the image default. Consumers copying this example will have a wrong mental model of how configuration works. **Fix:** rewrite the header to say the swarm is resolved at runtime from owner variables (mirroring the new reusable body comments on lines 37–50). - **`examples/reusable.yml:66` — Stale comment about the "full default swarm".** Says: > *default swarm (3 cloud + Claude Code sonnet/opus/opus:max, 5 lenses)* This is no longer baked in — it's a variable-driven value. A consumer reading this won't know they need owner variables set to get this. **Fix:** update to reference the variable-driven resolution. - **`examples/README.md:9` — Table description still claims a hardcoded default swarm.** Says: > *inherits its **default swarm** (3 cloud + Claude Code, 5-lens suite)* Same staleness as above. **Fix:** update to say "config resolved at runtime from owner variables" (matching the new README section at line 321). - **`examples/reusable.yml:52–53` — Pinning guidance out of sync with README.** Says `@v1` is fine for "auto-updating stability," but the README (line 316–318) now explicitly warns that `@v1` tags are *not* re-fetched by long-lived runners. **Fix:** update to recommend `@<sha>` for reliability, consistent with the new README guidance. All five items verified by reading `examples/reusable.yml` (lines 1–70), `examples/README.md` (lines 1–27), and `.gitea/workflows/review-reusable.yml` (lines 25–50). </details> <details><summary><b>⚡ Performance</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 06:11:25.645) </details> <details><summary><b>🧯 Error handling & edge cases</b> — ⚠️ could not complete</summary> ⚠️ This reviewer failed to complete: agent: step 0: all chain targets failed ragnaros/qwen3.6-27b: skipped (backed off until 06:11:25.645) </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 15m 22s</sub>
steve added 1 commit 2026-06-28 05:58:39 +00:00
feat(reusable): resolve swarm config from runtime variables (cache-immune central config)
Build & push image / build-and-push (pull_request) Successful in 5s
025485d76e
Long-lived act_runners cache the reusable workflow file by ref, so moving @v1
doesn't propagate config changes. Move the volatile config OUT of the cached
file and into runtime-injected user/org variables, which ARE re-read every run
(same mechanism as secrets):

- env now resolves: inputs.X || vars.GADFLY_DEFAULT_X  (input → owner var → image default)
  for models, specialists, provider_concurrency, provider_lens_concurrency.
- GADFLY_ENDPOINT_RAGNAROS (the 4090 Ti endpoint) now read from vars.GADFLY_ENDPOINT_RAGNAROS.
- input defaults dropped to "" so the public file default is generic (image default),
  which also removes the steve-specific swarm (incl. the personal LAN endpoint) from
  the publicly-inherited default — addresses the egress finding.

Net: owner retunes the whole fleet by editing one variable; consumers pin an
immutable @<sha> once and never re-pin for config tuning. README gets a "Central
config via variables" section; CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve force-pushed feat/vars-config from 5b1a912a9f to 025485d76e 2026-06-28 05:58:39 +00:00 Compare
steve merged commit 7bc3c982fa into main 2026-06-28 06:00:18 +00:00
steve deleted branch feat/vars-config 2026-06-28 06:00:18 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/gadfly#14