开发技巧

GitHub Copilot:Copilot cloud agent 开始工作前

Copilot cloud agent 开始工作前,会先运行 .github/workflows/copilot-setup-steps.yml,用来安装依赖、准备环境。 这篇给一个 uv 管理的 Python 项目写这个文件,然后做两层检查: - 用 actionlint 做通用的 Actions 语法和安全检查; - 用 setupstepscheck.

BINBHO · 图文教程

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/checkout v7.0.1、astral-sh/setup-uv v10.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 每次开始工作时的环境都一样。