feat: Phase 4 — reusable "subscribe" workflow (+ dogfood it) #8

Merged
steve merged 2 commits from feat/phase4-reusable-wf into main 2026-06-27 23:42:02 +00:00
Owner

Phase 4 of the gadfly-games build — the last one.

Centralizes the ~90-line consumer stub into a reusable Gitea workflow so a repo can subscribe to Gadfly with a tiny caller. Feasibility was probe-verified on your act_runner first: workflow_call runs, secrets: inherit delivers the secret, and a fully-qualified owner/repo/path@ref resolves (both probes cleaned up).

What's here

  • .gitea/workflows/review-reusable.ymlon: workflow_call job holding the image pin + all env plumbing. Inputs (models, specialists, provider, concurrency, timeouts, allowed_users, …) default to "", so an empty value falls back to the image's own default — the caller overrides only what it wants. Provider secrets come via secrets: inherit (optional ones resolve empty when unset).
  • adversarial-review.yml — gadfly's own dogfood is now a thin caller of the reusable workflow. This proves it end-to-end (this PR's own review runs through it) and is the project's dogfooding ethos; safe because reviews are advisory.
  • examples/reusable.yml — the slim ~8-line consumer stub (uses: steve/gadfly/.gitea/workflows/review-reusable.yml@… + secrets: inherit).
  • README / examples index / CLAUDE.md document the subscribe path.

Known limitation

Consumers with arbitrary GADFLY_ENDPOINT_<NAME> secrets still need the full self-contained stub — a reusable workflow can't enumerate dynamic secret names (the common M1/M5 + provider keys are mapped). Documented.

Note

This PR's own review is the live test of the reusable path. If Gitea doesn't propagate the caller's github.event context into the reusable workflow, the review may not run (advisory — harmless, and I'll see it + fix). YAML validated; Go unchanged (build + test -race green).

🤖 Generated with Claude Code

**Phase 4** of the gadfly-games build — the last one. Centralizes the ~90-line consumer stub into a **reusable Gitea workflow** so a repo can subscribe to Gadfly with a tiny caller. Feasibility was probe-verified on your act_runner first: `workflow_call` runs, `secrets: inherit` delivers the secret, and a fully-qualified `owner/repo/path@ref` resolves (both probes cleaned up). ### What's here - **`.gitea/workflows/review-reusable.yml`** — `on: workflow_call` job holding the image pin + all env plumbing. Inputs (`models`, `specialists`, `provider`, concurrency, timeouts, `allowed_users`, …) default to `""`, so an empty value falls back to the image's own default — the caller overrides only what it wants. Provider secrets come via `secrets: inherit` (optional ones resolve empty when unset). - **`adversarial-review.yml`** — gadfly's own dogfood is now a **thin caller** of the reusable workflow. This **proves it end-to-end** (this PR's own review runs through it) and is the project's dogfooding ethos; safe because reviews are advisory. - **`examples/reusable.yml`** — the slim ~8-line consumer stub (`uses: steve/gadfly/.gitea/workflows/review-reusable.yml@… ` + `secrets: inherit`). - README / examples index / CLAUDE.md document the subscribe path. ### Known limitation Consumers with **arbitrary `GADFLY_ENDPOINT_<NAME>`** secrets still need the full self-contained stub — a reusable workflow can't enumerate dynamic secret names (the common M1/M5 + provider keys are mapped). Documented. ### Note This PR's own review is the live test of the reusable path. If Gitea doesn't propagate the caller's `github.event` context into the reusable workflow, the review may not run (advisory — harmless, and I'll see it + fix). YAML validated; Go unchanged (build + `test -race` green). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 1 commit 2026-06-27 23:14:22 +00:00
feat: Phase 4 — reusable workflow ("subscribe") + dogfood it
Build & push image / build-and-push (pull_request) Successful in 5s
Adversarial Review (Gadfly) / review (pull_request) Successful in 14m49s
0a01c3ae91
Centralizes the ~90-line consumer stub into a reusable Gitea workflow so a
repo can subscribe to Gadfly with a tiny caller. Feasibility was probe-
verified on this act_runner: workflow_call runs, secrets: inherit
delivers, and a fully-qualified owner/repo/path@ref resolves.

- .gitea/workflows/review-reusable.yml: `on: workflow_call` job holding
  the image pin + all env plumbing. Inputs (models/specialists/provider/
  concurrency/timeouts/allowed_users/…) default to "" so an empty value
  falls back to the image's own default — caller overrides only what it
  wants. Secrets via `secrets: inherit` (optional ones resolve empty).
- adversarial-review.yml: gadfly's own dogfood is now a thin CALLER of the
  reusable (proves it end-to-end; advisory so safe to dogfood).
- examples/reusable.yml: the slim ~8-line consumer stub.
- README / examples/README / CLAUDE.md document the subscribe path.

Caveat: consumers with arbitrary GADFLY_ENDPOINT_<NAME>s still need the
full stub (a reusable workflow can't enumerate dynamic secret names).
YAML validated; Go unchanged (build + test green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

🪰 Gadfly — live review status

9/9 reviewers finished · updated 2026-06-27 23:29:11Z

claude-code/opus · claude-code — done

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

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

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

claude-code/sonnet · claude-code — done

  • security — Minor issues
  • correctness — Minor issues
  • error-handling — Minor issues

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

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

glm-5.1:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Minor issues
  • error-handling — Minor issues

glm-5.2:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — Minor issues
  • error-handling — Minor issues

minimax-m3:cloud · ollama-cloud — done

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

nemotron-3-super:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • error-handling — No material issues found

qwen3-coder:480b-cloud · ollama-cloud — done

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

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 9/9 reviewers finished · updated 2026-06-27 23:29:11Z #### `claude-code/opus` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Minor issues - ✅ **error-handling** — No material issues found #### `claude-code/opus:max` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **error-handling** — No material issues found #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **error-handling** — Minor issues #### `deepseek-v4-pro:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **error-handling** — No material issues found #### `glm-5.1:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **error-handling** — Minor issues #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — Minor issues - ✅ **error-handling** — Minor issues #### `minimax-m3:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — Blocking issues found - ✅ **error-handling** — Minor issues #### `nemotron-3-super:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **error-handling** — No material issues found #### `qwen3-coder:480b-cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **error-handling** — Minor issues <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

Now I have all the information I need. Let me assess each finding:

Finding 1: examples/reusable.yml:43@main mutable ref with secrets: inherit

  • Confirmed: Line 43 uses @main, and secrets: inherit is on line 44.
  • The comment on line 10 says "Pin @ to a Gadfly tag/branch."
  • The draft correctly identifies the supply-chain risk. This is a real, confirmed finding.

Finding 2: review-reusable.yml — no permissions: block

  • Confirmed: I searched for permissions in review-reusable.yml and found no matches. The reusable workflow indeed lacks a permissions: block.
  • However, in Gitea Actions (and GitHub Actions), reusable workflows called with uses: inherit the permissions from the calling workflow unless the reusable workflow itself specifies permissions:. The callers (adversarial-review.yml and examples/reusable.yml) both set permissions: {contents: read, issues: write, pull-requests: write}.
  • The draft notes this is a "defense-in-depth gap" — a future caller that omits permissions would run with defaults. This is a valid point. The finding is confirmed.

Finding 3: secrets: inherit passes all caller secrets without explicit declaration

  • Confirmed: The reusable workflow uses secrets: inherit (both in the dogfood caller and the example), and the workflow_call: section has no secrets: declarations — it only has inputs:.
  • The draft acknowledges this is a "deliberate convenience trade-off" and documents the rationale. The finding is noted as "No action required."
  • The comment in the reusable workflow (lines 13-18) explicitly explains why secrets: inherit was chosen and that undefined secrets resolve to empty. The dynamic secret name issue is also documented in the comment about GADFLY_ENDPOINT_<NAME>.
  • This is an accurate observation but it's already documented and deliberate. The finding is confirmed but is truly informational.

All three findings are confirmed against the actual code. Let me output the corrected review.VERDICT: Minor issues

  • examples/reusable.yml:43@main mutable ref with secrets: inherit. The example uses uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main, a mutable reference that receives all of the caller's secrets via secrets: inherit (line 44). If the gadfly repo's main branch is compromised, an attacker could modify the reusable workflow to exfiltrate every secret from every consumer. The comment on line 10 says "Pin @ to a Gadfly tag/branch," but the actual code defaults to @main. Most users copy-paste examples without reading inline comments. Suggest changing the example to use a pinned SHA/tag as the default (e.g., @v1 or @sha-c342bdb) and demoting @main to a comment, or adding a prominent WARNING comment about the supply-chain risk of an un-pinned ref combined with secrets: inherit.

  • review-reusable.yml — no permissions: block (defense-in-depth gap). The reusable workflow (lines 42-84) lacks an explicit permissions: block. Both existing callers (adversarial-review.yml line 22 and examples/reusable.yml line 27) set permissions: {contents: read, issues: write, pull-requests: write}, which scopes GITEA_TOKEN correctly. However, the reusable workflow itself doesn't enforce this — a future caller that omits permissions would run with the Gitea default (potentially write-all). Adding permissions: directly to the reusable job would provide belt-and-suspenders least-privilege, ensuring GITEA_TOKEN is always scoped regardless of caller configuration.

  • review-reusable.ymlsecrets: inherit passes all caller secrets without explicit declaration. The reusable workflow accesses secrets exclusively through secrets: inherit, meaning it receives every org/repo secret the caller has — not just the ~8 it actually maps as env vars (lines 60-72). The workflow_call: section (lines 26-40) declares inputs: but no secrets:, so there is no enumerated contract limiting which secrets are received. The alternative (declaring explicit secrets: under workflow_call:) would limit the blast radius if the gadfly image or reusable YAML is ever compromised. The comments (lines 13-18) acknowledge this as a deliberate convenience trade-off for dynamic secret names, and the GADFLY_ENDPOINT_<NAME> limitation is documented (lines 65-67). No action required, but any future secret-mapping additions expand the trust surface — explicit declarations would enforce least-privilege by default.

🎯 Correctness — Minor issues

All five findings check out against the actual code. Let me verify one detail — whether the specialist_suite comment already addresses the "worth a comment" suggestion:

Line 34 already has # reserved / future, so the suggestion to add a comment is already partially satisfied, though the input still silently discards values since there's no GADFLY_SPECIALIST_SUITE env mapping.

All findings confirmed. Verdict remains "Minor issues."

VERDICT: Minor issues

- **`review-reusable.yml:34``specialist_suite` input is declared but never wired to any env var.** The input `specialist_suite` (line 34) has no corresponding `GADFLY_SPECIALIST_SUITE` env mapping in the `env:` block (lines 74–84). It's labeled `# reserved / future`, so this is intentional — but if a consumer passes it today, the value is silently discarded. Not blocking, but worth either a `GADFLY_SPECIALIST_SUITE` env passthrough to make the intent explicit, or removing it until it's wired.

- **`adversarial-review.yml:42` — Local `uses:` path works for same-repo dogfooding but the example at `examples/reusable.yml:43` uses cross-repo `uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main`.** These are two fundamentally different resolution paths. The local `./.gitea/workflows/review-reusable.yml` (no `@ref`) always resolves at the caller's current commit; the cross-repo path resolves at the specified ref. This is correct for each respective use case — just noting it's intentional and verified.

- **`review-reusable.yml:53``github.event.pull_request.number` etc. in a `workflow_call` reusable workflow.** In Gitea's implementation of reusable workflows (`workflow_call`), the `github` context is propagated from the caller. The expressions on lines 50–58 all use `github.*` context that depend on caller propagation. This is semantically correct *if* Gitea propagates them — which the PR author has probe-verified. No bug per se, but the `PR` fallback chain (`github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number`) depends on this behavior.

- **`review-reusable.yml:40``timeout_minutes` defaults to `30` (a `number` type) but `adversarial-review.yml:52` passes `90` without quotes.** Gitea Actions (following GitHub Actions) treats `with:` number inputs as numbers, so passing `90` (unquoted) is correct. All other inputs are `type: string` with default `""`. When a consumer omits an input, it resolves to the empty string `""`, which becomes e.g. `GADFLY_MODELS: ""` in the env. The entrypoint.sh line `MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"` (line 156) uses `:-` (which treats empty as unset), so empty string correctly falls back to defaults. ✓ This is correct.

- **`review-reusable.yml:68-69` — Hardcoded `GADFLY_ENDPOINT_M1` / `GADFLY_ENDPOINT_M5` secrets.** These are specific to the gadfly repo's own infrastructure (the author's Mac Minis). A consumer who doesn't set these secrets will have them resolve to empty, which is harmless — the Go code only registers endpoints for non-empty `GADFLY_ENDPOINT_*` values. However, these two endpoint names are opinionated for a *reusable* workflow meant for arbitrary consumers. The comment at lines 65–67 acknowledges this ("Consumers with other GADFLY_ENDPOINT_<NAME>s need the full stub"). This is a design choice, not a bug, but worth noting that the reusable workflow cannot support arbitrary `GADFLY_ENDPOINT_*` names (documented limitation).

No blocking correctness issues found. The empty-string-to-default fallback pattern is correctly implemented throughout, the `github.*` context propagation is a known Gitea behavior that the author has verified, and the input-to-env mapping is complete for all non-reserved inputs.
🧯 Error handling & edge cases — Minor issues

Now I have enough information to verify both findings.

Finding 1 (specialist_suite not wired): Confirmed. The input is declared at line 34 with comment "reserved / future", but there is no GADFLY_SPECIALIST_SUITE env var in the env block (lines 73–84). A grep for GADFLY_SPECIALIST_SUITE across the entire repo returns zero results. A caller who sets specialist_suite via with: gets no effect and no error.

Finding 2 (timeout_minutes default): The draft claims "the old self-contained stubs all used 90." This is false. All five existing example stubs use timeout-minutes: 30 (verified: adversarial-review.yml:44, claude-code.yml:45, endpoint-aliases.yml:48, local-ollama.yml:39, openai-compatible.yml:41). The only workflow that ever used 90 was gadfly's own adversarial-review.yml, and it still does (via with: timeout_minutes: 90). The reusable workflow's default of 30 matches the established convention in all examples, so there is no regression. This finding's core claim is factually wrong and must be dropped.

VERDICT: Minor issues

  • review-reusable.yml:34specialist_suite input is accepted but never wired to an env var. The input is declared with type: string, default: "" and the comment says "reserved / future", but it's passed through the with: interface and then silently dropped — there's no GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }} in the env block. A caller who sets specialist_suite gets no error and no effect, which is a swallowed/no-op config path. If it's truly reserved, consider either wiring it (even to a no-op env var the image can later consume) or removing it from the interface until it's functional, so callers don't form incorrect expectations.

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

<!-- gadfly-review:ollama:glm-5.1:cloud --> ### 🪰 Gadfly review — `glm-5.1:cloud` (ollama-cloud) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Now I have all the information I need. Let me assess each finding: **Finding 1: `examples/reusable.yml:43` — `@main` mutable ref with `secrets: inherit`** - Confirmed: Line 43 uses `@main`, and `secrets: inherit` is on line 44. - The comment on line 10 says "Pin @<ref> to a Gadfly tag/branch." - The draft correctly identifies the supply-chain risk. This is a real, confirmed finding. **Finding 2: `review-reusable.yml` — no `permissions:` block** - Confirmed: I searched for `permissions` in `review-reusable.yml` and found no matches. The reusable workflow indeed lacks a `permissions:` block. - However, in Gitea Actions (and GitHub Actions), reusable workflows called with `uses:` inherit the permissions from the calling workflow unless the reusable workflow itself specifies `permissions:`. The callers (`adversarial-review.yml` and `examples/reusable.yml`) both set `permissions: {contents: read, issues: write, pull-requests: write}`. - The draft notes this is a "defense-in-depth gap" — a future caller that omits `permissions` would run with defaults. This is a valid point. The finding is confirmed. **Finding 3: `secrets: inherit` passes all caller secrets without explicit declaration** - Confirmed: The reusable workflow uses `secrets: inherit` (both in the dogfood caller and the example), and the `workflow_call:` section has no `secrets:` declarations — it only has `inputs:`. - The draft acknowledges this is a "deliberate convenience trade-off" and documents the rationale. The finding is noted as "No action required." - The comment in the reusable workflow (lines 13-18) explicitly explains why `secrets: inherit` was chosen and that undefined secrets resolve to empty. The dynamic secret name issue is also documented in the comment about `GADFLY_ENDPOINT_<NAME>`. - This is an accurate observation but it's already documented and deliberate. The finding is confirmed but is truly informational. All three findings are confirmed against the actual code. Let me output the corrected review.</think>**VERDICT: Minor issues** - **`examples/reusable.yml:43` — `@main` mutable ref with `secrets: inherit`**. The example uses `uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main`, a mutable reference that receives **all** of the caller's secrets via `secrets: inherit` (line 44). If the gadfly repo's `main` branch is compromised, an attacker could modify the reusable workflow to exfiltrate every secret from every consumer. The comment on line 10 says "Pin @<ref> to a Gadfly tag/branch," but the actual code defaults to `@main`. Most users copy-paste examples without reading inline comments. Suggest changing the example to use a pinned SHA/tag as the default (e.g., `@v1` or `@sha-c342bdb`) and demoting `@main` to a comment, or adding a prominent WARNING comment about the supply-chain risk of an un-pinned ref combined with `secrets: inherit`. - **`review-reusable.yml` — no `permissions:` block (defense-in-depth gap)**. The reusable workflow (lines 42-84) lacks an explicit `permissions:` block. Both existing callers (`adversarial-review.yml` line 22 and `examples/reusable.yml` line 27) set `permissions: {contents: read, issues: write, pull-requests: write}`, which scopes `GITEA_TOKEN` correctly. However, the reusable workflow itself doesn't enforce this — a future caller that omits `permissions` would run with the Gitea default (potentially write-all). Adding `permissions:` directly to the reusable job would provide belt-and-suspenders least-privilege, ensuring `GITEA_TOKEN` is always scoped regardless of caller configuration. - **`review-reusable.yml` — `secrets: inherit` passes all caller secrets without explicit declaration**. The reusable workflow accesses secrets exclusively through `secrets: inherit`, meaning it receives *every* org/repo secret the caller has — not just the ~8 it actually maps as env vars (lines 60-72). The `workflow_call:` section (lines 26-40) declares `inputs:` but no `secrets:`, so there is no enumerated contract limiting which secrets are received. The alternative (declaring explicit `secrets:` under `workflow_call:`) would limit the blast radius if the gadfly image or reusable YAML is ever compromised. The comments (lines 13-18) acknowledge this as a deliberate convenience trade-off for dynamic secret names, and the `GADFLY_ENDPOINT_<NAME>` limitation is documented (lines 65-67). No action required, but any future secret-mapping additions expand the trust surface — explicit declarations would enforce least-privilege by default. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> All five findings check out against the actual code. Let me verify one detail — whether the `specialist_suite` comment already addresses the "worth a comment" suggestion: Line 34 already has `# reserved / future`, so the suggestion to add a comment is already partially satisfied, though the input still silently discards values since there's no `GADFLY_SPECIALIST_SUITE` env mapping. All findings confirmed. Verdict remains "Minor issues." ```markdown VERDICT: Minor issues - **`review-reusable.yml:34` — `specialist_suite` input is declared but never wired to any env var.** The input `specialist_suite` (line 34) has no corresponding `GADFLY_SPECIALIST_SUITE` env mapping in the `env:` block (lines 74–84). It's labeled `# reserved / future`, so this is intentional — but if a consumer passes it today, the value is silently discarded. Not blocking, but worth either a `GADFLY_SPECIALIST_SUITE` env passthrough to make the intent explicit, or removing it until it's wired. - **`adversarial-review.yml:42` — Local `uses:` path works for same-repo dogfooding but the example at `examples/reusable.yml:43` uses cross-repo `uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main`.** These are two fundamentally different resolution paths. The local `./.gitea/workflows/review-reusable.yml` (no `@ref`) always resolves at the caller's current commit; the cross-repo path resolves at the specified ref. This is correct for each respective use case — just noting it's intentional and verified. - **`review-reusable.yml:53` — `github.event.pull_request.number` etc. in a `workflow_call` reusable workflow.** In Gitea's implementation of reusable workflows (`workflow_call`), the `github` context is propagated from the caller. The expressions on lines 50–58 all use `github.*` context that depend on caller propagation. This is semantically correct *if* Gitea propagates them — which the PR author has probe-verified. No bug per se, but the `PR` fallback chain (`github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number`) depends on this behavior. - **`review-reusable.yml:40` — `timeout_minutes` defaults to `30` (a `number` type) but `adversarial-review.yml:52` passes `90` without quotes.** Gitea Actions (following GitHub Actions) treats `with:` number inputs as numbers, so passing `90` (unquoted) is correct. All other inputs are `type: string` with default `""`. When a consumer omits an input, it resolves to the empty string `""`, which becomes e.g. `GADFLY_MODELS: ""` in the env. The entrypoint.sh line `MODELS="${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}"` (line 156) uses `:-` (which treats empty as unset), so empty string correctly falls back to defaults. ✓ This is correct. - **`review-reusable.yml:68-69` — Hardcoded `GADFLY_ENDPOINT_M1` / `GADFLY_ENDPOINT_M5` secrets.** These are specific to the gadfly repo's own infrastructure (the author's Mac Minis). A consumer who doesn't set these secrets will have them resolve to empty, which is harmless — the Go code only registers endpoints for non-empty `GADFLY_ENDPOINT_*` values. However, these two endpoint names are opinionated for a *reusable* workflow meant for arbitrary consumers. The comment at lines 65–67 acknowledges this ("Consumers with other GADFLY_ENDPOINT_<NAME>s need the full stub"). This is a design choice, not a bug, but worth noting that the reusable workflow cannot support arbitrary `GADFLY_ENDPOINT_*` names (documented limitation). No blocking correctness issues found. The empty-string-to-default fallback pattern is correctly implemented throughout, the `github.*` context propagation is a known Gitea behavior that the author has verified, and the input-to-env mapping is complete for all non-reserved inputs. ``` </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> Now I have enough information to verify both findings. **Finding 1 (specialist_suite not wired):** Confirmed. The input is declared at line 34 with comment "reserved / future", but there is no `GADFLY_SPECIALIST_SUITE` env var in the env block (lines 73–84). A grep for `GADFLY_SPECIALIST_SUITE` across the entire repo returns zero results. A caller who sets `specialist_suite` via `with:` gets no effect and no error. **Finding 2 (timeout_minutes default):** The draft claims "the old self-contained stubs all used 90." This is **false**. All five existing example stubs use `timeout-minutes: 30` (verified: `adversarial-review.yml:44`, `claude-code.yml:45`, `endpoint-aliases.yml:48`, `local-ollama.yml:39`, `openai-compatible.yml:41`). The only workflow that ever used 90 was gadfly's own `adversarial-review.yml`, and it still does (via `with: timeout_minutes: 90`). The reusable workflow's default of 30 matches the established convention in all examples, so there is no regression. This finding's core claim is factually wrong and must be dropped. **VERDICT: Minor issues** - **`review-reusable.yml:34` — `specialist_suite` input is accepted but never wired to an env var.** The input is declared with `type: string, default: ""` and the comment says "reserved / future", but it's passed through the `with:` interface and then silently dropped — there's no `GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }}` in the env block. A caller who sets `specialist_suite` gets no error and no effect, which is a swallowed/no-op config path. If it's truly reserved, consider either wiring it (even to a no-op env var the image can later consume) or removing it from the interface until it's functional, so callers don't form incorrect expectations. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 3m 8s</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — No material issues found

I've verified the security-critical paths. Here's my review.


VERDICT: No material issues found (Security lens)

This change is a structural refactor — it moves the image-pin and env plumbing into a workflow_call reusable workflow and turns adversarial-review.yml into a thin caller. I traced the trust boundary that actually matters (who can trigger a secret-bearing run) and it is preserved.

What I verified:

  • Actor gate survives the move. The comment-trigger gate (github.actor ∈ steve/fizi/dazed) stays on the caller job in .gitea/workflows/adversarial-review.yml:36-39, which now does uses: ./.gitea/workflows/review-reusable.yml. A reusable-workflow call is gated by the calling job's if:, so an untrusted issue_comment is dropped before the reusable (and thus the secret-bearing container) ever runs. No regression vs. the old inline gate.

  • Empty allowed_users default is fail-safe, not fail-open. The reusable defaults every input — including allowed_users — to "" (review-reusable.yml:38), so GADFLY_ALLOWED_USERS can reach the container empty. I checked entrypoint.sh:68-83: with an empty allow-list, actor_allowed() falls back to a Gitea collaborator (write-access) check (/collaborators/${actor} → HTTP 204), not to allowing everyone. So even a consumer who omits the job-level if: gate still only lets write-access users comment-trigger. Good insecure-default avoidance.

  • secrets: inherit does not broaden exposure. The reusable references only named secrets (GITEA_TOKEN, provider keys, GADFLY_ENDPOINT_M1/M5, findings URL/token). inherit passes the caller's existing secrets; for a fork PR no secrets are available to inherit, matching the prior behavior. Optional secrets resolve to empty when unset.

  • No new injection vector. The untrusted-input env vars (COMMENT_BODY, ACTOR, PR_BRANCH) are copied verbatim from the old workflow. COMMENT_BODY is only glob-matched in a case (entrypoint.sh:95-98) and ACTOR only interpolated into a Gitea API path for a username — both unchanged from main and neither eval'd.

One non-blocking observation (verified, not a defect): splitting the gate (now in the consumer's caller file) from the run (in the reusable) makes it easier for a consumer to forget the job-level if: than when both lived in one file. The in-container collaborator fallback above backstops this, so the worst case is limited to write-access users — acceptable. The examples/reusable.yml template correctly ships both the if: gate and allowed_users: (belt-and-suspenders), which is the right guidance to keep.

Nothing in the security lens blocks this PR.

🎯 Correctness — Minor issues

Both files match the diff exactly. I've confirmed:

  • The reusable workflow (review-reusable.yml:75) reads GADFLY_SPECIALISTS only from ${{ inputs.specialists }} — there is no vars.GADFLY_SPECIALISTS fallback anywhere in it.
  • The new caller (adversarial-review.yml:46) hardcodes specialists: "security,correctness,error-handling".
  • The old inline stub (per the diff) used ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}.

So the repo-var override path is genuinely removed for gadfly's own repo — the minor finding holds against the actual code. The behavior-preservation and "empty ⇒ image default" claims are background, not findings, and don't change the verdict.


VERDICT: Minor issues

  • .gitea/workflows/adversarial-review.yml:46 — The old stub set GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}, letting the repo var GADFLY_SPECIALISTS override the lens suite without editing the workflow. The new caller hardcodes specialists: "security,correctness,error-handling", and the reusable workflow (review-reusable.yml:75) reads only ${{ inputs.specialists }} with no vars.GADFLY_SPECIALISTS path, so that override is silently lost for gadfly's own repo. Impact is small and likely intentional (config simplification for the dogfood caller), but it's a real behavior change. If the override is meant to survive, the caller should pass specialists: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}.
🧯 Error handling & edge cases — No material issues found

I've verified the central edge-case risk in this change. Since the diff is config plumbing, the one real error-handling concern it introduces is that the reusable workflow always sets the config env vars — to "" when the caller doesn't override — whereas a hand-written stub previously left them absent. So the whole design rests on "empty string is treated identically to unset" everywhere those vars are consumed. I checked each consumer:

  • cmd/gadfly/main.go:450 envInt — empty → default; also guards n <= 0. ✓ (covers GADFLY_TIMEOUT_SECS, GADFLY_MAX_STEPS)
  • cmd/gadfly/main.go:291 providerOverride — empty splits to a single un-Cut-able item → (0,false) fallback. ✓ (GADFLY_PROVIDER_CONCURRENCY/..._LENS_CONCURRENCY)
  • cmd/gadfly/specialists.go:136 — gated on TrimSpace(...) != "", so empty → default suite. ✓
  • cmd/gadfly/model.go:55,59,115TrimSpace + != "" checks for GADFLY_PROVIDER/GADFLY_BASE_URL/GADFLY_WORKER_MODEL. ✓
  • entrypoint.sh:53,71,156,159${VAR:-default} and [ -n ... ] treat empty as unset for GADFLY_TRIGGER_PHRASE, GADFLY_ALLOWED_USERS, GADFLY_MODELS, GADFLY_PROVIDER. ✓

The "empty ⇒ image default" contract the PR depends on holds at every read site I could find. The unhappy paths the change actually touches (missing required event context) are unchanged from before: entrypoint.sh:60-63 still :?-exits on absent PR/EVENT_NAME, which is a config-error exit explicitly permitted by goal #2 — not introduced here.


VERDICT: No material issues found

Through the error-handling & edge-cases lens, this change is clean:

  • Empty-input contract is sound (verified). Every env var the reusable workflow newly forces to "" is read through a path that treats empty as "use default" (envInt, providerOverride, TrimSpace != "" guards, shell :-/-n). No empty value reaches a strconv.Atoi/parse that would error or yield a degenerate 0. The one numeric input rendered directly in YAML, timeout_minutes (review-reusable.yml:31 → used at :37), is type: number, default: 30, so it can't arrive empty.
  • No new swallowed errors or missing cleanup — this is declarative workflow wiring; the binary's existing per-lens isolation (reviewWithSpecialist, main.go:304-309) and entrypoint's || log "... (continuing)" fault tolerance are untouched.

One adjacent observation I could not verify and am therefore not filing as a finding: the PR's own "Note" flags that Gitea may not propagate the caller's github.event into the reusable workflow. If it doesn't, PR/EVENT_NAME resolve empty and entrypoint.sh:60-63 exits 1 — but that's a permitted config-error exit, advisory, and on a non-required check, so even in the worst case it doesn't violate the never-block invariant. Whether the propagation actually works is a behavior/CI question outside my lens (and the author already plans to confirm it via this PR's live run).

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

<!-- gadfly-review:ollama:claude-code/opus --> ### 🪰 Gadfly review — `claude-code/opus` (claude-code) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> I've verified the security-critical paths. Here's my review. --- **VERDICT: No material issues found** (Security lens) This change is a structural refactor — it moves the image-pin and env plumbing into a `workflow_call` reusable workflow and turns `adversarial-review.yml` into a thin caller. I traced the trust boundary that actually matters (who can trigger a secret-bearing run) and it is preserved. **What I verified:** - **Actor gate survives the move.** The comment-trigger gate (`github.actor` ∈ steve/fizi/dazed) stays on the *caller* job in `.gitea/workflows/adversarial-review.yml:36-39`, which now does `uses: ./.gitea/workflows/review-reusable.yml`. A reusable-workflow call is gated by the calling job's `if:`, so an untrusted `issue_comment` is dropped before the reusable (and thus the secret-bearing container) ever runs. No regression vs. the old inline gate. - **Empty `allowed_users` default is fail-safe, not fail-open.** The reusable defaults every input — including `allowed_users` — to `""` (`review-reusable.yml:38`), so `GADFLY_ALLOWED_USERS` can reach the container empty. I checked `entrypoint.sh:68-83`: with an empty allow-list, `actor_allowed()` falls back to a Gitea **collaborator (write-access) check** (`/collaborators/${actor}` → HTTP 204), not to allowing everyone. So even a consumer who omits the job-level `if:` gate still only lets write-access users comment-trigger. Good insecure-default avoidance. - **`secrets: inherit` does not broaden exposure.** The reusable references only named secrets (`GITEA_TOKEN`, provider keys, `GADFLY_ENDPOINT_M1/M5`, findings URL/token). `inherit` passes the caller's existing secrets; for a fork PR no secrets are available to inherit, matching the prior behavior. Optional secrets resolve to empty when unset. - **No new injection vector.** The untrusted-input env vars (`COMMENT_BODY`, `ACTOR`, `PR_BRANCH`) are copied verbatim from the old workflow. `COMMENT_BODY` is only glob-matched in a `case` (`entrypoint.sh:95-98`) and `ACTOR` only interpolated into a Gitea API path for a username — both unchanged from `main` and neither `eval`'d. **One non-blocking observation (verified, not a defect):** splitting the gate (now in the consumer's caller file) from the run (in the reusable) makes it *easier for a consumer to forget the job-level `if:`* than when both lived in one file. The in-container collaborator fallback above backstops this, so the worst case is limited to write-access users — acceptable. The `examples/reusable.yml` template correctly ships both the `if:` gate and `allowed_users:` (belt-and-suspenders), which is the right guidance to keep. Nothing in the security lens blocks this PR. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> Both files match the diff exactly. I've confirmed: - The reusable workflow (`review-reusable.yml:75`) reads `GADFLY_SPECIALISTS` only from `${{ inputs.specialists }}` — there is no `vars.GADFLY_SPECIALISTS` fallback anywhere in it. - The new caller (`adversarial-review.yml:46`) hardcodes `specialists: "security,correctness,error-handling"`. - The old inline stub (per the diff) used `${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`. So the repo-var override path is genuinely removed for gadfly's own repo — the minor finding holds against the actual code. The behavior-preservation and "empty ⇒ image default" claims are background, not findings, and don't change the verdict. --- **VERDICT: Minor issues** - **`.gitea/workflows/adversarial-review.yml:46`** — The old stub set `GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`, letting the repo var `GADFLY_SPECIALISTS` override the lens suite without editing the workflow. The new caller hardcodes `specialists: "security,correctness,error-handling"`, and the reusable workflow (`review-reusable.yml:75`) reads only `${{ inputs.specialists }}` with no `vars.GADFLY_SPECIALISTS` path, so that override is silently lost for gadfly's own repo. Impact is small and likely intentional (config simplification for the dogfood caller), but it's a real behavior change. If the override is meant to survive, the caller should pass `specialists: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I've verified the central edge-case risk in this change. Since the diff is config plumbing, the one real error-handling concern it introduces is that the reusable workflow **always** sets the config env vars — to `""` when the caller doesn't override — whereas a hand-written stub previously left them absent. So the whole design rests on "empty string is treated identically to unset" everywhere those vars are consumed. I checked each consumer: - `cmd/gadfly/main.go:450` `envInt` — empty → default; also guards `n <= 0`. ✓ (covers `GADFLY_TIMEOUT_SECS`, `GADFLY_MAX_STEPS`) - `cmd/gadfly/main.go:291` `providerOverride` — empty splits to a single un-`Cut`-able item → `(0,false)` fallback. ✓ (`GADFLY_PROVIDER_CONCURRENCY`/`..._LENS_CONCURRENCY`) - `cmd/gadfly/specialists.go:136` — gated on `TrimSpace(...) != ""`, so empty → default suite. ✓ - `cmd/gadfly/model.go:55,59,115` — `TrimSpace` + `!= ""` checks for `GADFLY_PROVIDER`/`GADFLY_BASE_URL`/`GADFLY_WORKER_MODEL`. ✓ - `entrypoint.sh:53,71,156,159` — `${VAR:-default}` and `[ -n ... ]` treat empty as unset for `GADFLY_TRIGGER_PHRASE`, `GADFLY_ALLOWED_USERS`, `GADFLY_MODELS`, `GADFLY_PROVIDER`. ✓ The "empty ⇒ image default" contract the PR depends on holds at every read site I could find. The unhappy paths the change actually touches (missing required event context) are unchanged from before: `entrypoint.sh:60-63` still `:?`-exits on absent `PR`/`EVENT_NAME`, which is a config-error exit explicitly permitted by goal #2 — not introduced here. --- **VERDICT: No material issues found** Through the error-handling & edge-cases lens, this change is clean: - **Empty-input contract is sound (verified).** Every env var the reusable workflow newly forces to `""` is read through a path that treats empty as "use default" (`envInt`, `providerOverride`, `TrimSpace != ""` guards, shell `:-`/`-n`). No empty value reaches a `strconv.Atoi`/parse that would error or yield a degenerate `0`. The one numeric input rendered directly in YAML, `timeout_minutes` (`review-reusable.yml:31` → used at `:37`), is `type: number, default: 30`, so it can't arrive empty. - **No new swallowed errors or missing cleanup** — this is declarative workflow wiring; the binary's existing per-lens isolation (`reviewWithSpecialist`, `main.go:304-309`) and entrypoint's `|| log "... (continuing)"` fault tolerance are untouched. One adjacent observation I could **not** verify and am therefore *not* filing as a finding: the PR's own "Note" flags that Gitea may not propagate the caller's `github.event` into the reusable workflow. If it doesn't, `PR`/`EVENT_NAME` resolve empty and `entrypoint.sh:60-63` exits 1 — but that's a permitted config-error exit, advisory, and on a non-required check, so even in the worst case it doesn't violate the never-block invariant. Whether the propagation actually works is a behavior/CI question outside my lens (and the author already plans to confirm it via this PR's live run). </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 6m 22s</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

Minor issues


Finding 1 — examples/reusable.yml pins to @main while passing secrets: inherit

File: examples/reusable.yml:43

uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main
secrets: inherit

Any consumer who copies this stub verbatim flows their entire secret store (secrets: inherit) into whatever code happens to be at the tip of Gadfly's main branch at run-time — including any future push, force-push, or repo compromise. This is textbook supply-chain risk: the trust boundary is the live branch pointer, not a reviewed, immutable commit.

The in-file comment at line 10 says "Pin @<ref> to a Gadfly tag/branch" — but the actual uses: line still reads @main and does not show what the pinned form looks like. A user copying the file verbatim will not pin.

The docker image in review-reusable.yml is already correctly pinned to :sha-c342bdb (line 47) — the same discipline should apply to the workflow ref in the example stub.

Suggested fix: Change the example uses: to a concrete pinned ref (tag or commit SHA) and add a # TODO: update on each Gadfly release comment so the instruction is actionable:

uses: steve/gadfly/.gitea/workflows/review-reusable.yml@sha-c342bdb
# ^ pin to an immutable ref; update when you bump Gadfly
🎯 Correctness — Minor issues

Both findings are confirmed. Here is the corrected review:


Minor issues

  • specialist_suite input wired to nothingreview-reusable.yml:34 declares specialist_suite: { type: string, default: "" } under workflow_call.inputs, but no corresponding env entry appears in the env: block (lines 74–84 map every other input; specialist_suite is absent). The inline comment # reserved / future shows this is intentional, but a consumer who passes specialist_suite: "some-suite" will see it silently ignored with no warning. Fix: either add GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }} to the env block when the feature is implemented, or remove the declaration and reintroduce it alongside the implementation.

  • vars.GADFLY_SPECIALISTS runtime override silently dropped — The old adversarial-review.yml set GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}, explicitly advertising vars.GADFLY_SPECIALISTS as an operational override knob. The new file (adversarial-review.yml:46) replaces that with a hard-coded specialists: "security,correctness,error-handling" in the with: block, and review-reusable.yml:75 maps that to GADFLY_SPECIALISTS: ${{ inputs.specialists }}. There is no path by which the repo variable now reaches the container. Repos where vars.GADFLY_SPECIALISTS was set to "all" or "auto" will silently revert to the three-lens suite. Fix: use specialists: "${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}" as the with: value in adversarial-review.yml, or document the removal explicitly.

🧯 Error handling & edge cases — Minor issues

Both findings are confirmed against the actual code:

  1. entrypoint.sh:62 does contain : "${PR:?PR required}" — a strict bail-out that fires before run.sh is ever called, with no graceful PR comment fallback.
  2. specialist_suite appears only at review-reusable.yml:34 in the inputs: stanza and has no corresponding entry in the env: block (lines 73–84 map every other input but skip specialist_suite).

Minor issues

  • PR required but no graceful fallback on workflow_call context propagation failurereview-reusable.yml evaluates ${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} to populate PR. In a standard workflow_call the called workflow inherits the caller's github.event, so github.event.inputs.pr_number should resolve when the caller was triggered by workflow_dispatch. But if Gitea's workflow_call isolates the event context (the author calls this out as a known risk), all three expressions evaluate to empty, PR is "", and entrypoint.sh:62 hard-exits via ${PR:?PR required} before run.sh is ever invoked. Consequence: no PR comment is posted, the Docker step exits non-zero, the reusable workflow job fails, and the caller's review: job shows as failed — silently from the reviewer's point of view (no explanation on the PR). Verified: entrypoint.sh line 62 is a strict-expansion bail-out; run.sh's graceful error-comment logic is only reached after the model loop, which is never entered. This is a new failure mode — in the pre-refactor self-contained job the expression was always evaluated in the triggering event's context. Suggested fix: add continue-on-error: true on the docker step in review-reusable.yml, or add a pre-check in entrypoint.sh that posts a "could not determine PR number" notice and exits 0 when PR is unset.

  • specialist_suite input declared but never wiredreview-reusable.yml defines specialist_suite: { type: string, default: "" } (marked "reserved / future") but there is no corresponding env var in the env: block. Verified: specialist_suite appears only at review-reusable.yml:34 in the inputs: stanza; no other file in the repo references it. A consumer who sets this input gets silent discard. The comment documents it as reserved, so this won't surprise readers of the source, but at runtime there is no warning. Low severity given the explicit "reserved" label, but worth noting: if a consumer sets specialist_suite: "...", they will see no effect and no error.

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

<!-- gadfly-review:ollama:claude-code/sonnet --> ### 🪰 Gadfly review — `claude-code/sonnet` (claude-code) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> **Minor issues** --- ### Finding 1 — `examples/reusable.yml` pins to `@main` while passing `secrets: inherit` **File:** `examples/reusable.yml:43` ```yaml uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main secrets: inherit ``` Any consumer who copies this stub verbatim flows their entire secret store (`secrets: inherit`) into whatever code happens to be at the tip of Gadfly's `main` branch at run-time — including any future push, force-push, or repo compromise. This is textbook supply-chain risk: the trust boundary is the live branch pointer, not a reviewed, immutable commit. The in-file comment at line 10 says *"Pin @\<ref\> to a Gadfly tag/branch"* — but the actual `uses:` line still reads `@main` and does not show what the pinned form looks like. A user copying the file verbatim will not pin. The docker image in `review-reusable.yml` is already correctly pinned to `:sha-c342bdb` (line 47) — the same discipline should apply to the workflow ref in the example stub. **Suggested fix:** Change the example `uses:` to a concrete pinned ref (tag or commit SHA) and add a `# TODO: update on each Gadfly release` comment so the instruction is actionable: ```yaml uses: steve/gadfly/.gitea/workflows/review-reusable.yml@sha-c342bdb # ^ pin to an immutable ref; update when you bump Gadfly ``` </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> Both findings are confirmed. Here is the corrected review: --- **Minor issues** - **`specialist_suite` input wired to nothing** — `review-reusable.yml:34` declares `specialist_suite: { type: string, default: "" }` under `workflow_call.inputs`, but no corresponding env entry appears in the `env:` block (lines 74–84 map every other input; `specialist_suite` is absent). The inline comment `# reserved / future` shows this is intentional, but a consumer who passes `specialist_suite: "some-suite"` will see it silently ignored with no warning. Fix: either add `GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }}` to the env block when the feature is implemented, or remove the declaration and reintroduce it alongside the implementation. - **`vars.GADFLY_SPECIALISTS` runtime override silently dropped** — The old `adversarial-review.yml` set `GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`, explicitly advertising `vars.GADFLY_SPECIALISTS` as an operational override knob. The new file (`adversarial-review.yml:46`) replaces that with a hard-coded `specialists: "security,correctness,error-handling"` in the `with:` block, and `review-reusable.yml:75` maps that to `GADFLY_SPECIALISTS: ${{ inputs.specialists }}`. There is no path by which the repo variable now reaches the container. Repos where `vars.GADFLY_SPECIALISTS` was set to `"all"` or `"auto"` will silently revert to the three-lens suite. Fix: use `specialists: "${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}"` as the `with:` value in `adversarial-review.yml`, or document the removal explicitly. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> Both findings are confirmed against the actual code: 1. `entrypoint.sh:62` does contain `: "${PR:?PR required}"` — a strict bail-out that fires before `run.sh` is ever called, with no graceful PR comment fallback. 2. `specialist_suite` appears only at `review-reusable.yml:34` in the `inputs:` stanza and has no corresponding entry in the `env:` block (lines 73–84 map every other input but skip `specialist_suite`). --- **Minor issues** - **`PR` required but no graceful fallback on `workflow_call` context propagation failure** — `review-reusable.yml` evaluates `${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }}` to populate `PR`. In a standard `workflow_call` the called workflow inherits the caller's `github.event`, so `github.event.inputs.pr_number` should resolve when the caller was triggered by `workflow_dispatch`. But if Gitea's `workflow_call` isolates the event context (the author calls this out as a known risk), all three expressions evaluate to empty, `PR` is `""`, and `entrypoint.sh:62` hard-exits via `${PR:?PR required}` *before* `run.sh` is ever invoked. Consequence: no PR comment is posted, the Docker step exits non-zero, the reusable workflow job fails, and the caller's `review:` job shows as failed — silently from the reviewer's point of view (no explanation on the PR). **Verified**: `entrypoint.sh` line 62 is a strict-expansion bail-out; `run.sh`'s graceful error-comment logic is only reached after the model loop, which is never entered. This is a new failure mode — in the pre-refactor self-contained job the expression was always evaluated in the triggering event's context. Suggested fix: add `continue-on-error: true` on the docker step in `review-reusable.yml`, or add a pre-check in `entrypoint.sh` that posts a "could not determine PR number" notice and exits 0 when `PR` is unset. - **`specialist_suite` input declared but never wired** — `review-reusable.yml` defines `specialist_suite: { type: string, default: "" }` (marked "reserved / future") but there is no corresponding env var in the `env:` block. **Verified**: `specialist_suite` appears only at `review-reusable.yml:34` in the `inputs:` stanza; no other file in the repo references it. A consumer who sets this input gets silent discard. The comment documents it as reserved, so this won't surprise readers of the source, but at runtime there is no warning. Low severity given the explicit "reserved" label, but worth noting: if a consumer sets `specialist_suite: "..."`, they will see no effect and no error. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 14m 40s</sub>

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

Verdict: Blocking issues found — 3 reviewers: security, correctness, error-handling

🔒 Security — No material issues found

Confirmed: GADFLY_FINDINGS_URL flows unvalidated into http.NewRequest. This is a pre-existing issue not introduced by this PR.

Now I have enough to render a verdict.

Verdict: No material issues found (security lens)

This is a refactor — it relocates ~90 lines of workflow YAML into a reusable workflow_call workflow and has gadfly dogfood it. Walking the change through the 🔒 Security lens specifically:

  • Authn/authz: No change. The caller-side if: comment-trigger actor gate is unchanged (was already caller-side). permissions: block (contents: read; issues: write; pull-requests: write) is preserved verbatim. The reusable workflow deliberately doesn't re-gate — the same trust model as before (caller's secrets, caller's allow-list).
  • Injection / SSRF: No new taint sources. The only new inputs are CSVs into env vars (GADFLY_MODELS, GADFLY_SPECIALISTS, GADFLY_PROVIDER_CONCURRENCY, etc.) that the binary already parses. GADFLY_FINDINGS_URL was already consumed as a base URL in cmd/gadfly/emit.go:97 (passed straight to http.NewRequest at emit.go:370); this PR only moves where the env var is plumbed, not how it's used. The pre-existing lack of URL/scheme validation is not introduced here — out of scope for this diff.
  • Secret leakage / secrets: inherit: Deliberate; the whole point. The reusable workflow is in the same repo (or pinned via @ref for cross-repo consumers), so the trust boundary equals the prior stub's. Undefined secrets resolve to empty per Gitea — verified by reading review-reusable.yml:60-69. No secret is logged.
  • Image supply chain: Pin gitea.stevedudenhoeffer.com/steve/gadfly:sha-c342bdb is moved from caller into reusable (line 47) — no downgrade, same digest-style tag.
  • Unsafe defaults: examples/reusable.yml:42 ships the literal placeholder github.actor == 'your-username' — it's a copy-paste template, not real config, and secrets: inherit is required for it to function; consumers must edit before merging. This is consistent with examples/adversarial-review.yml:42 (same your-username pattern). Not a regression — and clearly a template.
  • Unsafe deserialization / new endpoints: None added.
  • specialist_suite input (review-reusable.yml:34) is declared but unused (commented reserved / future). Dead config, no security impact.

Verified by reading: .gitea/workflows/review-reusable.yml, .gitea/workflows/adversarial-review.yml, examples/reusable.yml, examples/adversarial-review.yml, entrypoint.sh (lines 67–114 for the in-container allow-list + collaborator fallback), cmd/gadfly/emit.go (lines 96–161 for the findings telemetry consumer), and the relevant env flow through entrypoint.sh:139-142.

Outside my lens — and pre-existing, not introduced here — but worth flagging once: cmd/gadfly/emit.go:97 and :153,157 build the findings POST URL as strings.TrimRight(base, "/") + "/runs|"/reports" with no scheme check, no host allowlist, and no rejection of file:// / http://internal-host. Anyone who can set the GADFLY_FINDINGS_URL secret on a consumer repo can turn this into an internal-network POST. Since secrets: inherit now delivers this secret into the reusable workflow without per-caller scrutiny, a misconfigured consumer could exfiltrate review telemetry to an arbitrary URL. The PR doesn't change the behavior, only the plumbing — so it's not a regression in this diff — but the refactor is a reasonable moment to harden it (e.g. require https:// and an optional allowlist of hosts).

🎯 Correctness — Blocking issues found

VERDICT: Blocking issues found

  • .gitea/workflows/adversarial-review.yml:46specialists input hardcoded, drops the vars.GADFLY_SPECIALISTS repo-var override. The previous inline form was GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}. The new caller hardcodes specialists: "security,correctness,error-handling" and ignores vars.GADFLY_SPECIALISTS entirely, so a repo var set to auto or all (or any custom csv) is silently lost. Fix: specialists: ${{ vars.GADFLY_SPECIALISTS || '' }} so the var wins and empty falls back.

  • .gitea/workflows/review-reusable.yml:75 + cmd/gadfly/specialists.go:136 — "empty ⇒ image default" is FALSE for specialists. cmd/gadfly/specialists.go:136 is case strings.TrimSpace(os.Getenv("GADFLY_SPECIALISTS")) != "": — when the env is empty, control falls through to the file config (.gadfly.yml) and then to defaultSuite (line 141: security, correctness, maintainability, performance, error-handling). For a consumer with no .gadfly.yml this is the 5-lens default suite, NOT the 3-lens security,correctness,error-handling the old inline form hardcoded. So the reusable's "caller overrides only what it wants" promise holds for the file-absent case (you get the full default suite, which is a strict superset of the old behavior) but breaks the old contract: callers that relied on the inline form getting exactly security,correctness,error-handling now silently get two extra lenses (maintainability, performance). The reusable should declare a non-empty default for specialists (the old security,correctness,error-handling), or the caller should pass specialists: "security,correctness,error-handling" explicitly — which it does, so this is partially mitigated for the dogfood caller but the contract is still misleading.

  • .gitea/workflows/review-reusable.yml:40timeout_minutes: 30 default is too low. The documented fleet is 6 cloud + 3 claude-code models × 3 lenses × 2 passes, all concurrent. cmd/gadfly/main.go:86 sets defaultTimeoutSecs = 300 per lens, and entrypoint.sh:156 defaults to one cloud model. The old inline dogfood caller used timeout-minutes: 90. Any consumer copying examples/reusable.yml without overriding timeout_minutes will hit the 30-min wall-clock cap mid-suite and get an incomplete truncated review. Either raise the default to 90 or document the override prominently. (examples/reusable.yml:46 comment "All optional — omit to take Gadfly's defaults" does not warn of truncation.)

  • examples/reusable.yml:42 — actor gate missing the pull_request check. The dogfood caller at .gitea/workflows/adversarial-review.yml:36-41 gates issue_comment on github.event.issue.pull_request && (github.actor == 'steve' || ...). The example's gate github.event_name != 'issue_comment' || github.actor == 'your-username' lacks the pull_request sub-check, so a comment on a plain issue by your-username containing the trigger phrase passes the workflow-level gate, then entrypoint.sh:103 (API "${GITEA_API}/pulls/${PR}") 404s and exits 0 — harmless but wasteful (full image pull + clone before bail). Match the dogfood pattern.

  • .gitea/workflows/review-reusable.yml:34specialist_suite input is dead code. Verified: grep for specialist_suite and GADFLY_SPECIALIST_SUITE across the repo returns only this one declaration. No env consumer in entrypoint.sh (which reads GADFLY_TRIGGER_PHRASE, GADFLY_ALLOWED_USERS, GADFLY_FINDINGS_* etc.) and no binary reader in cmd/gadfly/. The comment "reserved / future" is honest but the input is misleading cruft — drop it or implement it.

🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/review-reusable.yml:40 + examples/reusable.yml — default timeout_minutes: 30 is a footgun for new callers. Verified: the reusable's input default is 30 (review-reusable.yml:40), and examples/reusable.yml:43-46 passes no timeout_minutes, so a copy-pasting consumer inherits 30. Confirmed against entrypoint.sh:52 (image default model is qwen3-coder:480b-cloud) and cmd/gadfly/main.go:86/main.go:310 (per-lens timeout default 300s × 3 lenses already exceeds the 30-min cap under contention). The dogfood file .gitea/workflows/adversarial-review.yml:52 overrides to timeout_minutes: 90, confirming the author's own floor. Fix: bump default to 90 (matching dogfood) or have examples/reusable.yml pass timeout_minutes: 90.

  • .gitea/workflows/review-reusable.yml:55IS_DRAFT: ${{ github.event.pull_request.draft }} is fragile for non-PR triggers. Verified: entrypoint.sh:90 guards with if [ "${IS_DRAFT:-false}" = "true" ], so empty falls through correctly today. Note this exact expression is unchanged from the pre-PR pattern (still present unchanged in examples/adversarial-review.yml:108, examples/claude-code.yml:75, examples/endpoint-aliases.yml:64, examples/local-ollama.yml:53, examples/openai-compatible.yml:56), so the PR doesn't introduce new breakage — it inherits an existing brittleness into the new reusable. Fix (light): defensively || false or wrap in toJSON(...) for Gitea's expression evaluator.

(Dropped: the third claim about "caller-event context propagation" — speculative, author-flagged, depends on host workflow_call semantics which the verification cannot confirm against the repo. The author's own comment block in the reusable yml is the right place for that caveat.)

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

<!-- gadfly-review:ollama:minimax-m3:cloud --> ### 🪰 Gadfly review — `minimax-m3:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> Confirmed: `GADFLY_FINDINGS_URL` flows unvalidated into `http.NewRequest`. This is a pre-existing issue not introduced by this PR. Now I have enough to render a verdict. ## Verdict: No material issues found (security lens) This is a refactor — it relocates ~90 lines of workflow YAML into a reusable `workflow_call` workflow and has gadfly dogfood it. Walking the change through the **🔒 Security** lens specifically: - **Authn/authz:** No change. The caller-side `if:` comment-trigger actor gate is unchanged (was already caller-side). `permissions:` block (`contents: read; issues: write; pull-requests: write`) is preserved verbatim. The reusable workflow deliberately doesn't re-gate — the same trust model as before (caller's secrets, caller's allow-list). - **Injection / SSRF:** No new taint sources. The only new inputs are CSVs into env vars (`GADFLY_MODELS`, `GADFLY_SPECIALISTS`, `GADFLY_PROVIDER_CONCURRENCY`, etc.) that the binary already parses. `GADFLY_FINDINGS_URL` was *already* consumed as a base URL in `cmd/gadfly/emit.go:97` (passed straight to `http.NewRequest` at `emit.go:370`); this PR only moves where the env var is plumbed, not how it's used. The pre-existing lack of URL/scheme validation is not introduced here — out of scope for this diff. - **Secret leakage / `secrets: inherit`:** Deliberate; the whole point. The reusable workflow is in the same repo (or pinned via `@ref` for cross-repo consumers), so the trust boundary equals the prior stub's. Undefined secrets resolve to empty per Gitea — verified by reading `review-reusable.yml:60-69`. No secret is logged. - **Image supply chain:** Pin `gitea.stevedudenhoeffer.com/steve/gadfly:sha-c342bdb` is moved from caller into reusable (line 47) — no downgrade, same digest-style tag. - **Unsafe defaults:** `examples/reusable.yml:42` ships the literal placeholder `github.actor == 'your-username'` — it's a copy-paste template, not real config, and `secrets: inherit` is required for it to function; consumers must edit before merging. This is consistent with `examples/adversarial-review.yml:42` (same `your-username` pattern). Not a regression — and clearly a template. - **Unsafe deserialization / new endpoints:** None added. - **`specialist_suite` input** (`review-reusable.yml:34`) is declared but unused (commented `reserved / future`). Dead config, no security impact. Verified by reading: `.gitea/workflows/review-reusable.yml`, `.gitea/workflows/adversarial-review.yml`, `examples/reusable.yml`, `examples/adversarial-review.yml`, `entrypoint.sh` (lines 67–114 for the in-container allow-list + collaborator fallback), `cmd/gadfly/emit.go` (lines 96–161 for the findings telemetry consumer), and the relevant env flow through `entrypoint.sh:139-142`. Outside my lens — and pre-existing, not introduced here — but worth flagging once: `cmd/gadfly/emit.go:97` and `:153,157` build the findings POST URL as `strings.TrimRight(base, "/") + "/runs|"/reports"` with no scheme check, no host allowlist, and no rejection of `file://` / `http://internal-host`. Anyone who can set the `GADFLY_FINDINGS_URL` secret on a consumer repo can turn this into an internal-network POST. Since `secrets: inherit` now delivers this secret into the reusable workflow without per-caller scrutiny, a misconfigured consumer could exfiltrate review telemetry to an arbitrary URL. The PR doesn't change the behavior, only the plumbing — so it's not a regression in this diff — but the refactor is a reasonable moment to harden it (e.g. require `https://` and an optional allowlist of hosts). </details> <details><summary><b>🎯 Correctness</b> — Blocking issues found</summary> ## VERDICT: Blocking issues found - **`.gitea/workflows/adversarial-review.yml:46` — `specialists` input hardcoded, drops the `vars.GADFLY_SPECIALISTS` repo-var override.** The previous inline form was `GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`. The new caller hardcodes `specialists: "security,correctness,error-handling"` and ignores `vars.GADFLY_SPECIALISTS` entirely, so a repo var set to `auto` or `all` (or any custom csv) is silently lost. Fix: `specialists: ${{ vars.GADFLY_SPECIALISTS || '' }}` so the var wins and empty falls back. - **`.gitea/workflows/review-reusable.yml:75` + `cmd/gadfly/specialists.go:136` — "empty ⇒ image default" is FALSE for `specialists`.** `cmd/gadfly/specialists.go:136` is `case strings.TrimSpace(os.Getenv("GADFLY_SPECIALISTS")) != "":` — when the env is empty, control falls through to the file config (`.gadfly.yml`) and then to `defaultSuite` (line 141: `security, correctness, maintainability, performance, error-handling`). For a consumer with no `.gadfly.yml` this is the 5-lens default suite, NOT the 3-lens `security,correctness,error-handling` the old inline form hardcoded. So the reusable's "caller overrides only what it wants" promise holds for the file-absent case (you get the full default suite, which is a strict superset of the old behavior) but **breaks the old contract**: callers that relied on the inline form getting exactly `security,correctness,error-handling` now silently get two extra lenses (`maintainability`, `performance`). The reusable should declare a non-empty default for `specialists` (the old `security,correctness,error-handling`), or the caller should pass `specialists: "security,correctness,error-handling"` explicitly — which it does, so this is partially mitigated for the dogfood caller but the contract is still misleading. - **`.gitea/workflows/review-reusable.yml:40` — `timeout_minutes: 30` default is too low.** The documented fleet is 6 cloud + 3 claude-code models × 3 lenses × 2 passes, all concurrent. `cmd/gadfly/main.go:86` sets `defaultTimeoutSecs = 300` per lens, and `entrypoint.sh:156` defaults to one cloud model. The old inline dogfood caller used `timeout-minutes: 90`. Any consumer copying `examples/reusable.yml` without overriding `timeout_minutes` will hit the 30-min wall-clock cap mid-suite and get an incomplete truncated review. Either raise the default to 90 or document the override prominently. (`examples/reusable.yml:46` comment "All optional — omit to take Gadfly's defaults" does not warn of truncation.) - **`examples/reusable.yml:42` — actor gate missing the `pull_request` check.** The dogfood caller at `.gitea/workflows/adversarial-review.yml:36-41` gates issue_comment on `github.event.issue.pull_request && (github.actor == 'steve' || ...)`. The example's gate `github.event_name != 'issue_comment' || github.actor == 'your-username'` lacks the `pull_request` sub-check, so a comment on a plain **issue** by `your-username` containing the trigger phrase passes the workflow-level gate, then `entrypoint.sh:103` (`API "${GITEA_API}/pulls/${PR}"`) 404s and exits 0 — harmless but wasteful (full image pull + clone before bail). Match the dogfood pattern. - **`.gitea/workflows/review-reusable.yml:34` — `specialist_suite` input is dead code.** Verified: `grep` for `specialist_suite` and `GADFLY_SPECIALIST_SUITE` across the repo returns only this one declaration. No env consumer in `entrypoint.sh` (which reads `GADFLY_TRIGGER_PHRASE`, `GADFLY_ALLOWED_USERS`, `GADFLY_FINDINGS_*` etc.) and no binary reader in `cmd/gadfly/`. The comment "reserved / future" is honest but the input is misleading cruft — drop it or implement it. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> ## VERDICT: Minor issues - **`.gitea/workflows/review-reusable.yml:40` + `examples/reusable.yml` — default `timeout_minutes: 30` is a footgun for new callers.** Verified: the reusable's input default is 30 (`review-reusable.yml:40`), and `examples/reusable.yml:43-46` passes no `timeout_minutes`, so a copy-pasting consumer inherits 30. Confirmed against `entrypoint.sh:52` (image default model is `qwen3-coder:480b-cloud`) and `cmd/gadfly/main.go:86`/`main.go:310` (per-lens timeout default 300s × 3 lenses already exceeds the 30-min cap under contention). The dogfood file `.gitea/workflows/adversarial-review.yml:52` overrides to `timeout_minutes: 90`, confirming the author's own floor. **Fix:** bump default to 90 (matching dogfood) or have `examples/reusable.yml` pass `timeout_minutes: 90`. - **`.gitea/workflows/review-reusable.yml:55` — `IS_DRAFT: ${{ github.event.pull_request.draft }}` is fragile for non-PR triggers.** Verified: `entrypoint.sh:90` guards with `if [ "${IS_DRAFT:-false}" = "true" ]`, so empty falls through correctly today. Note this exact expression is unchanged from the pre-PR pattern (still present unchanged in `examples/adversarial-review.yml:108`, `examples/claude-code.yml:75`, `examples/endpoint-aliases.yml:64`, `examples/local-ollama.yml:53`, `examples/openai-compatible.yml:56`), so the PR doesn't introduce new breakage — it inherits an existing brittleness into the new reusable. **Fix (light):** defensively `|| false` or wrap in `toJSON(...)` for Gitea's expression evaluator. (Dropped: the third claim about "caller-event context propagation" — speculative, author-flagged, depends on host `workflow_call` semantics which the verification cannot confirm against the repo. The author's own comment block in the reusable yml is the right place for that caveat.) </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 1m 59s</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues

Investigated through the security lens. The core authz model is preserved: the actor if: gate stays at the caller (adversarial-review.yml:36-41, examples/reusable.yml:40-42), and the image remains pinned to an immutable :sha-c342bdb tag in the reusable workflow (review-reusable.yml:47). secrets: inherit only exposes secrets that are explicitly mapped into the container env, so unmapped secret names aren't leaked into the run. No injection surface is added (inputs are maintainer-authored config, not PR-author-controlled). Verified by reading review-reusable.yml, adversarial-review.yml, examples/reusable.yml.

Findings:

  • examples/reusable.yml:43 — insecure mutable ref for the consumer template. The example tells consumers to use uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main, and the header only says "Pin @<ref> to a Gadfly tag/branch." Because the caller passes secrets: inherit, a compromise of steve/gadfly's main branch (e.g. a merged malicious PR) would let the attacker author a new workflow file that exfiltrates all of the consumer's inherited secrets (OLLAMA_CLOUD_API_KEY, GITEA_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, etc.) or swap the docker:// image pin for a malicious image. This is a real supply-chain / insecure-default issue for the recommended consumer path. Suggest defaulting the template to an immutable sha (or at least a non-moving signed tag) and changing the guidance to "pin to an immutable commit sha, not a branch." Verified by reading examples/reusable.yml:10,43.

  • review-reusable.yml has no permissions: block. Not a regression (the dogfood caller adversarial-review.yml:22-25 and the example examples/reusable.yml:27-30 both set contents: read, issues: write, pull-requests: write, and reusable workflows inherit the caller's permissions), but worth a note: a consumer who drops the permissions: block from their caller would get the runner's default (often broad) token scope for a job that handles secrets and writes comments. Consider setting an explicit restrictive permissions: in the reusable workflow as defense-in-depth. Verified by reading review-reusable.yml:42-84.

No other material security issues found in this lens.

🎯 Correctness — Minor issues

Verdict: Minor issues

  • .gitea/workflows/review-reusable.yml:49-58 — correctness depends on github.event.* propagation into the workflow_call job, which is the one part of the refactor not probe-verified. The reusable job reads github.event.pull_request.number, github.event.comment.body, github.event.comment.id, github.head_ref, github.event.pull_request.draft from the called workflow's context. I confirmed entrypoint.sh:62-63 does : "${PR:?PR required}" and : "${EVENT_NAME:?EVENT_NAME required}", and PR: in the reusable is built solely from those github.event.* references (line 53). If this Gitea act_runner version does not forward the caller's event payload into the called workflow, PR is empty, entrypoint.sh dies with PR required, and no review runs (this PR's review included). The file header (lines 15-16) only claims secrets: inherit was verified — not github.event.* context inheritance. Suggested fix: run one real PR through the reusable path and confirm PR/COMMENT_BODY are non-empty in the called job; if Gitea blanks them, pass the event context explicitly via with: inputs (pr_number, event_name, comment_body, …) from the caller's github.* and forward them as env, removing the dependence on context inheritance entirely.

  • .gitea/workflows/review-reusable.yml:59-64GADFLY_API_KEY (the generic provider-key env) is not mapped; only the provider-specific keys are. I verified entrypoint.sh:38 documents GADFLY_API_KEY as the provider-key fallback, cmd/gadfly/model.go:60 reads os.Getenv("GADFLY_API_KEY"), and scripts/run.sh:151 checks it for the ollama-cloud key path. The reusable maps OLLAMA_CLOUD_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY/GOOGLE_API_KEY/CLAUDE_CODE_OAUTH_TOKEN, but not GADFLY_API_KEY. A consumer whose provider key is only available as the secret GADFLY_API_KEY (not one of the named provider envs) gets an empty key through the reusable path and silently fails auth. Suggested fix: add GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }} to the provider-auth block (it resolves empty when unset, same as the others).

  • .gitea/workflows/review-reusable.yml:34specialist_suite input is declared but never plumbed to any env var. The input is commented reserved / future, and a repo-wide grep confirms no env references it — it is dead, user-visible config surface that does nothing. Either wire it to a planned env or drop it to avoid implying it works.

🧯 Error handling & edge cases — Minor issues

Minor issues

  • examples/reusable.yml:40-42 — the public template's if: gate (github.event_name != 'issue_comment' || github.actor == 'your-username') lacks the github.event.issue.pull_request guard that the dogfood caller (adversarial-review.yml:36-41) has. Verified: an issue_comment on a plain issue by the matching actor passes the gate and only gets rejected later inside the container at entrypoint.sh:103-105 (which exit 0s harmlessly). Impact is minor — no crash and the run no-ops — but it wastes a secret-bearing runner invocation for every qualifying issue comment. Worth adding github.event.issue.pull_request && to the example's if:, matching the dogfood file.

No swallowed errors, missing defers, panics, or nil/empty-collection crashes were introduced by the diff. The reusable workflow's default: "" inputs degrade cleanly to the image/entrypoint built-in defaults (verified in entrypoint.sh:53 for GADFLY_TRIGGER_PHRASE and entrypoint.sh:71-82 for GADFLY_ALLOWED_USERS), and the dogfood caller passes explicit values for the config it needs.

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

<!-- gadfly-review:ollama:glm-5.2:cloud --> ### 🪰 Gadfly review — `glm-5.2:cloud` (ollama-cloud) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> ## VERDICT: Minor issues Investigated through the security lens. The core authz model is preserved: the actor `if:` gate stays at the caller (`adversarial-review.yml:36-41`, `examples/reusable.yml:40-42`), and the image remains pinned to an immutable `:sha-c342bdb` tag in the reusable workflow (`review-reusable.yml:47`). `secrets: inherit` only exposes secrets that are explicitly mapped into the container env, so unmapped secret names aren't leaked into the run. No injection surface is added (inputs are maintainer-authored config, not PR-author-controlled). Verified by reading `review-reusable.yml`, `adversarial-review.yml`, `examples/reusable.yml`. Findings: - **`examples/reusable.yml:43` — insecure mutable ref for the consumer template.** The example tells consumers to use `uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main`, and the header only says "Pin `@<ref>` to a Gadfly tag/branch." Because the caller passes `secrets: inherit`, a compromise of `steve/gadfly`'s `main` branch (e.g. a merged malicious PR) would let the attacker author a new workflow file that exfiltrates **all** of the consumer's inherited secrets (`OLLAMA_CLOUD_API_KEY`, `GITEA_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, etc.) or swap the `docker://` image pin for a malicious image. This is a real supply-chain / insecure-default issue for the recommended consumer path. Suggest defaulting the template to an immutable sha (or at least a non-moving signed tag) and changing the guidance to "pin to an immutable commit sha, not a branch." Verified by reading `examples/reusable.yml:10,43`. - **`review-reusable.yml` has no `permissions:` block.** Not a regression (the dogfood caller `adversarial-review.yml:22-25` and the example `examples/reusable.yml:27-30` both set `contents: read, issues: write, pull-requests: write`, and reusable workflows inherit the caller's permissions), but worth a note: a consumer who drops the `permissions:` block from their caller would get the runner's default (often broad) token scope for a job that handles secrets and writes comments. Consider setting an explicit restrictive `permissions:` in the reusable workflow as defense-in-depth. Verified by reading `review-reusable.yml:42-84`. No other material security issues found in this lens. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## Verdict: Minor issues - **`.gitea/workflows/review-reusable.yml:49-58` — correctness depends on `github.event.*` propagation into the `workflow_call` job, which is the one part of the refactor not probe-verified.** The reusable job reads `github.event.pull_request.number`, `github.event.comment.body`, `github.event.comment.id`, `github.head_ref`, `github.event.pull_request.draft` from the *called* workflow's context. I confirmed `entrypoint.sh:62-63` does `: "${PR:?PR required}"` and `: "${EVENT_NAME:?EVENT_NAME required}"`, and `PR:` in the reusable is built solely from those `github.event.*` references (line 53). If this Gitea act_runner version does not forward the caller's event payload into the called workflow, `PR` is empty, `entrypoint.sh` dies with `PR required`, and no review runs (this PR's review included). The file header (lines 15-16) only claims `secrets: inherit` was verified — not `github.event.*` context inheritance. Suggested fix: run one real PR through the reusable path and confirm `PR`/`COMMENT_BODY` are non-empty in the called job; if Gitea blanks them, pass the event context explicitly via `with:` inputs (`pr_number`, `event_name`, `comment_body`, …) from the caller's `github.*` and forward them as env, removing the dependence on context inheritance entirely. - **`.gitea/workflows/review-reusable.yml:59-64` — `GADFLY_API_KEY` (the generic provider-key env) is not mapped; only the provider-specific keys are.** I verified `entrypoint.sh:38` documents `GADFLY_API_KEY` as the provider-key fallback, `cmd/gadfly/model.go:60` reads `os.Getenv("GADFLY_API_KEY")`, and `scripts/run.sh:151` checks it for the ollama-cloud key path. The reusable maps `OLLAMA_CLOUD_API_KEY`/`OPENAI_API_KEY`/`ANTHROPIC_API_KEY`/`GOOGLE_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, but not `GADFLY_API_KEY`. A consumer whose provider key is only available as the secret `GADFLY_API_KEY` (not one of the named provider envs) gets an empty key through the reusable path and silently fails auth. Suggested fix: add `GADFLY_API_KEY: ${{ secrets.GADFLY_API_KEY }}` to the provider-auth block (it resolves empty when unset, same as the others). - **`.gitea/workflows/review-reusable.yml:34` — `specialist_suite` input is declared but never plumbed to any env var.** The input is commented `reserved / future`, and a repo-wide grep confirms no env references it — it is dead, user-visible config surface that does nothing. Either wire it to a planned env or drop it to avoid implying it works. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **Minor issues** - `examples/reusable.yml:40-42` — the public template's `if:` gate (`github.event_name != 'issue_comment' || github.actor == 'your-username'`) lacks the `github.event.issue.pull_request` guard that the dogfood caller (`adversarial-review.yml:36-41`) has. Verified: an `issue_comment` on a *plain issue* by the matching actor passes the gate and only gets rejected later inside the container at `entrypoint.sh:103-105` (which `exit 0`s harmlessly). Impact is minor — no crash and the run no-ops — but it wastes a secret-bearing runner invocation for every qualifying issue comment. Worth adding `github.event.issue.pull_request &&` to the example's `if:`, matching the dogfood file. No swallowed errors, missing defers, panics, or nil/empty-collection crashes were introduced by the diff. The reusable workflow's `default: ""` inputs degrade cleanly to the image/entrypoint built-in defaults (verified in `entrypoint.sh:53` for `GADFLY_TRIGGER_PHRASE` and `entrypoint.sh:71-82` for `GADFLY_ALLOWED_USERS`), and the dogfood caller passes explicit values for the config it needs. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 1m 54s</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

Verdict: Minor issues

  • examples/reusable.yml:40-42 — Actor gate is a single-username placeholder with no defense-in-depth for the issue_comment trigger. The example's if: gate checks only github.actor == 'your-username', and the allowed_users input is also "your-username". If a consumer copies this verbatim and only changes the username in one place but not the other, the two gates will be out of sync. More importantly, the example's if: gate has no github.event.issue.pull_request guard — unlike the dogfood caller at adversarial-review.yml:38 which checks github.event.issue.pull_request before evaluating the actor. Without that guard, a comment on a plain issue (not a PR) would pass the if: gate (since github.event_name != 'issue_comment' is false, and github.actor == 'your-username' could be true), causing the job to start, pull the image, and inject secrets — only to have the entrypoint later discover the issue isn't a PR and exit. The entrypoint's actor_allowed + PR-existence check (line 103) still prevents any actual review, but secrets are already in the container's environment at that point. The dogfood caller correctly nests the actor check inside github.event.issue.pull_request; the example should mirror that pattern. (Verified by reading both adversarial-review.yml:36-41 and examples/reusable.yml:40-42.)

  • .gitea/workflows/review-reusable.yml:51GITEA_TOKEN is implicitly required but never declared as a secret input. The reusable workflow consumes ${{ secrets.GITEA_TOKEN }} (the built-in Actions token) via secrets: inherit. This works because Gitea automatically provides GITEA_TOKEN to every workflow. However, if a consumer were to use secrets: inherit from a context where GITEA_TOKEN is somehow not available (e.g., a workflow with permissions: {} or a misconfigured runner), the container would fail at the : "${GITEA_TOKEN:?GITEA_TOKEN required}" check in entrypoint.sh:61. This is a robustness concern rather than a security vulnerability — the token is always present in normal Gitea Actions operation. The example caller correctly sets permissions: { issues: write, pull-requests: write, contents: read } which ensures the token has the needed scopes. (Verified by reading entrypoint.sh:60-63 and examples/reusable.yml:27-30.)

No injection, authz-bypass, or credential-leakage vulnerabilities introduced by this PR. The entrypoint's actor_allowed check (line 68-83) and the COMMENT_BODY glob-match (line 95-96) remain the same safe patterns as before. The ACTOR value interpolated into the API URL path at entrypoint.sh:81 is a pre-existing concern (Gitea usernames are restricted to safe characters, so it's low-risk), not introduced here.

🎯 Correctness — Minor issues

Verdict: Minor issues

  • specialist_suite input is declared but never plumbed to any env var.gitea/workflows/review-reusable.yml:34. The workflow_call accepts specialist_suite (comment says "reserved / future"), but the env: block (lines 73–84) has no GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }} mapping. I verified by reading the full file and grepping the entire repo: no Go code reads GADFLY_SPECIALIST_SUITE either. A consumer who sets specialist_suite: "something" gets a silent no-op — the value is accepted and discarded. Either remove the input until it's actually wired up, or add the env mapping (and the Go-side reader) now.
🧯 Error handling & edge cases — No material issues found

No material issues found.

I verified the full chain — reusable workflow → entrypoint.sh → Go binary — for every env var the diff introduces. The design consistently handles the empty-string default correctly at each layer:

  • GADFLY_SPECIALISTS, GADFLY_TIMEOUT_SECS, GADFLY_MAX_STEPS, GADFLY_WORKER_MODEL: empty string → Go's envInt / strings.TrimSpace checks fall through to built-in defaults (specialists.go:136, main.go:450-459, model.go:115).
  • GADFLY_PROVIDER_CONCURRENCY: empty string → provider_cap in entrypoint.sh:167-176 iterates one empty element, finds no match, returns DEFAULT_CONC (1). Malformed values like "abc" or "0" are caught by [ "$cap" -ge 1 ] || cap=1 at line 208.
  • GADFLY_PROVIDER_LENS_CONCURRENCY: empty string → providerOverride in main.go:291-301 splits "" into [""], strings.Cut returns !ok, loop exits, returns 0, false → default used.
  • GADFLY_TRIGGER_PHRASE: empty string → ${GADFLY_TRIGGER_PHRASE:-@gadfly review} at entrypoint.sh:53 substitutes the default (bash :- covers both unset and empty).
  • GADFLY_ALLOWED_USERS: empty string → [ -n "${GADFLY_ALLOWED_USERS:-}" ] is false at entrypoint.sh:71, falls through to the collaborator check.
  • GADFLY_PROVIDER: empty string → ${GADFLY_PROVIDER:-ollama-cloud} at entrypoint.sh:159 substitutes the default.
  • GADFLY_MODELS: empty string → the chained ${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}} at entrypoint.sh:156 resolves to qwen3-coder:480b-cloud.
  • Required secrets (GITEA_TOKEN, etc.): if a consumer lacks them, they resolve empty → entrypoint.sh:61 fails with : "${GITEA_TOKEN:?GITEA_TOKEN required}" — a clear, early failure. Optional secrets (Claude Code, findings store, foreman endpoints) are empty → harmlessly disabled.
  • timeout_minutes: defaults to 30; the dogfood caller overrides to 90. The type: number input with a sensible default means a consumer who omits it gets a reasonable wall-clock cap.

The if: guard in examples/reusable.yml:41-42 omits the github.event.issue.pull_request check that the dogfood caller has, so a comment on a plain issue would briefly start a job — but entrypoint.sh:103-105 catches it and exits 0 before any review work. Minor inefficiency, not a correctness or safety issue.

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

<!-- gadfly-review:ollama:deepseek-v4-pro:cloud --> ### 🪰 Gadfly review — `deepseek-v4-pro:cloud` (ollama-cloud) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> ## Verdict: Minor issues - **`examples/reusable.yml:40-42` — Actor gate is a single-username placeholder with no defense-in-depth for the `issue_comment` trigger.** The example's `if:` gate checks only `github.actor == 'your-username'`, and the `allowed_users` input is also `"your-username"`. If a consumer copies this verbatim and only changes the username in one place but not the other, the two gates will be out of sync. More importantly, the example's `if:` gate has no `github.event.issue.pull_request` guard — unlike the dogfood caller at `adversarial-review.yml:38` which checks `github.event.issue.pull_request` before evaluating the actor. Without that guard, a comment on a plain *issue* (not a PR) would pass the `if:` gate (since `github.event_name != 'issue_comment'` is false, and `github.actor == 'your-username'` could be true), causing the job to start, pull the image, and inject secrets — only to have the entrypoint later discover the issue isn't a PR and exit. The entrypoint's `actor_allowed` + PR-existence check (line 103) still prevents any actual review, but secrets are already in the container's environment at that point. The dogfood caller correctly nests the actor check inside `github.event.issue.pull_request`; the example should mirror that pattern. *(Verified by reading both `adversarial-review.yml:36-41` and `examples/reusable.yml:40-42`.)* - **`.gitea/workflows/review-reusable.yml:51` — `GITEA_TOKEN` is implicitly required but never declared as a secret input.** The reusable workflow consumes `${{ secrets.GITEA_TOKEN }}` (the built-in Actions token) via `secrets: inherit`. This works because Gitea automatically provides `GITEA_TOKEN` to every workflow. However, if a consumer were to use `secrets: inherit` from a context where `GITEA_TOKEN` is somehow not available (e.g., a workflow with `permissions: {}` or a misconfigured runner), the container would fail at the `: "${GITEA_TOKEN:?GITEA_TOKEN required}"` check in `entrypoint.sh:61`. This is a robustness concern rather than a security vulnerability — the token is always present in normal Gitea Actions operation. The example caller correctly sets `permissions: { issues: write, pull-requests: write, contents: read }` which ensures the token has the needed scopes. *(Verified by reading `entrypoint.sh:60-63` and `examples/reusable.yml:27-30`.)* No injection, authz-bypass, or credential-leakage vulnerabilities introduced by this PR. The entrypoint's `actor_allowed` check (line 68-83) and the `COMMENT_BODY` glob-match (line 95-96) remain the same safe patterns as before. The `ACTOR` value interpolated into the API URL path at `entrypoint.sh:81` is a pre-existing concern (Gitea usernames are restricted to safe characters, so it's low-risk), not introduced here. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## Verdict: Minor issues - **`specialist_suite` input is declared but never plumbed to any env var** — `.gitea/workflows/review-reusable.yml:34`. The `workflow_call` accepts `specialist_suite` (comment says "reserved / future"), but the `env:` block (lines 73–84) has no `GADFLY_SPECIALIST_SUITE: ${{ inputs.specialist_suite }}` mapping. I verified by reading the full file and grepping the entire repo: no Go code reads `GADFLY_SPECIALIST_SUITE` either. A consumer who sets `specialist_suite: "something"` gets a silent no-op — the value is accepted and discarded. Either remove the input until it's actually wired up, or add the env mapping (and the Go-side reader) now. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> No material issues found. I verified the full chain — reusable workflow → entrypoint.sh → Go binary — for every env var the diff introduces. The design consistently handles the empty-string default correctly at each layer: - **`GADFLY_SPECIALISTS`, `GADFLY_TIMEOUT_SECS`, `GADFLY_MAX_STEPS`, `GADFLY_WORKER_MODEL`**: empty string → Go's `envInt` / `strings.TrimSpace` checks fall through to built-in defaults (`specialists.go:136`, `main.go:450-459`, `model.go:115`). - **`GADFLY_PROVIDER_CONCURRENCY`**: empty string → `provider_cap` in `entrypoint.sh:167-176` iterates one empty element, finds no match, returns `DEFAULT_CONC` (1). Malformed values like `"abc"` or `"0"` are caught by `[ "$cap" -ge 1 ] || cap=1` at line 208. - **`GADFLY_PROVIDER_LENS_CONCURRENCY`**: empty string → `providerOverride` in `main.go:291-301` splits `""` into `[""]`, `strings.Cut` returns `!ok`, loop exits, returns `0, false` → default used. - **`GADFLY_TRIGGER_PHRASE`**: empty string → `${GADFLY_TRIGGER_PHRASE:-@gadfly review}` at `entrypoint.sh:53` substitutes the default (bash `:-` covers both unset and empty). - **`GADFLY_ALLOWED_USERS`**: empty string → `[ -n "${GADFLY_ALLOWED_USERS:-}" ]` is false at `entrypoint.sh:71`, falls through to the collaborator check. - **`GADFLY_PROVIDER`**: empty string → `${GADFLY_PROVIDER:-ollama-cloud}` at `entrypoint.sh:159` substitutes the default. - **`GADFLY_MODELS`**: empty string → the chained `${GADFLY_MODELS:-${OLLAMA_REVIEW_MODELS:-$DEFAULT_MODELS}}` at `entrypoint.sh:156` resolves to `qwen3-coder:480b-cloud`. - **Required secrets** (`GITEA_TOKEN`, etc.): if a consumer lacks them, they resolve empty → `entrypoint.sh:61` fails with `: "${GITEA_TOKEN:?GITEA_TOKEN required}"` — a clear, early failure. Optional secrets (Claude Code, findings store, foreman endpoints) are empty → harmlessly disabled. - **`timeout_minutes`**: defaults to 30; the dogfood caller overrides to 90. The `type: number` input with a sensible default means a consumer who omits it gets a reasonable wall-clock cap. The `if:` guard in `examples/reusable.yml:41-42` omits the `github.event.issue.pull_request` check that the dogfood caller has, so a comment on a plain issue would briefly start a job — but `entrypoint.sh:103-105` catches it and exits 0 before any review work. Minor inefficiency, not a correctness or safety issue. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 8m 11s</sub>

🪰 Gadfly review — nemotron-3-super:cloud (ollama-cloud)

Verdict: No material issues found — 3 reviewers: security, correctness, error-handling

🔒 Security — No material issues found

No material issues found.

🎯 Correctness — No material issues found

No material issues found

The changes introduce a reusable workflow for the adversarial review job, replacing the previous inline workflow with a call to .gitea/workflows/review-reusable.yml. After verifying the changes:

  1. The reusable workflow correctly maps all inputs to environment variables with appropriate defaults (empty string uses image defaults)
  2. The dogfood workflow (adversarial-review.yml) correctly passes all necessary configuration as inputs:
    • Models, specialists, concurrency settings, timeouts, and allowed users match the previous inline configuration
    • Secrets are properly inherited via secrets: inherit
    • Event context variables are correctly forwarded from the caller's github context
  3. Timeouts are correctly mapped:
    • Job-level timeout-minutes maps to workflow input
    • Per-lens timeout_secs and max_steps are preserved
  4. All provider authentication and optional configuration (endpoints, findings) are properly handled via secrets inheritance
  5. The Docker image pin remains consistent (sha-c342bdb)
  6. Documentation updates correctly reflect the new reusable workflow pattern

No correctness issues in logic, behavior, or configuration mapping were found. The change correctly centralizes the workflow while maintaining identical functionality.

Note: Verified that the reusable workflow's input definitions match usage in the dogfood workflow and examples, and that all environment variable mappings are correct. The change is purely refactoring with no behavioral alteration.

🧯 Error handling & edge cases — No material issues found

No material issues found

The changes introduce a reusable workflow and update the caller workflows and documentation. From an error handling and edge cases perspective:

  • The new reusable workflow (review-reusable.yml) defines all inputs as optional with sensible defaults (empty strings for string inputs, default number for timeout_minutes). Empty string inputs correctly fall back to the image's built-in defaults, avoiding missing configuration issues.
  • The workflow uses secrets: inherit which safely passes through secrets; undefined secrets resolve to empty strings, which the entrypoint handles gracefully (e.g., empty OLLAMA_CLOUD_API_KEY results in a harmless "not configured" notice).
  • The caller workflows (adversarial-review.yml and examples/reusable.yml) pass appropriate values for inputs they override, maintaining correct types (e.g., timeout_minutes as a number).
  • The entrypoint.sh already contains defensive checks for required environment variables (exiting with error messages if missing), optional feature flags (status board, findings telemetry), and error suppression for advisory actions (reactions, telemetry POSTs).
  • Edge cases like empty concurrency strings are handled in provider_cap() (falling back to default concurrency of 1), and negative/zero concurrency values are corrected to minimum 1.
  • No new error handling gaps were introduced; the changes reuse existing, already-defensive entrypoint logic.

All error handling and edge cases appear properly addressed in the context of this PR's changes.

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

<!-- gadfly-review:ollama:nemotron-3-super:cloud --> ### 🪰 Gadfly review — `nemotron-3-super:cloud` (ollama-cloud) **Verdict: No material issues found** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> No material issues found. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> No material issues found The changes introduce a reusable workflow for the adversarial review job, replacing the previous inline workflow with a call to `.gitea/workflows/review-reusable.yml`. After verifying the changes: 1. The reusable workflow correctly maps all inputs to environment variables with appropriate defaults (empty string uses image defaults) 2. The dogfood workflow (adversarial-review.yml) correctly passes all necessary configuration as inputs: - Models, specialists, concurrency settings, timeouts, and allowed users match the previous inline configuration - Secrets are properly inherited via `secrets: inherit` - Event context variables are correctly forwarded from the caller's github context 3. Timeouts are correctly mapped: - Job-level timeout-minutes maps to workflow input - Per-lens timeout_secs and max_steps are preserved 4. All provider authentication and optional configuration (endpoints, findings) are properly handled via secrets inheritance 5. The Docker image pin remains consistent (sha-c342bdb) 6. Documentation updates correctly reflect the new reusable workflow pattern No correctness issues in logic, behavior, or configuration mapping were found. The change correctly centralizes the workflow while maintaining identical functionality. Note: Verified that the reusable workflow's input definitions match usage in the dogfood workflow and examples, and that all environment variable mappings are correct. The change is purely refactoring with no behavioral alteration. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> No material issues found The changes introduce a reusable workflow and update the caller workflows and documentation. From an error handling and edge cases perspective: - The new reusable workflow (review-reusable.yml) defines all inputs as optional with sensible defaults (empty strings for string inputs, default number for timeout_minutes). Empty string inputs correctly fall back to the image's built-in defaults, avoiding missing configuration issues. - The workflow uses `secrets: inherit` which safely passes through secrets; undefined secrets resolve to empty strings, which the entrypoint handles gracefully (e.g., empty OLLAMA_CLOUD_API_KEY results in a harmless "not configured" notice). - The caller workflows (adversarial-review.yml and examples/reusable.yml) pass appropriate values for inputs they override, maintaining correct types (e.g., timeout_minutes as a number). - The entrypoint.sh already contains defensive checks for required environment variables (exiting with error messages if missing), optional feature flags (status board, findings telemetry), and error suppression for advisory actions (reactions, telemetry POSTs). - Edge cases like empty concurrency strings are handled in provider_cap() (falling back to default concurrency of 1), and negative/zero concurrency values are corrected to minimum 1. - No new error handling gaps were introduced; the changes reuse existing, already-defensive entrypoint logic. All error handling and edge cases appear properly addressed in the context of this PR's changes. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 8m 1s</sub>

🪰 Gadfly review — qwen3-coder:480b-cloud (ollama-cloud)

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/review-reusable.yml:51 — The GITEA_TOKEN secret is used directly from the caller's context. While this is necessary for the workflow to post comments, it's critical that consumers only call this reusable workflow from trusted contexts, as it will have write access to pull requests. The documentation should more strongly emphasize this trust requirement.
  • .gitea/workflows/review-reusable.yml:68-69 — Hardcoded references to GADFLY_ENDPOINT_M1 and GADFLY_ENDPOINT_M5 mean that consumers with different endpoint names must use the full stub. This is documented but creates a slight inconsistency in the "reusable" abstraction.
  • examples/reusable.yml:49 — The example placeholder "your-username" for allowed_users might be overlooked by users copying the workflow, potentially leading to unauthorized comment triggers if not updated.
🎯 Correctness — No material issues found

VERDICT: No material issues found

  • The refactoring to use a reusable workflow appears to be correctly implemented. The adversarial-review.yml now calls the reusable workflow with the correct parameters.
  • All the necessary environment variables and secrets are properly passed through to the reusable workflow.
  • The inputs in the reusable workflow match the parameters passed by the caller.
  • The timeout configuration is consistent between the caller and the reusable workflow, with the reusable workflow correctly using timeout_minutes as a number type with a default of 30 minutes, overridden to 90 minutes by the caller.
  • The concurrency settings for providers and provider-lens combinations are correctly mapped from the caller to the reusable workflow inputs.
🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/review-reusable.yml:40 — The timeout_minutes input defaults to 30, but the calling workflow (.gitea/workflows/adversarial-review.yml) explicitly sets it to 90. This is correct behavior, but the default of 30 may be too low for complex reviews, potentially leading to premature job termination. Consider raising the default to 60 or 90 minutes to accommodate longer-running review tasks.

  • .gitea/workflows/review-reusable.yml:68-69 — The workflow only maps two specific GADFLY_ENDPOINT_* secrets (M1 and M5). As noted in the comments, consumers with other named endpoints must use the full stub. While this is documented, it's an edge case that could lead to misconfiguration if users aren't careful. No error handling is present for missing endpoint secrets, which could result in silent failures or degraded functionality.

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

<!-- gadfly-review:ollama:qwen3-coder:480b-cloud --> ### 🪰 Gadfly review — `qwen3-coder:480b-cloud` (ollama-cloud) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> VERDICT: Minor issues - `.gitea/workflows/review-reusable.yml:51` — The `GITEA_TOKEN` secret is used directly from the caller's context. While this is necessary for the workflow to post comments, it's critical that consumers only call this reusable workflow from trusted contexts, as it will have write access to pull requests. The documentation should more strongly emphasize this trust requirement. - `.gitea/workflows/review-reusable.yml:68-69` — Hardcoded references to `GADFLY_ENDPOINT_M1` and `GADFLY_ENDPOINT_M5` mean that consumers with different endpoint names must use the full stub. This is documented but creates a slight inconsistency in the "reusable" abstraction. - `examples/reusable.yml:49` — The example placeholder `"your-username"` for `allowed_users` might be overlooked by users copying the workflow, potentially leading to unauthorized comment triggers if not updated. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> VERDICT: No material issues found - The refactoring to use a reusable workflow appears to be correctly implemented. The `adversarial-review.yml` now calls the reusable workflow with the correct parameters. - All the necessary environment variables and secrets are properly passed through to the reusable workflow. - The inputs in the reusable workflow match the parameters passed by the caller. - The timeout configuration is consistent between the caller and the reusable workflow, with the reusable workflow correctly using `timeout_minutes` as a number type with a default of 30 minutes, overridden to 90 minutes by the caller. - The concurrency settings for providers and provider-lens combinations are correctly mapped from the caller to the reusable workflow inputs. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> VERDICT: Minor issues - `.gitea/workflows/review-reusable.yml:40` — The `timeout_minutes` input defaults to 30, but the calling workflow (`.gitea/workflows/adversarial-review.yml`) explicitly sets it to 90. This is correct behavior, but the default of 30 may be too low for complex reviews, potentially leading to premature job termination. Consider raising the default to 60 or 90 minutes to accommodate longer-running review tasks. - `.gitea/workflows/review-reusable.yml:68-69` — The workflow only maps two specific `GADFLY_ENDPOINT_*` secrets (`M1` and `M5`). As noted in the comments, consumers with other named endpoints must use the full stub. While this is documented, it's an edge case that could lead to misconfiguration if users aren't careful. No error handling is present for missing endpoint secrets, which could result in silent failures or degraded functionality. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 2m 10s</sub>

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

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

I've verified the three files involved. Let me confirm the factual basis of the surviving finding.

The draft's "verified" items (authz boundary preserved) are confirmations, not findings — the gate only needs to adjudicate the one Minor finding about examples/reusable.yml.

Confirmed against the code:

  • examples/reusable.yml:43uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main (mutable ref) ✓ — note the draft cited line 42; the uses:@main line is 43.
  • examples/reusable.yml:44secrets: inherit
  • The header comment (line 10) does say "Pin @ to a Gadfly tag/branch" — though it explicitly sanctions a branch, so the example isn't strictly self-contradicting; it's a hardening gap, not an inconsistency.
  • The image is pinned to immutable :sha-c342bdb at review-reusable.yml:47 (draft cited line 42; correct line is 47).

The core security reasoning — a mutable workflow-definition ref combined with secrets: inherit widens the trust surface beyond the mapped keys — is factually grounded and valid. I'll keep the finding with corrected line references and tightened framing.


VERDICT: Minor issues

Minor:

  • examples/reusable.yml:43 (uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main) — the documented subscribe example pins the reusable workflow to the mutable @main ref while using secrets: inherit (line 44). With secrets: inherit, the consumer's entire secret set is exposed to whatever YAML is on gadfly's main at trigger time. The image is correctly pinned to an immutable :sha-c342bdb (review-reusable.yml:47), but the workflow definition itself is a second, mutable trust point: a future edit to review-reusable.yml on main (or a branch compromise) could add an exfil step running with the consumer's inherited secrets. The header comment (line 10) already notes you can pin @<ref>, but the shipped example demonstrates the less-pinned @main.
    • Impact: modest supply-chain / secret-exposure consideration for subscribing consumers — real but low (gadfly is a trusted, advisory-only project), since secrets: inherit widens the blast radius beyond the explicitly mapped keys.
    • Fix: have the example uses: pin an immutable release tag (e.g. @v1) and add a one-line note that secrets: inherit grants the pinned ref access to all caller secrets, so pinning to a tag (not main) is the safer default.

Nothing else in the security lane is materially wrong — the actor/authn gate (adversarial-review.yml:36-42 keeps the identical steve/fizi/dazed allow-list; allowed_usersGADFLY_ALLOWED_USERS belt-and-suspenders preserved) and the secret plumbing are carried over faithfully from the previous inline stub.

🎯 Correctness — Minor issues

I've verified the single finding against the actual files. The draft's one finding is confirmed: adversarial-review.yml:46 hardcodes specialists: "security,correctness,error-handling", and the diff shows the old job used GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}. The repo-var override is genuinely dropped (it now no-ops for anyone who had set it), and since a reusable workflow can't read the caller's vars, the caller would need to forward the expression to preserve it. The supporting confirmations (env-var parity, empty⇒default plumbing, claude-code lane) are accurate.


VERDICT: Minor issues

The refactor is a faithful move of logic from the self-contained stub into the reusable workflow. Env-var parity holds, every input that defaults to "" is consumed by code that treats empty as unset (so a passed-through empty string yields the built-in default), and the claude-code lane plumbing is unchanged.

Finding (minor)

  • .gitea/workflows/adversarial-review.yml:46 — silently drops the vars.GADFLY_SPECIALISTS repo-var override. The old job set GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}, letting a maintainer override the lens suite via a repo variable without editing YAML. The new caller hardcodes specialists: "security,correctness,error-handling". Impact: low — the hardcoded value equals the prior default, so behavior only changes for someone who had set the repo var (the override now no-ops). A reusable workflow can't read the caller's vars, but the caller can still forward it: specialists: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}. Suggest restoring that expression if the override was intended to stay functional.
🧯 Error handling & edge cases — No material issues found

I've verified the core of this diff. Here's my review through the error-handling & edge-cases lens.


VERDICT: No material issues found

This PR is workflow-plumbing + docs. The only edge-case-bearing claim is the design's load-bearing assumption — "an empty input value falls back to the image's own built-in default" — since the reusable workflow now unconditionally sets every GADFLY_* env var, including to "". I traced that assumption through the actual consuming code rather than trusting the comments:

  • GADFLY_MODELS / GADFLY_TRIGGER_PHRASEentrypoint.sh:156,53 use ${VAR:-default}, which fires on empty and unset. Empty ⇒ default. ✓
  • GADFLY_TIMEOUT_SECS / GADFLY_MAX_STEPS — both go through envInt (cmd/gadfly/main.go:450-460), which explicitly returns the default on "", on non-numeric, and on n <= 0. Empty ⇒ default. ✓ (Verified by reading the function — the <= 0 guard also means a stray "0" can't disable the timeout.)
  • GADFLY_PROVIDER_LENS_CONCURRENCYproviderOverride (main.go:291-302) splits on ,, and an empty string yields no match → (0, false) → caller falls back. ✓
  • GADFLY_PROVIDER / GADFLY_SPECIALISTS / GADFLY_WORKER_MODEL — all read via strings.TrimSpace(os.Getenv(...)) then == "" checks (model.go:55-57, specialists.go:136, model.go:115). Empty ⇒ off/default. ✓
  • GADFLY_BASE_URL — the one I was most worried about, since the reusable always sets it and model.go:107 errors when it's "set but unsupported." Confirmed at model.go:59,65: it's TrimSpace'd and an empty value takes the baseURL == "" branch (majordomo registry resolution) before reaching the error path. Setting it to "" is harmless. ✓

So the empty-string fallback holds for every plumbed variable — no silent no-op or parse-panic on the unhappy "caller overrides nothing" path.

Two non-issues I checked and am deliberately not flagging:

  • The PR: / event-context expressions (${{ github.event.pull_request.number || ... }}) are moved verbatim from the old adversarial-review.yml into the reusable — no new edge case introduced by this diff; whether the caller's github.event reaches the reusable is a will-it-run/correctness concern (and the PR author already flagged it), not an error-handling regression.
  • timeout-minutes: ${{ inputs.timeout_minutes }} is type: number, default: 30, so it can't be empty/unparseable; the slim stub inheriting a 30-min wall-clock vs. the old 90 is a config-default tradeoff, not a swallowed error.

Outside my lens: the reusable workflow has no permissions: block and no actor gate of its own — both are pushed onto the caller (the example stub includes them). Whether that's safe is a security-lens question; flagging only because a consumer who copies the slim stub but deletes the if: gate would lose the comment-trigger guard.

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

<!-- gadfly-review:ollama:claude-code/opus:max --> ### 🪰 Gadfly review — `claude-code/opus:max` (claude-code) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> I've verified the three files involved. Let me confirm the factual basis of the surviving finding. The draft's "verified" items (authz boundary preserved) are confirmations, not findings — the gate only needs to adjudicate the one Minor finding about `examples/reusable.yml`. Confirmed against the code: - `examples/reusable.yml:43` — `uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main` (mutable ref) ✓ — note the draft cited line 42; the `uses:@main` line is 43. - `examples/reusable.yml:44` — `secrets: inherit` ✓ - The header comment (line 10) does say "Pin @<ref> to a Gadfly tag/branch" — though it explicitly sanctions a *branch*, so the example isn't strictly self-contradicting; it's a hardening gap, not an inconsistency. - The image is pinned to immutable `:sha-c342bdb` at `review-reusable.yml:47` (draft cited line 42; correct line is 47). The core security reasoning — a mutable workflow-definition ref combined with `secrets: inherit` widens the trust surface beyond the mapped keys — is factually grounded and valid. I'll keep the finding with corrected line references and tightened framing. --- ## VERDICT: Minor issues **Minor:** - `examples/reusable.yml:43` (`uses: steve/gadfly/.gitea/workflows/review-reusable.yml@main`) — the documented subscribe example pins the reusable workflow to the **mutable `@main` ref** while using `secrets: inherit` (line 44). With `secrets: inherit`, the consumer's *entire* secret set is exposed to whatever YAML is on gadfly's `main` at trigger time. The image is correctly pinned to an immutable `:sha-c342bdb` (`review-reusable.yml:47`), but the workflow *definition* itself is a second, mutable trust point: a future edit to `review-reusable.yml` on `main` (or a branch compromise) could add an exfil step running with the consumer's inherited secrets. The header comment (line 10) already notes you can pin `@<ref>`, but the shipped example demonstrates the less-pinned `@main`. - **Impact:** modest supply-chain / secret-exposure consideration for subscribing consumers — real but low (gadfly is a trusted, advisory-only project), since `secrets: inherit` widens the blast radius beyond the explicitly mapped keys. - **Fix:** have the example `uses:` pin an immutable release tag (e.g. `@v1`) and add a one-line note that `secrets: inherit` grants the pinned ref access to all caller secrets, so pinning to a tag (not `main`) is the safer default. Nothing else in the security lane is materially wrong — the actor/authn gate (`adversarial-review.yml:36-42` keeps the identical `steve`/`fizi`/`dazed` allow-list; `allowed_users` → `GADFLY_ALLOWED_USERS` belt-and-suspenders preserved) and the secret plumbing are carried over faithfully from the previous inline stub. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> I've verified the single finding against the actual files. The draft's one finding is confirmed: `adversarial-review.yml:46` hardcodes `specialists: "security,correctness,error-handling"`, and the diff shows the old job used `GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`. The repo-var override is genuinely dropped (it now no-ops for anyone who had set it), and since a reusable workflow can't read the caller's `vars`, the caller would need to forward the expression to preserve it. The supporting confirmations (env-var parity, empty⇒default plumbing, claude-code lane) are accurate. --- ## VERDICT: Minor issues The refactor is a faithful move of logic from the self-contained stub into the reusable workflow. Env-var parity holds, every input that defaults to `""` is consumed by code that treats empty as unset (so a passed-through empty string yields the built-in default), and the `claude-code` lane plumbing is unchanged. ### Finding (minor) - **`.gitea/workflows/adversarial-review.yml:46` — silently drops the `vars.GADFLY_SPECIALISTS` repo-var override.** The old job set `GADFLY_SPECIALISTS: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`, letting a maintainer override the lens suite via a repo variable without editing YAML. The new caller hardcodes `specialists: "security,correctness,error-handling"`. Impact: low — the hardcoded value equals the prior default, so behavior only changes for someone who had set the repo var (the override now no-ops). A reusable workflow can't read the caller's `vars`, but the caller *can* still forward it: `specialists: ${{ vars.GADFLY_SPECIALISTS || 'security,correctness,error-handling' }}`. Suggest restoring that expression if the override was intended to stay functional. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> I've verified the core of this diff. Here's my review through the error-handling & edge-cases lens. --- **VERDICT: No material issues found** This PR is workflow-plumbing + docs. The only edge-case-bearing claim is the design's load-bearing assumption — *"an empty input value falls back to the image's own built-in default"* — since the reusable workflow now **unconditionally** sets every `GADFLY_*` env var, including to `""`. I traced that assumption through the actual consuming code rather than trusting the comments: - **`GADFLY_MODELS` / `GADFLY_TRIGGER_PHRASE`** — `entrypoint.sh:156,53` use `${VAR:-default}`, which fires on empty *and* unset. Empty ⇒ default. ✓ - **`GADFLY_TIMEOUT_SECS` / `GADFLY_MAX_STEPS`** — both go through `envInt` (`cmd/gadfly/main.go:450-460`), which explicitly returns the default on `""`, on non-numeric, and on `n <= 0`. Empty ⇒ default. ✓ (Verified by reading the function — the `<= 0` guard also means a stray `"0"` can't disable the timeout.) - **`GADFLY_PROVIDER_LENS_CONCURRENCY`** — `providerOverride` (`main.go:291-302`) splits on `,`, and an empty string yields no match → `(0, false)` → caller falls back. ✓ - **`GADFLY_PROVIDER` / `GADFLY_SPECIALISTS` / `GADFLY_WORKER_MODEL`** — all read via `strings.TrimSpace(os.Getenv(...))` then `== ""` checks (`model.go:55-57`, `specialists.go:136`, `model.go:115`). Empty ⇒ off/default. ✓ - **`GADFLY_BASE_URL`** — the one I was most worried about, since the reusable always sets it and `model.go:107` errors when it's "set but unsupported." Confirmed at `model.go:59,65`: it's `TrimSpace`'d and an empty value takes the `baseURL == ""` branch (majordomo registry resolution) *before* reaching the error path. Setting it to `""` is harmless. ✓ So the empty-string fallback holds for every plumbed variable — no silent no-op or parse-panic on the unhappy "caller overrides nothing" path. Two non-issues I checked and am deliberately **not** flagging: - The `PR:` / event-context expressions (`${{ github.event.pull_request.number || ... }}`) are moved **verbatim** from the old `adversarial-review.yml` into the reusable — no new edge case introduced by this diff; whether the caller's `github.event` reaches the reusable is a will-it-run/correctness concern (and the PR author already flagged it), not an error-handling regression. - `timeout-minutes: ${{ inputs.timeout_minutes }}` is `type: number, default: 30`, so it can't be empty/unparseable; the slim stub inheriting a 30-min wall-clock vs. the old 90 is a config-default tradeoff, not a swallowed error. *Outside my lens:* the reusable workflow has no `permissions:` block and no actor gate of its own — both are pushed onto the caller (the example stub includes them). Whether that's safe is a security-lens question; flagging only because a consumer who copies the slim stub but deletes the `if:` gate would lose the comment-trigger guard. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 11s</sub>
steve added 1 commit 2026-06-27 23:41:47 +00:00
fix: fold in PR #8 review findings (reusable workflow)
Build & push image / build-and-push (pull_request) Successful in 7s
27aa92a6e0
The swarm reviewed PR #8 *through the reusable path itself* — proving
github.event context propagates into a workflow_call reusable workflow on
this act_runner (the one part the probes hadn't covered). Folded in the
warranted findings:

- review-reusable.yml: bump timeout_minutes default 30 -> 45 (a multi-
  model/slow-lens review can exceed 30); map the generic GADFLY_API_KEY
  secret (was missing); add an explicit permissions block; drop the dead
  `specialist_suite` input.
- examples/reusable.yml: actor gate now also requires
  github.event.issue.pull_request (so an issue-comment on a plain issue
  doesn't waste a runner), and a note to pin @<ref> to a release tag.

Graded ~70 findings (heavy clustering): the real ones above + several
by-design/documented (inputs replace vars-overrides; only M1/M5 named
endpoints mapped) and many false positives (IS_DRAFT pattern, GITEA_TOKEN
via inherit, "empty specialists" misread — empty does default).

YAML validated; Go unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve merged commit 5f86062a5a into main 2026-06-27 23:42:02 +00:00
steve deleted branch feat/phase4-reusable-wf 2026-06-27 23:42:02 +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#8