Files
agents/.gitea/workflows/agent.yml
T
Felix FaerberandClaude Opus 4.8 09c716a8c1
ci / lint (push) Skipped
agents: fix review findings — thread attribution, races, rescue stall, docs, CI
Fixes from a full repo review:

- fetch-thread.sh: attribute每 comment to its REAL author (@pm/@qa/… — agents
  post as their own users now); the old "🤖 @name line at the top" hint pointed
  at headers we removed, leaving every teammate comment anonymous. Also strip
  the hidden loop-prevention marker from bodies (prompt noise).
- agent.yml: per-issue concurrency group (queued, no cancel) — two quick
  comments on one issue no longer race the same ai/issue-N branch.
- rescue-pr.sh: after opening a rescue PR, hand it back into the flow with an
  unmarked @pm trigger (the pm→qa choreography previously stalled silently on
  any rescued run); drop the old "🤖 **@name**" header style; add ops to the
  token case; mark the status note with the hidden marker.
- README: token table said "@qa merges / TOKEN_QA needs write:repository" —
  inverted since the PM-orchestrated flow (@pm merges, autopilot only; @qa is
  read-only). Updated the agent table (descs, node1-ssh moved to homelab) and
  added a "How a task flows" section.
- agents.json: pm/qa descs now describe the orchestrator/reviewer roles (these
  feed the roster prompt agents route by).
- NEW ci.yml: bash -n + shellcheck(-S error) on every script, YAML-parse on
  every workflow, agents.json schema check — the ${x:-{}} brace bug would have
  been caught here before it shipped.
- install-opencode.sh: pin opencode (default 1.17.13, override via
  OPENCODE_VERSION) — a breaking release no longer takes down every agent.
- build-activity-log.sh: ollama/ollama-cloud models are subscription-billed
  (no $/token exists) — label the footer "subscription" instead of a
  misleading $0.0000; metered (anthropic) models keep the real dollar cost.
- route.sh: document that mention-priority is list-order and load-bearing for
  the flow's trigger comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:46:53 +03:00

267 lines
14 KiB
YAML

name: agent
# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues
# and invokes this via: uses: gitea/agents/.gitea/workflows/agent.yml@main (secrets: inherit).
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on:
workflow_call:
jobs:
agent:
# One run at a time PER ISSUE: two quick comments on the same issue would otherwise race —
# both checking out ai/issue-N, pushing (non-fast-forward loss) and double-posting. Queued
# runs wait (no cancel) so every trigger is still processed, just serially.
concurrency:
group: ai-agent-${{ github.repository }}-${{ github.event.issue.number }}
cancel-in-progress: false
# Trusted author only, and only when a known agent is mentioned. This gate is the main
# defense against malicious-issue prompt injection — do not loosen it.
if: >
(github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' ||
github.event.comment.user.login == 'senior' ||
github.event.comment.user.login == 'lead' ||
github.event.comment.user.login == 'qa' ||
github.event.comment.user.login == 'ops') &&
!contains(github.event.comment.body, '🤖') &&
(contains(github.event.comment.body, '@pm') ||
contains(github.event.comment.body, '@junior') ||
contains(github.event.comment.body, '@senior') ||
contains(github.event.comment.body, '@lead') ||
contains(github.event.comment.body, '@qa') ||
contains(github.event.comment.body, '@ops')))
runs-on: ci-runner
steps:
- name: Acknowledge with 👀
env:
GT: ${{ secrets.GITEA_TOKEN }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
"$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }}
# This is a REUSABLE workflow (workflow_call): the checkout above clones the CALLER's repo,
# not this `agents` repo — so the externalized step scripts (in THIS repo under
# .gitea/workflows/scripts/) are NOT on disk yet. Check this repo out into a separate subdir
# and run the scripts from $SCRIPTS. Pinned to @main to match the caller's
# `uses: …/agent.yml@main`, so the scripts and the workflow always move together.
#
# actions/checkout requires `path` to be inside the workspace, so this necessarily lands the
# clone at `.agents-workflow/` INSIDE the tree the dev agent later edits. That checkout dir is
# untracked and looks like a stray artifact: an agent that commits it as a gitlink or
# `rm -rf`s it as "leftover" would destroy the very scripts the post-agent steps run, breaking
# the run with exit 127 and stranding pushed work with no PR (issue #33). To make the run
# immune, the next step copies the scripts to a stable location OUTSIDE the workspace
# (${{ runner.temp }}) and every later step runs from $SCRIPTS there — so nothing the agent
# does to the working tree can break the run's own execution environment.
- name: Fetch shared agent scripts (this repo)
uses: actions/checkout@v4
with:
repository: gitea/agents
ref: main
path: .agents-workflow
token: ${{ secrets.GITEA_TOKEN }}
# Copy the step scripts out of the workspace so the agent cannot break them (issue #33).
# $SCRIPTS points here for every subsequent step, NOT into the in-tree .agents-workflow/.
- name: Stage shared scripts outside the workspace
env:
SRC: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
DST: ${{ runner.temp }}/agents-scripts
run: |
set -eu
rm -rf "$DST"
mkdir -p "$DST"
cp -a "$SRC"/. "$DST"/
chmod -R a+rx "$DST" || true
echo "staged $(ls -1 "$DST" | wc -l) scripts at $DST"
- name: Route agent + prepare branch
id: prep
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
BODY: ${{ github.event.comment.body }} # event text via env, never inline in shell
IBODY: ${{ github.event.issue.body }}
# Comment-vs-issue discriminator. Do NOT use github.event_name here: this is a REUSABLE
# (workflow_call) workflow, so on Gitea event_name is 'workflow_call', not the original
# 'issues'/'issue_comment'. The comment id, however, is reliably present in the forwarded
# payload — empty on an issue-opened event, set on a comment event.
CID: ${{ github.event.comment.id }}
IS_PR: ${{ github.event.issue.pull_request }}
NUM: ${{ github.event.issue.number }}
GT: ${{ secrets.GITEA_TOKEN }}
TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
run: bash "$SCRIPTS/route.sh"
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
NAME: ${{ steps.prep.outputs.name }}
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
run: bash "$SCRIPTS/install-opencode.sh"
- name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
# Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
# `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
# "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
# deploy-target / infra specifics live in the repo they belong to, not in this framework.
# SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
# (whose names this framework can't know) via jq; it never touches disk here in the clear.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }}
WORKSPACE: ${{ github.workspace }}
SECRETS_JSON: ${{ toJSON(secrets) }}
run: bash "$SCRIPTS/install-caller-skills.sh"
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
# Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
# (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
# Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
run: bash "$SCRIPTS/skill-gitea-api.sh"
- name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance)
# Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME) and permission.skill also denies
# it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
# Run-agent step. This step only writes the doc.
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }}
run: bash "$SCRIPTS/skill-gitea-admin.sh"
- name: Inspect / fetch image attachments (download only for vision agents)
id: imgs
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
NUM: ${{ github.event.issue.number }}
VISION: ${{ steps.prep.outputs.vision }}
run: bash "$SCRIPTS/fetch-images.sh"
- name: Fetch the full issue thread (shared memory)
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
NUM: ${{ github.event.issue.number }}
run: bash "$SCRIPTS/fetch-thread.sh"
- name: Run agent
id: run
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
# Only this agent's token is placed in its process env, so no agent can act as another.
# Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
# consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
# for ffaerber/*), so there is no shared-token fallback.
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || '' }}
NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }}
VISION: ${{ steps.prep.outputs.vision }}
MODE: ${{ steps.prep.outputs.mode }}
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
BRANCH: ${{ steps.prep.outputs.branch }}
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
IBODY: ${{ github.event.issue.body }}
CMT: ${{ github.event.comment.body }}
FILES: ${{ steps.imgs.outputs.files }} # opencode -f image flags (vision agents only)
run: bash "$SCRIPTS/run-agent.sh"
- name: Build run report (tool calls + input/output tokens + $ cost) from the event stream
id: log
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
MODE: ${{ steps.prep.outputs.mode }}
MODEL: ${{ steps.prep.outputs.model }} # ollama-cloud models are subscription-billed (no $/token)
run: bash "$SCRIPTS/build-activity-log.sh"
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
BRANCH: ${{ steps.prep.outputs.branch }}
NEW: ${{ steps.prep.outputs.new }}
IS_PR: ${{ github.event.issue.pull_request }} # set when this run is on a PR thread
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the origin issue carries `autopilot`
ISSNUM: ${{ steps.prep.outputs.issnum }} # origin issue number (resolved from branch on PR threads)
run: bash "$SCRIPTS/publish.sh"
# Failure-safe: if any step above failed AFTER a dev agent already pushed commits, the normal
# Publish step never ran, so the work would be stranded on the branch with no PR (issue #33).
# This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from
# $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent.
- name: Rescue — open a PR for pushed work if the run failed
if: failure()
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }}
TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
TOKEN_QA: ${{ secrets.TOKEN_QA }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
NAME: ${{ steps.prep.outputs.name }}
MODE: ${{ steps.prep.outputs.mode }}
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
BRANCH: ${{ steps.prep.outputs.branch }}
run: bash "$SCRIPTS/rescue-pr.sh" || true
- name: Mark done with 🚀 (remove 👀)
env:
GT: ${{ secrets.GITEA_TOKEN }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
- name: Mark failed with 😕 (remove 👀)
if: failure()
env:
GT: ${{ secrets.GITEA_TOKEN }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true