预计完成时间:25 分钟 | 难度:高级 | 类型:知识
在本课结束时,你将理解 sandcastle 和 course-video-manager 中四个关键高级模式:
本课会接触到两个参考仓库中实际使用的命令行工具和 git 操作。在深入模式之前先简单认识一下:
| 工具 | 是什么 | 在本课中怎么用 |
|---|---|---|
npx tsx |
npx 是 Node.js 自带的包运行器,可以运行 npm 包而无需全局安装。tsx 是一个工具,能直接运行 TypeScript 文件(不需要先编译成 JS)。合起来: npx tsx some-script.ts = "直接执行这个 TS 文件" |
sandcastle 用 npx tsx .sandcastle/implement/implement.ts来运行 AI 代理的入口脚本 |
gh api ... --jq |
gh api 是 GitHub CLI 中调用 REST/GraphQL API 的命令。--jq 参数让你用 jq 语法从返回的 JSON 中提取字段。(jq 是一个 JSON 处理工具,预装在 GitHub Actions 的 Ubuntu 虚拟机上) |
gh api repos/.../issues --jq 'length'获取 JSON 数组的长度 |
git push --force-with-lease |
git push 的一个更安全的变体。普通 --force 会无条件覆盖远程分支;--force-with-lease 只在远程分支没有被其他人修改过时才允许覆盖。 |
AI 代理推送代码时使用,防止覆盖 人类在此期间手动提交的内容 |
假设 Implement 工作流成功后创建了一个 PR。你希望自动触发 Review 工作流来审核这个 PR。自然的做法是:
但这里有一个关键陷阱:GitHub 会抑制由 GITHUB_TOKEN 触发的事件来防止递归工作流。如果你用 secrets.GITHUB_TOKEN 来添加标签,那个标签不会触发下游工作流!
用 PAT 添加的标签会触发下游工作流。sandcastle 和 course-video-manager 都使用了这个技巧:
# 在 Implement 工作流的最后一步中:
- name: "Request review on new PR"
run: |
if [ -n "$AGENT_PAT" ]; then
# PAT 可用 → 下游 Review 工作流会被自动触发
GH_TOKEN="$AGENT_PAT" gh pr edit "$PR_NUMBER" --add-label "agent:review"
else
# 回退到 GITHUB_TOKEN → 下游不被触发,需要手动帮助
gh pr edit "$PR_NUMBER" --add-label "agent:review"
echo "::warning::没有 AGENT_PAT。请手动给 PR 添加 agent:review 标签"
fi
env:
AGENT_PAT: ${{ secrets.AGENT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
if [ -n "$AGENT_PAT" ]; then 是 shell 条件判断,不是 GitHub Actions 的 if:(详见第 2 课逐步解析)。逐词拆解:if = "如果",[ -n "..." ] = "字符串非空则为真"(方括号内必须有空格,写成 [-n 就报错),; then = "那么执行以下代码"。对应词 -z = "字符串为空则为真"。
concurrency——它防止同一个 Issue/PR 同时跑多个实例,是链式触发场景下的另一道保险(详见第 2 课 concurrency 深入解释)。
github_pat_11ABC...。它有三重含义:GITHUB_TOKEN = 机器人身份,受递归抑制;PAT = 你的真人身份,不受抑制。这就是为什么 sandcastle 能用 PAT 实现 Implement → Review 的链式自动触发。AGENT_PAT。
| 防线 | 机制 | 位置 |
|---|---|---|
| 1. 标签状态机 | 每一步的标签不同:agent:implement → agent:review → agent:review-done。链条走到头就停了——每个标签只触发一个特定工作流,不会往回指。 |
第 2 课 |
| 2. concurrency | 同一个 Issue/PR 同时只允许跑一个工作流实例。即使有人手滑加了不该加的标签,也不会出现两个实现同时跑。 | 第 2 课 |
| 3. 单向设计 | 链条是单向的:implement → review → 结束。没有箭头往回指。每一个后续标签都不会触发前一个工作流。 |
架构设计 |
GITHUB_TOKEN 是自动挡——安全但跑不起来(链式触发被抑制);PAT 是手动挡——能跑但需要你自己会踩刹车。sandcastle 用标签状态机 + concurrency + 单向设计三把刹车确保安全。
outputs 和 needs在进入模式 2 之前,需要理解同一个工作流中不同 job 之间如何传递数据。GitHub Actions 的 job 默认是独立运行的(可能在不同虚拟机上),它们通过两个机制通信:
| 机制 | 作用 |
|---|---|
jobs.<job>.outputs |
job 的输出声明。值来自该 job 中某个 step 通过 echo "key=value" >> "$GITHUB_OUTPUT" 写入的内容($GITHUB_OUTPUT 的概念见第 2 课运行环境概念)。 |
jobs.<job>.needs |
声明"我需要等某个 job 完成才能运行"。同时也让当前 job 能通过 needs.<job>.outputs.<key> 读取那个 job 的输出。 |
简单说:预检 job 通过 $GITHUB_OUTPUT 写入结论 → 执行 job 通过 needs.preflight.outputs.proceed 读取 → 决定是否继续。这样就实现了"先检查,再执行"。
needs 才会建立依赖关系:| 写法 | 行为 |
|---|---|
没写 needs | 所有 job 同时启动,互不等待 |
needs: preflight | 等 preflight 完成才启动 |
needs: [lint, test] | 等 lint 和 test 都完成才启动 |
implement job 的 needs: preflight 不仅保证顺序,还让它可以读取 needs.preflight.outputs.proceed。而且当 preflight 输出 proceed=false 时,implement job 根本不会启动——不在 if 判断快,而在虚拟机上连环境都没创建就被跳过了。
Implement 工作流需要数十分钟的 AI 代理时间和 API 费用。如果 Issue 根本不适合自动化实现(例如,它是一个讨论帖,或者已经有人在处理了),在运行前拒绝可以节省大量资源。
sandcastle 的 agent-implement.yml 在执行 AI 代理之前运行了三个预检步骤:
jobs:
preflight:
if: github.event.label.name == 'agent:implement'
runs-on: ubuntu-latest
outputs:
proceed: ${{ steps.check_pr.outputs.proceed }}
reason: ${{ steps.check_pr.outputs.reason }}
steps:
# 预检 1: 检查是否存在已有的 PR
- name: "Check for existing PR"
id: check_pr
run: |
PRS=$(gh pr list --repo "$REPO" --state open \
--search "Closes #$ISSUE_NUMBER" --json number -q length)
if [ "$PRS" -gt 0 ]; then
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "reason=已有开放 PR 引用了该 Issue" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
# 只有预检通过后,这个 job 才运行
implement:
needs: preflight
if: needs.preflight.outputs.proceed == 'true'
# ... 昂贵的 AI 代理任务 ...
outputs 和 needs 在 job 之间传递决策。这样执行 job 可以简单地检查 proceed == 'true' 而不需要重复验证逻辑。
sandcastle 中的三个预检守卫:
| 预检 | 检查什么 | 拒绝原因 |
|---|---|---|
| 1. 检测 Issue 形状 | Issue 是否有子 Issue?是否是子 Issue? | 子 Issue 必须通过父 Issue 处理 |
| 2. 拒绝子 Issue | 如果 Issue 是子 Issue | "请在父 Issue #N 上添加标签" |
| 3. 拒绝 PRD 形状 | 如果 Issue 有子 Issue(是 PRD) | "独立 Issue 实现不支持 PRD" |
在 sandcastle 的工作流中,你经常看到这样的 checkout 步骤:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
| 参数 | 含义 |
|---|---|
ref: head.sha |
指定检出哪个 commit。默认检出的是 merge commit(PR 合并到基础分支后的结果),但 AI 代理需要工作在 PR 的原始分支上。用 head.sha 直接检出 PR 分支的最新 commit。 |
fetch-depth: 0 |
获取完整的 git 历史(默认只获取最新一个 commit)。AI 代理需要完整历史来理解代码上下文和生成有意义的 commit。 |
fetch-depth 控制的是拉多少条 commit 记录,不是拉多少 MB 文件——两者没有直接关系:100 个 commit 每个改 1 行的仓库可能只有几 KB 的 git 数据;而 1 个 commit 塞了 500MB 视频的仓库,fetch-depth: 1 也照样拉 500MB。fetch-depth: 0 是因为它历史短、纯文本。对于你自己的 AI 代理工作流,fetch-depth: 50 通常足够——既给了 AI 上下文理解代码风格,又不会拉无用历史。git blame、git bisect、生成 changelog,或者仓库本身很小(<100 MB)。agent:implement 标签可以加在任何 Issue 上。但不同类型的 Issue 需要不同的处理方式:
如何在运行时区分它们?
GitHub REST API 可以告诉你一个 Issue 是否有子 Issue,但不能告诉你它是否是子 Issue。为此需要 GraphQL:
# 检测 1: Issue 是否有子 Issue?(REST API)
- name: "Detect sub-issues"
id: detect
run: |
SUB_COUNT=$(gh api "repos/$REPO/issues/$N/sub_issues" \
--jq 'length')
echo "has_sub_issues=$([ "$SUB_COUNT" -gt 0 ] \
&& echo true || echo false)" >> "$GITHUB_OUTPUT"
# 检测 2: Issue 是否是子 Issue?(GraphQL — REST 不支持)
PARENT=$(gh api graphql -f query='
query($owner:String!, $repo:String!, $number:Int!) {
repository(owner:$owner, name:$repo) {
issue(number:$number) {
parent { number title }
}
}
}
' -f owner="$OWNER" -f repo="$REPO" -f number="$N" \
--jq '.data.repository.issue.parent.number')
echo "is_sub_issue=$([ -n "$PARENT" ] \
&& echo true || echo false)" >> "$GITHUB_OUTPUT"
issue.parent 字段可以查询。这是 sandcastle/course-video-manager 中唯一使用 GraphQL 的地方。
有了这两个检测结果,工作流可以决定走哪个分支:
| has_sub_issues | is_sub_issue | 形状 | 动作 |
|---|---|---|---|
| false | false | 独立 Issue | ✅ 运行 implement(单 Issue 模式) |
| true | false | PRD | ✅ 运行 implement-prd(逐个实现子 Issue) |
| — | true | 子 Issue | ❌ 拒绝并引导用户到父 Issue |
将三个模式组合在一起,就是一个完整的工作流架构:
一个 PRD(有子 Issue 的 Issue)被加上 agent:implement 标签时,不是只实现一个 Issue——它需要逐个实现所有子 Issue,所有子 Issue 的提交汇入同一个分支,最终产出一个 PR。怎么做到?
最容易误解的地方:它不是一个工作流跑一次循环实现所有子 Issue,而是自触发接力:
每次都是全新的工作流运行实例,依靠 GitHub 的标签事件驱动下一棒。
假设 PRD Issue #42 有 4 个子 Issue(#43~#46),你给它加了 agent:implement 标签:
| 决策 | 怎么做 | 为什么 |
|---|---|---|
| 自触发而非内循环 | 每次完成一个子 Issue 后重新加标签触发下一次运行 | 每个子 Issue 独立运行,一个失败不影响架构;可以随时中断(不加标签就停了) |
| 共用分支累积提交 | 所有子 Issue 的 commit 都在同一分支上 | 一个 PRD = 一个 PR,便于人类审查整个功能 |
| 普通 push,不 force | PRD 工作流用 git push(不像单 Issue 用 --force) |
每次运行追加 commit,绝不能覆盖之前子 Issue 的提交 |
| 关闭子 Issue 是工作流的事 | AI 代理的 commit 信息只写 Part of #42,不写 Closes #43 |
分离职责:AI 只管写代码,工作流管理系统状态 |
| 单 Issue | PRD | |
|---|---|---|
| 分支 | agent/issue-<n>-<slug> | agent/prd-<n>-<slug> |
| push 方式 | --force(每次全新) | 普通 push(累积) |
| PR 创建 | 每次新建 | 首次创建,后续复用 |
| 链式触发 | → agent:review(1 跳) | → agent:implement(N-1 次自循环)→ agent:review |
| 关闭 Issue | PR 的 Closes #N 自动关 | 工作流手动 gh issue close 逐个关子 Issue |
1. 为什么用 GITHUB_TOKEN 添加标签不会触发下游工作流?
2. sandcastle 在 Implement 工作流中为什么需要 GraphQL?
3. 预检步骤为什么应该放在独立的 job 中(而非任务的第一个 step)?
三个课程覆盖了 sandcastle 和 course-video-manager 中所有关键工作流模式:
on: issues: types: [labeled] + if: 门控 = 标签触发的基本模式本课涵盖的四个高级模式:
| 模式 | 解决什么问题 | 核心机制 |
|---|---|---|
| 1. PAT 技巧 | 工作流之间如何链式触发 | PAT 代表真人身份,绕过 GitHub 递归抑制 |
| 2. 预检守卫 | 如何在运行昂贵任务前验证条件 | 独立 preflight job + outputs/needs 传递决策 |
| 3. 形状检测 | 区分独立 Issue / 子 Issue / PRD | REST (查子 Issue) + GraphQL (查父 Issue) 双重检测 |
| 4. PRD 链式推进 | 多个子 Issue 如何逐个自动实现 | 自触发标签接力:完成一个 → 重新加标签 → 触发下一个 |
现在你已经具备了在自己的仓库中实现类似系统的所有知识。从简单开始(第 1 课的工作流),逐步添加状态机(第 2 课),最后根据需要引入高级模式(第 3 课)。