Copilot cloud agent 开始工作前,会先运行
.github/workflows/copilot-setup-steps.yml,用来安装依赖、准备环境。 这篇给一个 uv 管理的 Python 项目写这个文件,然后做两层检查:
- 用 actionlint 做通用的 Actions 语法和安全检查;
- 用
setup_steps_check.py按 GitHub 文档检查 Copilot 特有的规则:job 名称、只允许自定义的字段、timeout-minutes不超过 59、最小权限、actions 固定到 SHA。最后在 box 上把 workflow 里的命令按顺序实际跑一遍,确认它们能执行成功。
先看清成品(box 上真实运行)
- 正例通过 actionlint 和 Copilot 规则检查。
- 反例中,actionlint 报出
github.event.pull_request.title存在脚本注入风险;Copilot 规则检查报出 1 个 ERROR(超时 90 分钟)和 5 条 WARN/INFO。uv sync --locked和uv run pytest -q在本地实际运行通过(2 项测试)。- 检查脚本的 pytest 4 项通过,并通过 ruff 检查。
合规提醒:GitHub Copilot 需要有效的订阅或组织授权,不要共享账号,也不要用代理绕过地区或组织的限制。Copilot 会把代码上下文发送给 GitHub 和它的模型服务商处理,公司仓库先确认组织策略(比如是否允许使用 cloud agent,是否开启公开代码匹配过滤)。仓库里的说明文件、workflow、agent 配置都不要写密钥,MCP 用到的密钥放在 Agents secrets 里。本文不列价格,以官网为准。
运行说明:box 上没有登录 GitHub,也没有 Copilot 订阅,所以 Copilot 实际是否读取这些文件、cloud agent 是否真的执行了 setup 步骤,都没有验证。本文的规则全部来自 docs.github.com 的原文(文中会标出处),检查脚本、actionlint 和测试都在 box 上真实运行过。
关于 actionlint:从官方 GitHub Releases 下载 v1.7.12 的 Linux 二进制文件,并用发布页提供的 checksums 文件校验通过。actions 的 commit SHA 是通过 GitHub 公开 API 查询各仓库的最新 release 得到的(
actions/checkoutv7.0.1、astral-sh/setup-uvv10.3.0)。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
.github/workflows/copilot-setup-steps.yml |
正例 |
examples/bad-setup-steps.yml |
反例 |
setup_steps_check.py |
检查 Copilot 特有的规则 |
pyproject.toml、uv.lock、src/orders/、tests/ |
示例项目 |
test_setup_steps.py |
4 项测试(包括调用 actionlint) |
需要准备哪些环境?
uv(box 上是 0.13.1)、actionlint、Python 3.12 以上,pip install pyyaml pytest。
Copilot 特有规则(来自 customize-the-agent-environment 文档)
| 规则 | 内容 |
|---|---|
| 位置 | .github/workflows/copilot-setup-steps.yml,并且只有在默认分支上才会生效 |
| job | 必须包含一个名为 copilot-setup-steps 的 job,名字不对 Copilot 就不会使用 |
| 可自定义的字段 | 只有 steps、permissions、runs-on、services、snapshot、timeout-minutes,其他设置会被忽略 |
| 超时 | timeout-minutes 最大为 59 |
| checkout | actions/checkout 的 fetch-depth 会被覆盖 |
| 权限 | 尽量给最小权限,Copilot 自己有单独的 token;需要 checkout 时给 contents: read |
| 失败时 | 某一步返回非 0 时,会跳过剩下的步骤,Copilot 以当前的环境状态开始工作 |
文件结构
58-github-copilot-B-src/
├── .github/workflows/copilot-setup-steps.yml
├── examples/bad-setup-steps.yml
├── setup_steps_check.py
├── test_setup_steps.py
├── pyproject.toml
├── uv.lock
├── src/orders/__init__.py
└── tests/test_total.py
第 1 步:workflow
name: "Copilot Setup Steps"
# 改动这个文件时自动跑一遍,也可以在 Actions 页面手动触发
on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# 名字必须是 copilot-setup-steps,否则 Copilot 不会使用
copilot-setup-steps:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
- name: Install dependencies
run: uv sync --locked
- name: Smoke test
run: uv run pytest -q
# examples/bad-setup-steps.yml(反例)
name: setup
on: push
jobs:
setup:
runs-on: ubuntu-latest
timeout-minutes: 90
permissions: write-all
env:
DEBUG: "1"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- run: pip install -r requirements.txt
copilot-setup-steps:
runs-on: ubuntu-latest
container: python:3.12
timeout-minutes: 90
permissions: write-all
steps:
- uses: actions/checkout@main
- run: echo "${{ github.event.pull_request.title }}"
第 2 步:检查脚本
"""在 actionlint(通用语法检查)之外,按 GitHub 文档检查 copilot-setup-steps.yml 的特有规则。退出码:有 ERROR 为 1。
依据(docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment):
- 路径 .github/workflows/copilot-setup-steps.yml,且必须在默认分支上才生效
- 必须有且只有一个名为 copilot-setup-steps 的 job
- 只能自定义 steps / permissions / runs-on / services / snapshot / timeout-minutes,其他设置会被忽略
- timeout-minutes 最大 59
- actions/checkout 的 fetch-depth 会被覆盖
- permissions 尽量最小
"""
import re
import sys
import yaml
ALLOWED = {"steps", "permissions", "runs-on", "services", "snapshot", "timeout-minutes"}
def check(path):
out = []
with open(path, encoding="utf-8") as f:
doc = yaml.safe_load(f)
on = doc.get("on", doc.get(True)) # PyYAML 会把裸的 on 解析成 True
jobs = doc.get("jobs") or {}
if "copilot-setup-steps" not in jobs:
return [("ERROR", "没有名为 copilot-setup-steps 的 job,Copilot 不会使用这个文件")]
if len(jobs) > 1:
out.append(("WARN", f"文档写的是 must contain a single copilot-setup-steps job;这里有 {len(jobs)} 个 job:{list(jobs)},建议拆到别的 workflow"))
job = jobs["copilot-setup-steps"]
for k in set(job) - ALLOWED:
out.append(("WARN", f"{k} 会被忽略(只能自定义 {sorted(ALLOWED)})"))
t = job.get("timeout-minutes")
if isinstance(t, int) and t > 59:
out.append(("ERROR", f"timeout-minutes={t} 超过上限 59"))
perms = job.get("permissions")
if perms is None:
out.append(("WARN", "没写 permissions,会用仓库默认权限;建议显式写 contents: read"))
elif perms == "write-all" or (isinstance(perms, dict) and any(v == "write" for v in perms.values())):
out.append(("WARN", f"权限过大:{perms}。Copilot 有自己的 token,setup 步骤一般只要 contents: read"))
for i, st in enumerate(job.get("steps") or []):
uses = st.get("uses", "")
if uses and not re.search(r"@[0-9a-f]{40}$", uses):
out.append(("WARN", f"第 {i+1} 步 {uses} 没有固定到 commit SHA"))
if uses.startswith("actions/checkout") and "fetch-depth" in (st.get("with") or {}):
out.append(("INFO", f"第 {i+1} 步的 fetch-depth 会被 Copilot 覆盖"))
if not (isinstance(on, dict) and "workflow_dispatch" in on):
out.append(("INFO", "建议加 workflow_dispatch,方便在 Actions 页面手动验证"))
return out
if __name__ == "__main__":
bad = False
for p in sys.argv[1:] or [".github/workflows/copilot-setup-steps.yml"]:
res = check(p)
print(f"== {p}: {'OK' if not res else ''}")
for lvl, msg in res:
print(f" [{lvl}] {msg}"); bad |= lvl == "ERROR"
sys.exit(1 if bad else 0)
import pathlib
import shutil
import subprocess
import pytest
from setup_steps_check import check
HERE = pathlib.Path(__file__).parent
GOOD = HERE / ".github/workflows/copilot-setup-steps.yml"
BAD = HERE / "examples/bad-setup-steps.yml"
def test_good_is_clean():
assert check(GOOD) == []
def test_bad_findings():
msgs = " | ".join(f"{l} {m}" for l, m in check(BAD))
for s in ["ERROR timeout-minutes=90", "WARN container 会被忽略", "write-all", "actions/checkout@main 没有固定",
"workflow_dispatch", "2 个 job"]:
assert s in msgs
def test_missing_job(tmp_path):
p = tmp_path / "w.yml"; p.write_text("on: push\njobs:\n setup:\n runs-on: ubuntu-latest\n steps: []\n")
assert check(p)[0][0] == "ERROR"
ACTIONLINT = shutil.which("actionlint") or "/tmp/p/tools/actionlint"
@pytest.mark.skipif(not pathlib.Path(ACTIONLINT).exists(), reason="未安装 actionlint")
def test_actionlint():
assert subprocess.run([ACTIONLINT, str(GOOD)], check=False).returncode == 0
r = subprocess.run([ACTIONLINT, str(BAD)], capture_output=True, text=True, check=False)
assert r.returncode == 1 and "potentially untrusted" in r.stdout
第 3 步:示例项目
[project]
name = "orders"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orders"]
def total(items: list[tuple[int, int]]) -> int:
"""items: [(单价分, 数量)],返回总价(分)。"""
if any(p < 0 or q < 0 for p, q in items):
raise ValueError("价格和数量不能为负")
return sum(p * q for p, q in items)
第 4 步:运行
$ actionlint .github/workflows/copilot-setup-steps.yml && echo ok
ok
$ actionlint examples/bad-setup-steps.yml
examples/bad-setup-steps.yml:22:24: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. ... [expression]
$ python setup_steps_check.py .github/workflows/copilot-setup-steps.yml examples/bad-setup-steps.yml
== .github/workflows/copilot-setup-steps.yml: OK
== examples/bad-setup-steps.yml:
[WARN] 文档写的是 must contain a single copilot-setup-steps job;这里有 2 个 job:['setup', 'copilot-setup-steps'],建议拆到别的 workflow
[WARN] container 会被忽略(只能自定义 ['permissions', 'runs-on', 'services', 'snapshot', 'steps', 'timeout-minutes'])
[ERROR] timeout-minutes=90 超过上限 59
[WARN] 权限过大:write-all。Copilot 有自己的 token,setup 步骤一般只要 contents: read
[WARN] 第 1 步 actions/checkout@main 没有固定到 commit SHA
[INFO] 建议加 workflow_dispatch,方便在 Actions 页面手动验证
$ uv sync --locked && uv run pytest -q # workflow 里的步骤,本地实际运行
2 passed
$ python -m pytest -q test_setup_steps.py
4 passed
调试示例
uv sync --locked 在第一步就失败了。 我在本地按 workflow 的顺序执行命令,第一条就报错:
error: Unable to find lockfile at `uv.lock`, but `--locked` was provided. To create a lockfile, run `uv lock` or `uv sync` without the flag.
在 cloud agent 里,如果某一步返回非 0,Copilot 会跳过后面的所有步骤,直接在没有安装依赖的环境里开始工作。而且这种失败只会出现在会话日志里,很容易被忽略。解决办法是运行 uv lock,然后把 uv.lock 提交进仓库(--locked 保证依赖版本和锁文件一致)。之后 uv sync --locked 和 uv run pytest -q 都通过了。所以,setup 步骤一定要先在本地按顺序跑一遍。
另外一处修正: 文档原文是 “must contain a single copilot-setup-steps job”。我一开始把“有多个 job”判为 ERROR,后来觉得这句话并没有明确禁止其他 job 存在,于是降级为 WARN,并在提示里引用了原文。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| Copilot 没有执行 setup 步骤 | 文件不在默认分支上,或者 job 名称不对 | 合并到默认分支,job 名称必须是 copilot-setup-steps |
设置了 env 或 container 但没有效果 |
这些字段会被忽略 | 只用允许自定义的那 6 个字段 |
| 依赖没有装上,Copilot 照样开始工作 | 某一步失败后,剩下的步骤被跳过 | 先在本地跑一遍,再看会话日志 |
| actionlint 报 untrusted 表达式 | 在 run 里直接拼接了事件内容 |
先放到 env 变量里,再在命令中引用 |
| fetch-depth 设置不起作用 | 会被 Copilot 覆盖 | 不用设置 |
模板规则
- setup 步骤只做确定性的安装:锁文件加
--locked,actions 固定到 SHA。 - 权限只给
contents: read,timeout-minutes设置在 59 以内。 - 先用 actionlint 和本文的检查脚本把关,再在本地按顺序运行一遍。
项目资源
| 用途 | 地址 |
|---|---|
| Copilot 文档首页 | https://docs.github.com/en/copilot |
| 自定义 cloud agent 环境 | https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment |
| actionlint | https://github.com/rhysd/actionlint |
| Actions 安全实践(脚本注入) | https://docs.github.com/en/actions/reference/security/secure-use |
| uv 文档 | https://docs.astral.sh/uv/ |
setup 步骤写成确定性的、经过检查的 workflow,Copilot 每次开始工作时的环境都一样。