Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/workflows/ai-run.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .github/workflows/ghapp-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' }}
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,不再把"跳过验证"当作"验证通过"。
Expand Down
44 changes: 44 additions & 0 deletions agent-runner/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
const runnerMain = new URL("./main.ts", import.meta.url).pathname;
const tmpBase = join(process.env.RUNNER_TEMP ?? tmpdir(), "ghapp-dispatch");
Expand All @@ -186,6 +220,16 @@ async function processIssue(c: Candidate, budgetLeftMs: number): Promise<void> {
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) {
Expand Down
16 changes: 16 additions & 0 deletions agent-runner/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions agent-runner/test/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), {});
Expand Down
27 changes: 27 additions & 0 deletions docs/portability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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…)可以用镜像补齐:
Expand Down Expand Up @@ -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 关闭
Expand Down
Loading