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: # Pinned opencode version — used to install it and to key the CI cache below. env: OPENCODE_VERSION: "1.17.13" 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. # KNOWN CAVEAT: a run triggered on the PR thread groups under the PR number, not the origin # issue (that mapping is only resolved later, in route.sh) — so an issue-thread run and a # PR-thread run for the SAME work item can overlap. Accepted: they post to different threads, # and the branch is only mutated by dev runs, which resume serially per thread. 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' || github.event.comment.user.login == 'intern') && !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') || contains(github.event.comment.body, '@intern'))) runs-on: ci-runner # Job-level backstop (the per-attempt `timeout` in run-agent.sh is the primary guard): a wedged # job must never hold the single runner slot for hours. timeout-minutes: 45 steps: - 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 }} TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }} run: bash "$SCRIPTS/route.sh" - name: Acknowledge with 👀 (as the routed agent) if: steps.prep.outputs.mode != 'skip' env: 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 || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }} CID: ${{ github.event.comment.id }} NUM: ${{ github.event.issue.number }} run: | [ -n "$SELF_TOKEN" ] || { echo "no agent token — skipping 👀"; exit 0; } 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 $SELF_TOKEN" -H "Content-Type: application/json" \ "$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true - name: Cache opencode CLI if: steps.prep.outputs.mode != 'skip' continue-on-error: true # a cache backend hiccup must never fail an agent run uses: actions/cache@v4 with: path: ~/.opencode key: opencode-${{ runner.os }}-${{ env.OPENCODE_VERSION }} - name: Cache Playwright browsers + npm (browser agents only) if: steps.prep.outputs.mode != 'skip' && (steps.prep.outputs.name == 'senior' || steps.prep.outputs.name == 'lead' || steps.prep.outputs.name == 'qa') continue-on-error: true uses: actions/cache@v4 with: path: | ~/.cache/ms-playwright ~/.npm key: playwright-npm-${{ runner.os }}-v1 - name: Install opencode + provider config (+ Playwright MCP for browser agents) if: steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts OLLAMA_URL: ${{ secrets.OLLAMA_URL }} OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_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/) if: steps.prep.outputs.mode != 'skip' # Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under # `.gitea/agent-skills//` (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) if: steps.prep.outputs.mode != 'skip' # 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) if: steps.prep.outputs.mode != 'skip' # 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) if: steps.prep.outputs.mode != 'skip' 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) if: steps.prep.outputs.mode != 'skip' env: SCRIPTS: ${{ runner.temp }}/agents-scripts GT: ${{ secrets.GITEA_TOKEN }} NUM: ${{ github.event.issue.number }} run: bash "$SCRIPTS/fetch-thread.sh" - name: Run agent if: steps.prep.outputs.mode != 'skip' id: run env: SCRIPTS: ${{ runner.temp }}/agents-scripts ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_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 || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }} NAME: ${{ steps.prep.outputs.name }} MODEL: ${{ steps.prep.outputs.model }} VISION: ${{ steps.prep.outputs.vision }} MODE: ${{ steps.prep.outputs.mode }} WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread) 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 if: steps.prep.outputs.mode != 'skip' 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 if: steps.prep.outputs.mode != 'skip' 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 }} TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }} NAME: ${{ steps.prep.outputs.name }} MODE: ${{ steps.prep.outputs.mode }} WORKMODE: ${{ steps.prep.outputs.workmode }} 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() && steps.prep.outputs.mode != 'skip' 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 }} TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }} 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 👀) if: steps.prep.outputs.mode != 'skip' env: 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 || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }} CID: ${{ github.event.comment.id }} NUM: ${{ github.event.issue.number }} run: | [ -n "$SELF_TOKEN" ] || exit 0 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 $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true - name: Mark failed with 😕 (remove 👀) if: failure() && steps.prep.outputs.mode != 'skip' env: 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 || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }} CID: ${{ github.event.comment.id }} NUM: ${{ github.event.issue.number }} run: | [ -n "$SELF_TOKEN" ] || exit 0 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 $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true