diff --git a/.github/workflows/ai-run.yml b/.github/workflows/ai-run.yml new file mode 100644 index 0000000..a639501 --- /dev/null +++ b/.github/workflows/ai-run.yml @@ -0,0 +1,123 @@ +name: ai-run + +# Runs one AI task on a runner chosen by the repository profile. +# +# `ghapp-scan` dispatches this workflow when `agent-runner/config/repos.json` +# sets `runsOn` for the target repository (e.g. a self-hosted macOS runner for +# an Xcode project). Without `runsOn`, the dispatcher runs the task inline on +# ubuntu-latest as before. +# +# The job mints an installation token for the target organization, clones the +# target into $RUNNER_TEMP and runs the standard runner against it. +on: + workflow_dispatch: + inputs: + repo: + description: "Target repository (owner/name)" + required: true + type: string + issue: + description: "Issue number" + required: true + type: string + runs_on: + description: 'JSON array of runner labels, e.g. ["self-hosted","macos"]' + required: false + type: string + default: '["ubuntu-latest"]' + +permissions: + contents: read + +concurrency: + group: ai-run-${{ inputs.repo }}-${{ inputs.issue }} + cancel-in-progress: false + +jobs: + run: + # If no runner matches these labels the job stays queued: cancel it or fix + # the profile. That is intentional — silently falling back to ubuntu would + # produce unverifiable results for macOS/Windows toolchains. + runs-on: ${{ fromJSON(inputs.runs_on) }} + timeout-minutes: 120 + env: + TARGET: ${{ inputs.repo }} + ISSUE: ${{ inputs.issue }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Resolve target organization + id: target + run: | + set -euo pipefail + ORG="${TARGET%%/*}" + case "$TARGET" in */*) ;; *) echo "::error::repo must be owner/name"; exit 1 ;; esac + { + echo "org=${ORG}" + echo "key=$(printf '%s' "$ORG" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_' | sed 's/_*$//')" + } >> "$GITHUB_OUTPUT" + + - name: Mint token for the target organization + id: mint + env: + EASYGH_APP_ID: ${{ vars[format('EASYGH_APP_ID_{0}', steps.target.outputs.key)] || vars.EASYGH_APP_ID }} + EASYGH_APP_PRIVATE_KEY: ${{ secrets[format('EASYGH_APP_PRIVATE_KEY_{0}', steps.target.outputs.key)] || secrets.EASYGH_APP_PRIVATE_KEY }} + ORG: ${{ steps.target.outputs.org }} + run: node scripts/ghapp-token.mjs + + - name: Clone the target repository + env: + GH_TOKEN: ${{ steps.mint.outputs.token }} + run: | + set -euo pipefail + WORK="${RUNNER_TEMP}/ai-target" + rm -rf "$WORK" + git clone --quiet --single-branch \ + "https://x-access-token:${GH_TOKEN}@github.com/${TARGET}.git" "$WORK" + { + echo "WORK_DIR=${WORK}" + echo "DEFAULT_BRANCH=$(gh api "repos/${TARGET}" --jq .default_branch)" + } >> "$GITHUB_ENV" + + - name: Run the agent runner + env: + GH_TOKEN: ${{ steps.mint.outputs.token }} + GITHUB_REPOSITORY: ${{ inputs.repo }} + ISSUE_NUMBER: ${{ inputs.issue }} + WORK_DIR: ${{ env.WORK_DIR }} + DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }} + RUNNER_TEMP: ${{ runner.temp }} + AGENT: ${{ vars.AI_AGENT || '' }} + MODEL: ${{ vars.AI_MODEL || '' }} + THINKING: ${{ vars.AI_THINKING || '' }} + AI_MAX_ATTEMPTS: ${{ vars.AI_MAX_ATTEMPTS || '3' }} + AI_AUTOMERGE: ${{ vars.AI_AUTOMERGE || 'off' }} + AI_MAX_PRS_PER_DAY: ${{ vars.AI_MAX_PRS_PER_DAY || '10' }} + AI_REVIEWER: ${{ vars.AI_REVIEWER || '' }} + AGENT_BUDGET_MS: "4500000" + PI_TIMEOUT_MS: "900000" + VERIFY_TIMEOUT_MS: "600000" + EASYGH_VERIFY_CMDS: ${{ vars.EASYGH_VERIFY_CMDS }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + ANTHROPIC_BASE_URL: https://api.deepseek.com/anthropic + ANTHROPIC_AUTH_TOKEN: ${{ secrets.DEEPSEEK_API_KEY }} + ANTHROPIC_MODEL: "deepseek-v4-pro[1m]" + ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek-v4-pro[1m]" + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-v4-pro[1m]" + ANTHROPIC_DEFAULT_HAIKU_MODEL: deepseek-v4-flash + CLAUDE_CODE_SUBAGENT_MODEL: deepseek-v4-flash + CLAUDE_CODE_EFFORT_LEVEL: max + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "786432" + run: node agent-runner/src/main.ts + + - name: Upload run logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: ai-run-${{ inputs.repo }}-${{ inputs.issue }} + path: ${{ runner.temp }}/ai-issue-${{ inputs.issue }} + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/ghapp-scan.yml b/.github/workflows/ghapp-scan.yml index 915454f..2a586c2 100644 --- a/.github/workflows/ghapp-scan.yml +++ b/.github/workflows/ghapp-scan.yml @@ -26,6 +26,8 @@ on: permissions: contents: read + # Dispatch ai-run.yml for repositories whose profile pins a runner. + actions: write concurrency: group: ghapp-scan @@ -91,6 +93,8 @@ jobs: if: steps.mint.outcome == 'success' env: GH_TOKEN: ${{ steps.mint.outputs.token }} + # Used only to dispatch ai-run.yml (the App token cannot dispatch hub workflows). + GITHUB_TOKEN: ${{ github.token }} ORG: ${{ matrix.item.org }} REPO_FILTER: ${{ vars.EASYGH_REPO_FILTER }} MAX_ISSUES: ${{ vars.EASYGH_MAX_ISSUES || '4' }} diff --git a/AGENTS.md b/AGENTS.md index 5382b06..08b710f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ EasyGithub is an AI software-development pipeline: a GitHub Issue tagged `ai` is - `docs/learnings.md` — curated context injected into `TASK.md` (top matching sections). - `.github/workflows/ai-agent.yml` — the pipeline workflow (self-host). - `.github/workflows/ai-agent-reusable.yml` + `.github/actions/agent-runner` — reusable cross-repo mode (mode 2). -- `.github/workflows/ghapp-scan.yml` + `scripts/ghapp-token.mjs` — GitHub-App zero-file mode (mode 3). +- `.github/workflows/ghapp-scan.yml` + `scripts/ghapp-token.mjs` — GitHub-App zero-file mode (mode 3); `ai-run.yml` — one task on a profile-selected runner (self-hosted macOS/Windows). - `scripts/` — helper scripts (`dev-run.sh`, `create-labels.sh`, `install-to-repo.sh`). - `docs/` — `ai-workflow.md` (end-to-end workflow, risk tiers, human touchpoints), `portability.md` (adapting the pipeline to other projects), `agents.md` (multi-agent + model config), `realtime.md` (event-driven vs polling), `other-orgs.md` (using EasyGithub in other organizations; `ghapp-scan` org is `vars.EASYGH_ORG`). diff --git a/CHANGELOG.md b/CHANGELOG.md index f98d1f3..01720dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,14 @@ 宿主没有的工具链),工作区挂载到 `/work`;容器模式下不在宿主预装依赖;Docker 不可用时明确失败。 - 验证闸门同时冻结 `verifyContainer` 与命令引用脚本的内容哈希。 +### Added (runsOn) + +- 仓库画像支持 `runsOn`(字符串或 label 数组):App 模式下,dispatcher 不再在 hub 上直接执行,而是 + `workflow_dispatch` `ai-run.yml` 到指定 label 的 runner(自托管 macOS/Windows 可用)。 +- `ai-run.yml`:在目标 runner 上签发目标组织的 installation token、克隆目标仓库、运行标准 runner。 +- `ghapp-scan` 增加 `actions: write`(仅用于 dispatch;App token 无法 dispatch hub 工作流)。 +- 无匹配 runner 时任务保持排队(不静默回退 ubuntu),文档给出取消/补 runner 的指引。 + ### Changed - 检查命令在 agent 运行前冻结;若检查配置被改动,执行冻结版本并标记 `ai-verify-changed`,不再把"跳过验证"当作"验证通过"。 diff --git a/agent-runner/src/dispatcher.ts b/agent-runner/src/dispatcher.ts index 90d87b2..3c0be98 100644 --- a/agent-runner/src/dispatcher.ts +++ b/agent-runner/src/dispatcher.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { runAsync, runSync } from "./util/exec.ts"; import { listInstallationRepos, runWatchdog } from "./watch.ts"; +import { profileFor } from "./profile.ts"; /** * Central dispatcher for the GitHub-App (zero-file) cross-repo mode. @@ -176,6 +177,39 @@ function isHandled(repo: string, number: number): boolean { return Number(prs) > 0; } +/** + * Hand one issue to `.github/workflows/ai-run.yml` on the labels from the + * repository profile (self-hosted macOS/Windows included). Uses the hub's own + * GITHUB_TOKEN: the per-org App token cannot dispatch workflows in the hub. + */ +function dispatchToRunner(repo: string, issue: number, runsOn: string[]): boolean { + const hub = process.env.GITHUB_REPOSITORY; + const token = process.env.GITHUB_TOKEN; + if (!hub || !token) { + console.log("[dispatch] cannot dispatch: GITHUB_REPOSITORY/GITHUB_TOKEN missing"); + return false; + } + const args = [ + "workflow", + "run", + "ai-run.yml", + "-R", + hub, + "-f", + `repo=${repo}`, + "-f", + `issue=${issue}`, + "-f", + `runs_on=${JSON.stringify(runsOn)}`, + ]; + const res = runSync("bash", ["-c", `GH_TOKEN=${sq(token)} gh ${args.map(sq).join(" ")}`]); + if (res.status !== 0) { + console.log(`[dispatch] ai-run dispatch failed: ${res.stderr.trim().slice(0, 300)}`); + return false; + } + return true; +} + async function processIssue(c: Candidate, budgetLeftMs: number): Promise { const runnerMain = new URL("./main.ts", import.meta.url).pathname; const tmpBase = join(process.env.RUNNER_TEMP ?? tmpdir(), "ghapp-dispatch"); @@ -186,6 +220,16 @@ async function processIssue(c: Candidate, budgetLeftMs: number): Promise { const token = process.env.GH_TOKEN ?? ""; console.log(`\n[dispatch] === ${c.repo} #${c.number} ===`); + // Profile-driven runner: hand off to a workflow on the configured labels. + const runsOn = profileFor(c.repo).runsOn; + if (runsOn && runsOn.length > 0) { + const dispatched = dispatchToRunner(c.repo, c.number, runsOn); + console.log( + `[dispatch] ${c.repo} #${c.number} -> ai-run on [${runsOn.join(", ")}]${dispatched ? "" : " (dispatch failed; running inline on this runner)"}`, + ); + if (dispatched) return; + } + const cloneUrl = `https://x-access-token:${token}@github.com/${c.repo}.git`; const clone = runSync("git", ["clone", "--quiet", "--single-branch", cloneUrl, workDir], { env: { GIT_TERMINAL_PROMPT: "0" } }); if (clone.status !== 0) { diff --git a/agent-runner/src/profile.ts b/agent-runner/src/profile.ts index 6d00ab7..c2c51d4 100644 --- a/agent-runner/src/profile.ts +++ b/agent-runner/src/profile.ts @@ -23,6 +23,12 @@ export interface RepoProfile { verifyTimeoutMs?: number; /** Docker image used to run verification (e.g. "swift:6.0", "mcr.microsoft.com/dotnet/sdk:8.0") */ verifyContainer?: string; + /** + * Runner labels for this repository. When set, the dispatcher hands the + * issue to `.github/workflows/ai-run.yml` on those labels (self-hosted + * macOS/Windows included) instead of running on the hub's ubuntu runner. + */ + runsOn?: string[]; /** path prefixes that make a change high-risk (draft PR + code owner) */ protectedPaths?: string[]; protectedFiles?: string[]; @@ -88,6 +94,8 @@ const LIST_KEYS = [ "dependencyFiles", ] as const; const STRING_KEYS = ["learningsFile", "agent", "model", "verifyContainer"] as const; +/** Accepts a string or an array of strings. */ +const RUNNER_KEYS = ["runsOn"] as const; const NUMBER_KEYS = ["verifyTimeoutMs", "maxAttempts", "maxPrsPerDay"] as const; /** Drop unknown keys / wrong types so a typo cannot silently misconfigure a repo. */ @@ -105,6 +113,14 @@ export function sanitizeProfile(raw: unknown): RepoProfile { const value = source[key]; if (typeof value === "string" && value.trim()) out[key] = value.trim(); } + for (const key of RUNNER_KEYS) { + const value = source[key]; + if (typeof value === "string" && value.trim()) out.runsOn = [value.trim()]; + else if (isStringArray(value)) { + const labels = value.map((v) => v.trim()).filter(Boolean); + if (labels.length > 0) out.runsOn = labels; + } + } for (const key of NUMBER_KEYS) { const value = source[key]; if (typeof value === "number" && Number.isFinite(value) && value > 0) out[key] = value; diff --git a/agent-runner/test/profile.test.ts b/agent-runner/test/profile.test.ts index 79ae5d7..c8eed07 100644 --- a/agent-runner/test/profile.test.ts +++ b/agent-runner/test/profile.test.ts @@ -23,6 +23,13 @@ describe("profile: sanitizeProfile", () => { }); }); + it("normalises runsOn from a string or a label array", () => { + assert.deepEqual(sanitizeProfile({ runsOn: "macos-26" }).runsOn, ["macos-26"]); + assert.deepEqual(sanitizeProfile({ runsOn: ["self-hosted", "macos"] }).runsOn, ["self-hosted", "macos"]); + assert.equal(sanitizeProfile({ runsOn: ["", " "] }).runsOn, undefined); + assert.equal(sanitizeProfile({ runsOn: 42 }).runsOn, undefined); + }); + it("returns {} for junk", () => { assert.deepEqual(sanitizeProfile(null), {}); assert.deepEqual(sanitizeProfile("x"), {}); diff --git a/docs/portability.md b/docs/portability.md index 3bb8f1d..244cf7f 100644 --- a/docs/portability.md +++ b/docs/portability.md @@ -41,6 +41,7 @@ EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / R | `verify` | 验证命令数组,按顺序用 ` && ` 连接;**在 agent 启动前冻结** | 自动探测 | | `verifyTimeoutMs` | 单次验证超时 | 600000(10 分钟) | | `verifyContainer` | 在指定 Docker 镜像里执行验证(Swift/.NET/特定 JDK…) | 宿主直接执行 | +| `runsOn` | 该仓库的任务交给这些 runner label(自托管 macOS/Windows 也行) | hub 的 `ubuntu-latest` | | `protectedPaths` / `protectedFiles` | 改到这些路径 → **high 风险**(草稿 PR + code owner) | `.github/`、`agent-runner/`、`scripts/`、`eval/`、`LICENSE` | | `blockedPaths` / `blockedFiles` | 直接拒绝提交(运行失败) | `.github/workflows/`、`.github/actions/`、`LICENSE` | | `sensitivePaths` / `sensitiveFiles` | 允许提交但打 `ai-core` 标签,必须人工审查 | `agent-runner/`、`.github/`、`scripts/`、`package.json` | @@ -73,6 +74,31 @@ EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / R - 需要私有依赖/密钥的构建 → 在 hub 加 secret,或把命令收窄到静态检查。 - 没有可跑的检查时,结果会被标记为**未验证**(high 风险 → 草稿 PR),不会被当成"通过"。 +### 自托管 / 指定 runner(`runsOn`) + +macOS(Xcode)、Windows(MSVC)这类无法用 Linux 容器的项目,可以指定 runner: + +```json +"Org/iOSApp": { + "runsOn": ["self-hosted", "macos", "xcode-26"], + "verify": ["xcodebuild -scheme OrzMC -destination 'platform=iOS Simulator,name=iPhone 17' test"] +} +``` + +工作方式(仅 App / 零文件模式): + +1. `ghapp-scan` 发现该仓库的 Issue 后**不在 hub 上跑**,而是 `workflow_dispatch` 一个 + `ai-run.yml`,把 `repo` / `issue` / `runs_on`(JSON 数组)传过去(用 hub 自己的 `GITHUB_TOKEN`; + 需要工作流有 `actions: write`,已配置)。 +2. `ai-run.yml` 在 `runs-on: fromJSON(runs_on)` 上启动,签发目标组织的 installation token、 + 克隆目标仓库到 `$RUNNER_TEMP`,再跑标准 runner。 +3. 没有匹配 label 的 runner 时,任务会**一直排队**(这是有意的:静默退回 ubuntu 会给出不可信的验证结果)。 + 取消排队或补 runner 即可。 + +- 自托管 runner 需要:`node >= 22`(`setup-node` 会自动装)、`git`、`gh`;容器验证还需要 Docker。 +- `runsOn` 与 `verifyContainer` 可以并用(容器只在 Linux runner 上有意义)。 +- Mode 1/2(目标仓库自己的 workflow)不需要 `runsOn`:直接在 caller workflow 里写 `runs-on:` 即可。 + ### 容器化验证(`verifyContainer`) 宿主 runner 没有的工具链(Swift、.NET、特定 JDK、Flutter…)可以用镜像补齐: @@ -110,6 +136,7 @@ EasyGithub 的设计目标是**项目无关**:同一套 runner 服务 Node / R [ ] 仓库画像:config/repos.json 加一条(verify 必填;否则走自动探测) [ ] protectedPaths:把"改了必须人看"的目录写进去(CI、发布脚本、签名、生成代码…) [ ] dependencyFiles:该项目的依赖清单(lockfile / Package.resolved / go.sum …) +[ ] macOS/Windows:设置 `runsOn`(需要对应的自托管 runner),否则只能跑 Linux 可用的门禁 [ ] 目标仓库:allow_auto_merge + 0 批准 + 必需检查(bash scripts/enable-ai-gate.sh) [ ] 周报:把仓库加进 EASYGH_RETRO_REPOS [ ] 冒烟:一个 docs-only 的 `ai` Issue,确认 PR → auto-merge → Issue 关闭