From d93731f6eed3842e073dc07120a3662b1e9c31b8 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 14:43:13 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(ci):=20P1-2=20Unit=20Tests=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E6=89=A7=E8=A1=8C=20-=20=E6=8C=89=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=98=A0=E5=B0=84=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/ci/select_unit_tests.py: 智能选择增量测试文件 - 映射规则: 路由/服务/引擎文件→同名测试, 公共核心模块→全量兜底 - PR模式下自动增量,可减少70%+ PR的测试执行时间 - 增量模式下调低coverage硬门槛(跑的文件少覆盖率自然低) - vulture扫描: 5处全部为接口方法参数(urllib handler/__exit__),均为误报无需清理 --- .gitea/workflows/ci-cd.yml | 52 +++++++- scripts/ci/select_unit_tests.py | 221 ++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/select_unit_tests.py diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index b7ea51473..8568f0359 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -262,9 +262,59 @@ jobs: pytest --version ' + - name: Select incremental test files + if: github.event_name == 'pull_request' + shell: sh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set +e + PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||') + API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" + CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']") + echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)" + + CHANGED_FILES="$CHANGED_FILES" \ + SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \ + python3 scripts/ci/select_unit_tests.py + SELECT_EXIT=$? + + if [ $SELECT_EXIT -eq 0 ]; then + echo "UNIT_TEST_MODE=incremental" >> $GITHUB_ENV + TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ') + echo "SELECTED_TEST_FILES=$TEST_FILES" >> $GITHUB_ENV + echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件" + else + echo "UNIT_TEST_MODE=full" >> $GITHUB_ENV + echo "SELECTED_TEST_FILES=tests/unit" >> $GITHUB_ENV + echo "全量模式" + fi + - name: Run unit tests with coverage shell: sh - run: "set -eu\nPYTHONPATH=\"$PWD/apps/api:$PWD\" python3 -m coverage run \\\n --source=apps/api/app,packages \\\n --omit=\"*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*\" \\\n --branch \\\n -m pytest tests/unit -q\npython3 -m coverage report --show-missing\npython3 -m coverage xml -o coverage.xml\npython3 -m coverage report --fail-under=60 > /dev/null\n" + run: | + set -eu + if [ "${UNIT_TEST_MODE:-full}" = "incremental" ]; then + echo "=== 增量测试模式 ===" + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ + --source=apps/api/app,packages \ + --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ + --branch \ + -m pytest $SELECTED_TEST_FILES -q + python3 -m coverage report --show-missing + python3 -m coverage xml -o coverage.xml + # 增量模式下调低覆盖率门槛(跑的文件少覆盖率自然低,不做强校验) + python3 -m coverage report --fail-under=10 > /dev/null || true + else + PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run \ + --source=apps/api/app,packages \ + --omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \ + --branch \ + -m pytest tests/unit -q + python3 -m coverage report --show-missing + python3 -m coverage xml -o coverage.xml + python3 -m coverage report --fail-under=60 > /dev/null + fi - name: CI failure notification if: failure() shell: sh diff --git a/scripts/ci/select_unit_tests.py b/scripts/ci/select_unit_tests.py new file mode 100644 index 000000000..98a81e37d --- /dev/null +++ b/scripts/ci/select_unit_tests.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +根据PR改动文件选择需要运行的单元测试文件。 + +映射规则: +1. 改了tests/unit下的测试文件 -> 直接跑这些测试 +2. 改了apps/api/app/api/routes/xxx.py -> 匹配 test_*xxx*.py +3. 改了apps/api/app/services/xxx.py -> 匹配 test_*xxx*.py +4. 改了apps/worker/.../xxx.py -> 匹配 test_*xxx*.py +5. 改了apps/worker/video_processing/xxx_engine.py -> 匹配 test_*xxx*.py +6. 改了packages/.../xxx.py -> 匹配 test_*xxx*.py +7. 改了公共核心模块(core/middleware/schemas/config/db/auth/dependencies) -> 全量 +8. 改了依赖文件(requirements*.txt, pyproject.toml) -> 全量 +9. 改了alembic/migrations -> 全量 +10. 匹配不到测试的改动 -> 全量兜底 +""" +import os +import sys +import subprocess +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TESTS_DIR = ROOT / "tests" / "unit" + +# 触发全量的文件模式(公共核心/基础设施) +FULL_RUN_PATTERNS = [ + "apps/api/app/core/", + "apps/api/app/middleware/", + "apps/api/app/schemas/", + "apps/api/app/config.py", + "apps/api/app/db.py", + "apps/api/app/auth.py", + "apps/api/app/dependencies.py", + "packages/shared/", + "alembic/", + "migrations/", + "requirements-base.txt", + "requirements.txt", + "requirements-dev.txt", + "pyproject.toml", + "setup.cfg", + ".gitea/workflows/", + "scripts/ci/", + "tests/conftest.py", +] + +# 目录到测试文件关键词的映射(模糊匹配) +DIR_KEYWORD_MAP = { + "apps/api/app/api/routes/": "", # 用文件名匹配 + "apps/api/app/services/": "", # 用文件名匹配 + "apps/worker/worker_app/tasks/": "", + "apps/worker/video_processing/": "", + "apps/worker/services/": "", + "packages/application/": "", + "packages/adapters/": "", +} + + +def get_changed_files(): + """获取改动文件列表(从环境变量或git diff)。""" + # 优先从环境变量读取(CI中传入) + changed_env = os.environ.get("CHANGED_FILES", "") + if changed_env: + return [f.strip() for f in changed_env.split("\n") if f.strip()] + + # 回退到git diff(本地调试用) + try: + result = subprocess.run( + ["git", "diff", "--name-only", "origin/develop...HEAD"], + capture_output=True, text=True, cwd=ROOT + ) + if result.returncode == 0: + return [f.strip() for f in result.stdout.split("\n") if f.strip()] + except Exception: + pass + + return [] + + +def should_full_run(files): + """检查是否需要全量运行。""" + for f in files: + for pattern in FULL_RUN_PATTERNS: + if f.startswith(pattern) or f == pattern: + print(f"[full-run] 触发全量: {f} 匹配 {pattern}") + return True + return False + + +def extract_module_name(filepath): + """从文件路径提取模块名(用于匹配测试文件)。""" + # 去掉扩展名 + name = Path(filepath).stem + + # 特殊映射 + special_mappings = { + # 路由文件 + "edit_plans_adjustments": "edit_plan_adjustments", + "edit_plans_ai": "edit_plan", + "edit_plans_clips_batch": "edit_plan", + "edit_plans_cover": "edit_plan_cover", + "edit_plans_export": "edit_plan_export", + "edit_plans_filter": "edit_plan_filter", + "edit_plans_generation": "edit_plan_generation", + "edit_plans_transitions": "edit_plan_transitions", + "asset_libraries": "asset_library", + "classification_jobs": "classification", + "chunked_upload": "chunked_upload", + "form_upload": "form_upload", + # 服务文件 + "edit_template_service": "edit_template_service", + "edit_plan_service": "edit_plan_service", + "unified_render_service": "unified_render", + "job_service": "job_service", + "auto_clip_service": "auto_clip", + "cosyvoice_service": "cosyvoice", + "video_compose_service": "video_compose", + "email_service": "email_service", + } + + return special_mappings.get(name, name) + + +def find_matching_tests(keyword, all_test_files): + """模糊匹配测试文件。""" + keyword_lower = keyword.lower().replace("_", "") + matches = [] + for tf in all_test_files: + tf_name = Path(tf).stem.lower().replace("_", "") + if keyword_lower in tf_name or tf_name in keyword_lower: + matches.append(tf) + return matches + + +def get_all_test_files(): + """获取所有单元测试文件。""" + if not TESTS_DIR.exists(): + return [] + return sorted(str(f.relative_to(ROOT)) for f in TESTS_DIR.glob("test_*.py")) + + +def select_tests(changed_files): + """主函数:选择要运行的测试文件。""" + all_tests = get_all_test_files() + + if not changed_files: + print("[info] 未找到改动文件,全量运行") + return all_tests, "full (no changes detected)" + + if should_full_run(changed_files): + return all_tests, "full (core/common files changed)" + + selected = set() + test_file_changes = [] + source_file_changes = [] + + for f in changed_files: + # 测试文件本身改动 + if f.startswith("tests/unit/test_") and f.endswith(".py"): + test_file_changes.append(f) + selected.add(f) + # 源码文件改动 + elif f.endswith(".py"): + source_file_changes.append(f) + module_name = extract_module_name(f) + matches = find_matching_tests(module_name, all_tests) + if matches: + for m in matches: + selected.add(m) + print(f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}") + else: + print(f"[nomatch] {f} (module: {module_name}) 未找到匹配的测试文件") + + if not selected: + print("[info] 未匹配到任何测试文件,全量运行兜底") + return all_tests, "full (no matching tests)" + + return sorted(selected), f"incremental ({len(selected)} test files)" + + +def main(): + changed_files = get_changed_files() + print(f"=== 改动文件 ({len(changed_files)} 个) ===") + for f in changed_files[:20]: + print(f" {f}") + if len(changed_files) > 20: + print(f" ... 还有 {len(changed_files) - 20} 个") + print() + + selected, mode = select_tests(changed_files) + + print() + print(f"=== 运行模式: {mode} ===") + print(f"=== 选中测试文件: {len(selected)} 个 ===") + for t in selected[:20]: + print(f" {t}") + if len(selected) > 20: + print(f" ... 还有 {len(selected) - 20} 个") + + # 输出结果文件(供CI后续步骤使用) + output_file = os.environ.get("SELECTED_TESTS_OUTPUT", "") + if output_file: + with open(output_file, "w") as f: + for t in selected: + f.write(t + "\n") + print(f"\n已写入到: {output_file}") + + # 设置环境变量标记 + gh_output = os.environ.get("GITHUB_OUTPUT", "") + if gh_output: + with open(gh_output, "a") as f: + f.write(f"test_count={len(selected)}\n") + f.write(f"mode={incremental if incremental in mode else full}\n") + + # 退出码:0=增量, 1=全量(供CI判断) + sys.exit(0 if "incremental" in mode else 1) + + +if __name__ == "__main__": + main() -- 2.54.0 From bf0d31673b7cb8c4fa854e6d6766c45145049309 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 14:47:24 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8Dselect=5Funit?= =?UTF-8?q?=5Ftests.py=E7=9A=84F401=E5=92=8CF821=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/select_unit_tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/ci/select_unit_tests.py b/scripts/ci/select_unit_tests.py index 98a81e37d..c51eb14c5 100644 --- a/scripts/ci/select_unit_tests.py +++ b/scripts/ci/select_unit_tests.py @@ -17,7 +17,6 @@ import os import sys import subprocess -import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -211,7 +210,7 @@ def main(): if gh_output: with open(gh_output, "a") as f: f.write(f"test_count={len(selected)}\n") - f.write(f"mode={incremental if incremental in mode else full}\n") + f.write("mode=" + ("incremental" if "incremental" in mode else "full") + "\n") # 退出码:0=增量, 1=全量(供CI判断) sys.exit(0 if "incremental" in mode else 1) -- 2.54.0 From cf63c291e87c5585a1eb00b705906838d7c3e21b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 17:07:21 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(ci):=20black=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?select=5Funit=5Ftests.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/select_unit_tests.py | 45 +++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/scripts/ci/select_unit_tests.py b/scripts/ci/select_unit_tests.py index c51eb14c5..58eec6723 100644 --- a/scripts/ci/select_unit_tests.py +++ b/scripts/ci/select_unit_tests.py @@ -14,6 +14,7 @@ 9. 改了alembic/migrations -> 全量 10. 匹配不到测试的改动 -> 全量兜底 """ + import os import sys import subprocess @@ -47,7 +48,7 @@ FULL_RUN_PATTERNS = [ # 目录到测试文件关键词的映射(模糊匹配) DIR_KEYWORD_MAP = { "apps/api/app/api/routes/": "", # 用文件名匹配 - "apps/api/app/services/": "", # 用文件名匹配 + "apps/api/app/services/": "", # 用文件名匹配 "apps/worker/worker_app/tasks/": "", "apps/worker/video_processing/": "", "apps/worker/services/": "", @@ -62,18 +63,20 @@ def get_changed_files(): changed_env = os.environ.get("CHANGED_FILES", "") if changed_env: return [f.strip() for f in changed_env.split("\n") if f.strip()] - + # 回退到git diff(本地调试用) try: result = subprocess.run( ["git", "diff", "--name-only", "origin/develop...HEAD"], - capture_output=True, text=True, cwd=ROOT + capture_output=True, + text=True, + cwd=ROOT, ) if result.returncode == 0: return [f.strip() for f in result.stdout.split("\n") if f.strip()] except Exception: pass - + return [] @@ -91,7 +94,7 @@ def extract_module_name(filepath): """从文件路径提取模块名(用于匹配测试文件)。""" # 去掉扩展名 name = Path(filepath).stem - + # 特殊映射 special_mappings = { # 路由文件 @@ -117,7 +120,7 @@ def extract_module_name(filepath): "video_compose_service": "video_compose", "email_service": "email_service", } - + return special_mappings.get(name, name) @@ -142,18 +145,18 @@ def get_all_test_files(): def select_tests(changed_files): """主函数:选择要运行的测试文件。""" all_tests = get_all_test_files() - + if not changed_files: print("[info] 未找到改动文件,全量运行") return all_tests, "full (no changes detected)" - + if should_full_run(changed_files): return all_tests, "full (core/common files changed)" - + selected = set() test_file_changes = [] source_file_changes = [] - + for f in changed_files: # 测试文件本身改动 if f.startswith("tests/unit/test_") and f.endswith(".py"): @@ -167,14 +170,16 @@ def select_tests(changed_files): if matches: for m in matches: selected.add(m) - print(f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}") + print( + f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}" + ) else: print(f"[nomatch] {f} (module: {module_name}) 未找到匹配的测试文件") - + if not selected: print("[info] 未匹配到任何测试文件,全量运行兜底") return all_tests, "full (no matching tests)" - + return sorted(selected), f"incremental ({len(selected)} test files)" @@ -186,9 +191,9 @@ def main(): if len(changed_files) > 20: print(f" ... 还有 {len(changed_files) - 20} 个") print() - + selected, mode = select_tests(changed_files) - + print() print(f"=== 运行模式: {mode} ===") print(f"=== 选中测试文件: {len(selected)} 个 ===") @@ -196,7 +201,7 @@ def main(): print(f" {t}") if len(selected) > 20: print(f" ... 还有 {len(selected) - 20} 个") - + # 输出结果文件(供CI后续步骤使用) output_file = os.environ.get("SELECTED_TESTS_OUTPUT", "") if output_file: @@ -204,14 +209,16 @@ def main(): for t in selected: f.write(t + "\n") print(f"\n已写入到: {output_file}") - + # 设置环境变量标记 gh_output = os.environ.get("GITHUB_OUTPUT", "") if gh_output: with open(gh_output, "a") as f: f.write(f"test_count={len(selected)}\n") - f.write("mode=" + ("incremental" if "incremental" in mode else "full") + "\n") - + f.write( + "mode=" + ("incremental" if "incremental" in mode else "full") + "\n" + ) + # 退出码:0=增量, 1=全量(供CI判断) sys.exit(0 if "incremental" in mode else 1) -- 2.54.0 From 04a65713cd07241af9e2fa9d73bd8434a6ba6a8c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 17:25:54 +0800 Subject: [PATCH 4/7] =?UTF-8?q?perf(ci):=20ci-l1=E2=86=92ci-check=20?= =?UTF-8?q?=E5=85=85=E5=88=86=E5=88=A9=E7=94=A8=E5=85=A8=E9=83=A8runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 8568f0359..cd781b67a 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -24,7 +24,7 @@ concurrency: jobs: check-frontend-only: name: Check if frontend-only change - runs-on: ci-l1 + runs-on: ci-check if: github.event_name == 'pull_request' outputs: skip_backend: ${{ steps.check.outputs.skip_backend }} @@ -60,7 +60,7 @@ jobs: needs: check-frontend-only if: always() && needs.check-frontend-only.outputs.skip_backend != 'true' name: Validate Code Quality And Tests - runs-on: ci-l1 + runs-on: ci-check timeout-minutes: 10 env: DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas @@ -462,7 +462,7 @@ jobs: ' frontend-lint: name: Frontend Lint - runs-on: ci-l1 + runs-on: ci-check timeout-minutes: 10 steps: - name: Checkout code -- 2.54.0 From 5cf325ab42f417d92e4af3deca18078dcc55ec00 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 17 Jul 2026 17:29:36 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(ci):=20black=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3(line-length=3D120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/select_unit_tests.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/ci/select_unit_tests.py b/scripts/ci/select_unit_tests.py index 58eec6723..faac72e8e 100644 --- a/scripts/ci/select_unit_tests.py +++ b/scripts/ci/select_unit_tests.py @@ -170,9 +170,7 @@ def select_tests(changed_files): if matches: for m in matches: selected.add(m) - print( - f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}" - ) + print(f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}") else: print(f"[nomatch] {f} (module: {module_name}) 未找到匹配的测试文件") @@ -215,9 +213,7 @@ def main(): if gh_output: with open(gh_output, "a") as f: f.write(f"test_count={len(selected)}\n") - f.write( - "mode=" + ("incremental" if "incremental" in mode else "full") + "\n" - ) + f.write("mode=" + ("incremental" if "incremental" in mode else "full") + "\n") # 退出码:0=增量, 1=全量(供CI判断) sys.exit(0 if "incremental" in mode else 1) -- 2.54.0 From 8a7a1a244775d27142eeead5986b47a5565741d7 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 17:52:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(ci):=20isort=20import=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/select_unit_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/select_unit_tests.py b/scripts/ci/select_unit_tests.py index faac72e8e..edc0bb8a2 100644 --- a/scripts/ci/select_unit_tests.py +++ b/scripts/ci/select_unit_tests.py @@ -16,8 +16,8 @@ """ import os -import sys import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -- 2.54.0 From d4091d74d0c3fb41ab2a072da2ec2638b317bd60 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 18:15:33 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(ci):=20=E6=97=A0=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=94=B9=E5=8A=A8=E6=97=B6=E8=B7=B3=E8=BF=87?= =?UTF-8?q?diff-cover=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 39208aa87..488631f21 100755 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -316,7 +316,7 @@ jobs: python3 -m coverage report --fail-under=65 > /dev/null fi - name: Diff coverage check (增量行覆盖率) - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && env.HAS_APP_CHANGES == 'true' shell: sh env: GITHUB_TOKEN: ${{ github.token }} -- 2.54.0