248 lines
13 KiB
YAML
248 lines
13 KiB
YAML
name: agent
|
|
# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues
|
|
# and invokes this via: uses: ffaerber/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:
|
|
# 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' &&
|
|
!contains(github.event.comment.body, '🤖') &&
|
|
(contains(github.event.comment.body, '@pm') ||
|
|
contains(github.event.comment.body, '@intern') ||
|
|
contains(github.event.comment.body, '@junior') ||
|
|
contains(github.event.comment.body, '@senior') ||
|
|
contains(github.event.comment.body, '@lead') ||
|
|
contains(github.event.comment.body, '@qa')))
|
|
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: ffaerber/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_INTERN: ${{ secrets.TOKEN_INTERN }}
|
|
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
|
|
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
|
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
|
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: Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it)
|
|
# 1) Writes the deploy key + an SSH config alias so the agent can run
|
|
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist).
|
|
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any
|
|
# downstream repo's dev agent discovers this capability via OpenCode's skill registry
|
|
# rather than having to trial against the permission allowlist. Only emitted when the
|
|
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY).
|
|
# All three secrets are passed via env and never inlined into shell — this shared workflow
|
|
# runs in repos that don't have them and must not fail there.
|
|
env:
|
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
|
SWARM_HOST: ${{ secrets.SWARM_HOST }}
|
|
SWARM_USER: ${{ secrets.SWARM_USER }}
|
|
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
|
|
run: bash "$SCRIPTS/skill-node1-ssh.sh"
|
|
|
|
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
|
|
# Mirrors the node1-ssh pattern: emit an opencode Skill file under
|
|
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's
|
|
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the
|
|
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo).
|
|
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
|
|
# broken skill. The token is passed via env and never inlined into shell.
|
|
env:
|
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
|
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
|
run: bash "$SCRIPTS/skill-gitea-api.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 }}
|
|
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write).
|
|
# It is already a required secret for the delegation step below; exposing it here too
|
|
# lets the agent process itself call the Gitea API on demand.
|
|
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
|
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 activity log (tool calls + reasoning) from the event stream
|
|
id: log
|
|
env:
|
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
|
MODE: ${{ steps.prep.outputs.mode }}
|
|
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 }}
|
|
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
|
TOKEN_PM: ${{ secrets.TOKEN_PM }}
|
|
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
|
|
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
|
|
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
|
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
|
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_INTERN: ${{ secrets.TOKEN_INTERN }}
|
|
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
|
|
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
|
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
|
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
|