From a94c121009aa20ad50f8b1d3b8d96ba0e9e1729e Mon Sep 17 00:00:00 2001 From: Xi Xu Date: Thu, 14 May 2026 12:53:14 +0800 Subject: [PATCH] ci: auto-merge green dependabot updates --- .github/workflows/ci.yml | 91 ++++++++++++ .github/workflows/dependabot-auto-merge.yml | 147 ++++++++++++++++++++ README.md | 14 +- 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..42093f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate generated JSON + run: | + python3 -m json.tool data/tvbox.json > /dev/null + python3 -m json.tool data/tvbox.meta.json > /dev/null + + - name: Test extractor success path + run: | + temp_dir="$(mktemp -d)" + trap 'rm -rf "${temp_dir}"' EXIT + + payload_path="${temp_dir}/payload.bin" + PAYLOAD_PATH="${payload_path}" python3 - <<'PY' + import base64 + import os + + decoded_json = b'{"sites":[{"key":"demo"}]}' + payload = ( + b"\xff\xd8\xff\xd9" + + b"prefix**" + + base64.b64encode(decoded_json) + ) + + with open(os.environ["PAYLOAD_PATH"], "wb") as payload_file: + payload_file.write(payload) + PY + + payload_url="$(python3 - "${payload_path}" <<'PY' + import sys + from pathlib import Path + + print(Path(sys.argv[1]).resolve().as_uri()) + PY + )" + + TVBOX_URL="${payload_url}" \ + OUT_JSON="${temp_dir}/tvbox.json" \ + OUT_META="${temp_dir}/tvbox.meta.json" \ + python3 scripts/extract_tvbox.py > /dev/null + + python3 -m json.tool "${temp_dir}/tvbox.json" > /dev/null + python3 -m json.tool "${temp_dir}/tvbox.meta.json" > /dev/null + + - name: Test extractor upstream error path + run: | + temp_dir="$(mktemp -d)" + trap 'rm -rf "${temp_dir}"' EXIT + + bad_payload_path="${temp_dir}/bad-payload.bin" + printf 'not-a-tvbox-payload' > "${bad_payload_path}" + + bad_payload_url="$(python3 - "${bad_payload_path}" <<'PY' + import sys + from pathlib import Path + + print(Path(sys.argv[1]).resolve().as_uri()) + PY + )" + + set +e + TVBOX_URL="${bad_payload_url}" \ + OUT_JSON="${temp_dir}/tvbox.json" \ + OUT_META="${temp_dir}/tvbox.meta.json" \ + python3 scripts/extract_tvbox.py > /dev/null 2>&1 + status="$?" + set -e + + if [ "${status}" -ne 75 ]; then + echo "Expected upstream error exit code 75, got ${status}." + exit 1 + fi diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..704fd1c --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,147 @@ +name: Dependabot auto-merge + +on: + workflow_run: + workflows: + - CI + types: + - completed + +permissions: + checks: read + contents: write + pull-requests: write + statuses: read + +jobs: + merge: + if: ${{ github.event.workflow_run.event == 'pull_request' }} + runs-on: ubuntu-latest + + steps: + - name: Inspect completed PR checks + id: decision + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + PULL_REQUESTS_JSON: ${{ toJson(github.event.workflow_run.pull_requests) }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + if [ "${WORKFLOW_CONCLUSION}" != "success" ]; then + echo "Completed workflow conclusion is ${WORKFLOW_CONCLUSION}; skipping." + echo "merge=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + pr_number="$(python3 - <<'PY' + import json + import os + + pull_requests = json.loads(os.environ["PULL_REQUESTS_JSON"]) + print(pull_requests[0]["number"] if pull_requests else "") + PY + )" + + if [ -z "${pr_number}" ]; then + echo "Completed workflow is not associated with a pull request; skipping." + echo "merge=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + pr_file="${RUNNER_TEMP}/pr.json" + checks_file="${RUNNER_TEMP}/checks.json" + + gh api "repos/${REPOSITORY}/pulls/${pr_number}" > "${pr_file}" + gh pr view "${pr_number}" \ + --repo "${REPOSITORY}" \ + --json statusCheckRollup \ + > "${checks_file}" + + python3 - "${pr_file}" "${checks_file}" <<'PY' + import json + import os + import sys + + GREEN_CHECK_CONCLUSIONS = {"SUCCESS", "NEUTRAL", "SKIPPED"} + GREEN_STATUS_STATES = {"SUCCESS"} + + + def set_output(name: str, value: str) -> None: + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"{name}={value}\n") + + + def skip(reason: str) -> None: + print(reason) + set_output("merge", "false") + raise SystemExit(0) + + + with open(sys.argv[1], encoding="utf-8") as pr_json: + pull_request = json.load(pr_json) + + with open(sys.argv[2], encoding="utf-8") as checks_json: + checks = json.load(checks_json)["statusCheckRollup"] + + repository = os.environ["REPOSITORY"] + default_branch = os.environ["DEFAULT_BRANCH"] + + if pull_request["user"]["login"] != "dependabot[bot]": + skip("Pull request author is not Dependabot; skipping.") + + if pull_request["state"] != "open": + skip("Pull request is not open; skipping.") + + if pull_request["draft"]: + skip("Pull request is a draft; skipping.") + + if pull_request["base"]["repo"]["full_name"] != repository: + skip("Pull request targets a different repository; skipping.") + + if pull_request["head"]["repo"]["full_name"] != repository: + skip("Pull request comes from a fork; skipping.") + + if pull_request["base"]["ref"] != default_branch: + skip("Pull request does not target the default branch; skipping.") + + if not checks: + skip("Pull request has no checks; skipping.") + + not_green = [] + for check in checks: + check_type = check.get("__typename") + if check_type == "CheckRun": + name = check.get("name", "") + status = check.get("status") + conclusion = check.get("conclusion") + if status != "COMPLETED" or conclusion not in GREEN_CHECK_CONCLUSIONS: + not_green.append(f"{name}: {status}/{conclusion}") + elif check_type == "StatusContext": + context = check.get("context", "") + state = check.get("state") + if state not in GREEN_STATUS_STATES: + not_green.append(f"{context}: {state}") + else: + not_green.append(f"Unsupported status item: {check_type}") + + if not_green: + skip("Not all pull request checks are green:\n" + "\n".join(not_green)) + + set_output("merge", "true") + set_output("head_sha", pull_request["head"]["sha"]) + set_output("pr_url", pull_request["html_url"]) + print(f"Dependabot PR #{pull_request['number']} is green and eligible to merge.") + PY + + - name: Squash-merge Dependabot PR + if: ${{ steps.decision.outputs.merge == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.decision.outputs.head_sha }} + PR_URL: ${{ steps.decision.outputs.pr_url }} + run: | + gh pr merge "${PR_URL}" \ + --squash \ + --delete-branch \ + --match-head-commit "${HEAD_SHA}" diff --git a/README.md b/README.md index f4a3655..1b8a9e4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,13 @@ TVBox JSON Monitor 定时抓取一个 TVBox 兼容端点,从 JPEG-like 响应 | [`data/tvbox.json`](data/tvbox.json) | 解码并格式化后的 TVBox 配置,保持为标准 JSON。 | | [`data/tvbox.meta.json`](data/tvbox.meta.json) | 最近一次抓取与解码的元数据,包括哈希、大小、响应头和源地址。 | -可直接使用 raw 地址读取最新 JSON: +推荐使用 [Xget](https://github.com/xixu-me/xget) 加速地址读取最新 JSON: + +```text +https://xget.xi-xu.me/gh/xixu-me/tvbox-json-monitor/raw/refs/heads/main/data/tvbox.json +``` + +也可以直接使用 GitHub raw 地址: ```text https://raw.githubusercontent.com/xixu-me/tvbox-json-monitor/main/data/tvbox.json @@ -44,6 +50,12 @@ chore(tvbox): update decoded JSON 2026-05-13T16:00:00Z 如果上游端点临时不可用、返回格式异常或解码失败,本次更新会被跳过,已有数据保持不变,工作流不会因为这类上游问题失败。脚本使用退出码 `75` 标记这类上游问题;仓库变量缺失、脚本错误或文件写入失败等非上游问题仍会使工作流失败。 +## 依赖更新 + +Dependabot 每周检查 GitHub Actions 依赖并创建更新 PR。仓库通过 `CI` workflow 校验 PR;当 Dependabot PR 关联的所有 checks 都为绿色时,`Dependabot auto-merge` workflow 会自动 squash-merge 该 PR 并删除更新分支。 + +自动合并 workflow 不 checkout PR 代码,也不执行 PR 分支中的脚本;它只读取 PR 元数据和 check 状态,并且只处理作者为 `dependabot[bot]`、目标分支为默认分支的同仓库 PR。 + ## 仓库配置 工作流依赖一个 GitHub Actions repository variable: