diff --git a/.gitea/scripts/ci_gate.py b/.gitea/scripts/ci_gate.py new file mode 100755 index 0000000..6cb8043 --- /dev/null +++ b/.gitea/scripts/ci_gate.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""提交信息规范校验(CI 侧权威闸门)。 + +规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。 +放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。 +""" +import json +import os +import re +import subprocess +import sys + +TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" +RE = re.compile(r"^(?:" + TYPES + r")(?:\([a-zA-Z0-9._/-]+\))?!?: .{3,}$") +ALLOW = re.compile(r'^(?:Merge |Revert "|fixup! |squash! |amend! )') + +HELP = """ +\u274c 提交信息不合规,已拦截。 + + 规范 : <类型>(<范围>): <简短描述> + 类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑) + refactor 重构 | perf 性能 | test 测试 | build 构建/依赖 + ci CI 配置 | chore 杂务 | revert 回滚 + 示例 : feat(asset): 新增按歌曲生成应援序列 + fix(parser): 修复表格解析越界导致的 500 + docs: 补充 OSS 短签名接口说明 + + 要点 : 类型小写、冒号后一个空格、描述 \u22653 字符 + 标题 \u226472 字符、结尾不加句号 + 破坏性变更类型后加 !,正文写 BREAKING CHANGE: + 详见 : docs/提交规范.md +""" + + +def collect(): + """优先用 Gitea 事件负载里的本次推送提交,取不到则退回 git log。""" + msgs = [] + ev = os.environ.get("GITHUB_EVENT_PATH") or "" + if ev and os.path.exists(ev): + try: + with open(ev, encoding="utf-8") as fh: + d = json.load(fh) + for c in (d.get("commits") or []): + m = (c.get("message") or "").strip() + if m: + msgs.append(m) + except Exception as e: + print(" 事件负载解析失败(%s),回退到 git log" % e) + if not msgs: + out = subprocess.run(["git", "log", "-n", "5", "--format=%B%x00"], + capture_output=True, text=True).stdout + msgs = [x.strip() for x in out.split("\x00") if x.strip()] + return msgs + + +def subject_of(m): + for line in m.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + return s + return "" + + +def judge(subj): + if not subj: + return "空提交信息" + if ALLOW.match(subj): + return "" + if len(subj) > 72: + return "标题 %d 字符 > 72" % len(subj) + if not RE.match(subj): + return "不符合 <类型>(<范围>): <描述>" + if subj.endswith(".") or subj.endswith("\u3002"): + return "标题结尾不要加句号" + return "" + + +def main(): + msgs = collect() + bad = [] + print("待校验提交 %d 条:" % len(msgs)) + for m in msgs: + subj = subject_of(m) + why = judge(subj) + if why: + bad.append((subj, why)) + print(" \u274c [%s] %s" % (why, subj)) + else: + print(" \u2705 %s" % subj) + try: + with open("/tmp/ci-gate-status", "w") as fh: + fh.write("failure" if bad else "success") + except Exception: + pass + if bad: + print(HELP) + return 1 + print("\u2705 提交信息全部合规") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitea/scripts/ci_report.py b/.gitea/scripts/ci_report.py new file mode 100755 index 0000000..42dcd5d --- /dev/null +++ b/.gitea/scripts/ci_report.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""把 CI 结果上报给主机侧 ci-bridge(bridge 负责落报告页 + 推微信)。""" +import json +import os +import subprocess +import urllib.request + + +def rd(path, default=""): + try: + with open(path, encoding="utf-8") as fh: + return fh.read().strip() + except Exception: + return default + + +def git(*args): + try: + return subprocess.run(["git"] + list(args), capture_output=True, + text=True).stdout.strip() + except Exception: + return "" + + +def main(): + repo = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown") + sha = rd("/tmp/ci-sha") or git("rev-parse", "HEAD") + branch = os.environ.get("GITHUB_REF_NAME") or \ + os.environ.get("GITHUB_REF", "").replace("refs/heads/", "") + gate = rd("/tmp/ci-gate-status", "skipped") + build = rd("/tmp/ci-build-status", "failure") + status = "success" if (gate == "success" and build == "success") else "failure" + + log = rd("/tmp/ci-build-tail") + if gate != "success": + log = "提交信息规范校验未通过\n" + log + + try: + count = int(rd("/tmp/ci-commits-count", "1") or 1) + except Exception: + count = 1 + + rep = { + "repo": repo, + "sha": sha, + "branch": branch, + "actor": os.environ.get("GITHUB_ACTOR", ""), + "status": status, + "commit_subject": git("log", "-1", "--format=%s"), + "commit_count": count, + "duration_s": rd("/tmp/ci-duration", "?"), + "preview_url": rd("/tmp/ci-preview-url"), + "log": log[-3500:], + "steps": [ + {"name": "提交信息规范校验", "status": gate}, + {"name": "编译与自检", "status": build}, + ], + } + if rep["preview_url"]: + print("\U0001F310 网页预览: %s" % rep["preview_url"]) + print("结果: %s | 构建: %s | 提交闸门: %s | 耗时: %ss" + % (status, build, gate, rep["duration_s"])) + + base = os.environ.get("CI_BRIDGE_RESOLVED", "").rstrip("/") + if not base: + print("跳过上报:未找到可达的 ci-bridge") + return 0 + data = json.dumps(rep, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + base + "/api/ci/report", data=data, method="POST", + headers={"Content-Type": "application/json", + "X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")}) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + print("\u2705 已上报 ci-bridge:", resp.read().decode()[:300]) + except Exception as e: + print("\u274c 上报 ci-bridge 失败:", e) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..1292cc5 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,156 @@ +name: build + +# 提交 -> 本机 Runner 编译 -> 微信通知 + 网页预览 +# 实测结论(勿随意改动): +# 1. 不用任何第三方 action(作业容器不联网拉 action,全用镜像自带工具) +# 2. 必须自己 clone —— Runner 不会自动检出代码,工作区初始为空 +# 3. act 以 `bash -e -o pipefail` 执行每步 -> 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道 +# 4. 工具链来自主机只读挂载 /opt/citools(go/uv),缓存写 /opt/citools-cache +# 5. bridge 从容器内走 docker 网关 172.17.0.1:8899(实测可达) +# 6. 校验/上报逻辑放在 .gitea/scripts/ 下的脚本里,YAML 只负责调用(避免 YAML 里嵌深缩进代码) +on: + push: + branches: + - "**" + workflow_dispatch: + +env: + BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }} + BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }} + GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: 检出代码 + run: | + set -uo pipefail + SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}" + HP="${SRV#*://}" + BR="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}" + cd "${GITHUB_WORKSPACE:-$(pwd)}" + rm -rf ./* .git 2>/dev/null || true + git init -q . + git remote add origin "http://x-access-token:${GIT_TOKEN}@${HP}/${GITHUB_REPOSITORY}.git" + git fetch -q --depth 50 origin "$BR" + git checkout -q -B "$BR" FETCH_HEAD + git rev-parse HEAD > /tmp/ci-sha + date +%s > /tmp/ci-start + : > /tmp/ci-preview-url + echo skipped > /tmp/ci-build-status + git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count + echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BR}" + git log -1 --format=' %h %an %s' + + - name: 提交信息规范校验 + run: python3 .gitea/scripts/ci_gate.py + + - name: 编译与自检 + run: | + set +e; set +o pipefail + LOG=/tmp/ci-build.log; : > "$LOG" + run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; } + runin(){ local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; } + + export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH + export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod UV_CACHE_DIR=/opt/citools-cache/uv + export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod GOPATH=/opt/citools-cache/gopath + mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null + + echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)" + fail=0; ran="" + + if [ -f go.mod ]; then + ran="$ran go" + run go build ./... || fail=1 + run go vet ./... || fail=1 + run go test ./... || fail=1 + fi + + if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then + ran="$ran python" + if grep -qE '^check:' Makefile 2>/dev/null; then + run make UV=uv check || fail=1 + else + run uv sync || fail=1 + run uv run pytest -q || fail=1 + fi + fi + + WEBPKG="" + for p in package.json web/package.json frontend/package.json ui/package.json; do + if [ -f "$p" ]; then WEBPKG="$p"; break; fi + done + if [ -n "$WEBPKG" ]; then + ran="$ran node" + D="$(dirname "$WEBPKG")" + if [ -f "$D/package-lock.json" ]; then + runin "$D" npm ci || fail=1 + else + runin "$D" npm install || fail=1 + fi + if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then + runin "$D" npm run build || fail=1 + fi + fi + + if [ -z "$ran" ] && [ -f Makefile ]; then + ran="$ran make" + run make || fail=1 + fi + if [ -z "$ran" ]; then + echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译" + fi + + if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi + tail -c 4000 "$LOG" > /tmp/ci-build-tail + echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)" + [ "$fail" -eq 0 ] || exit 1 + + - name: 发布网页预览 + if: always() + run: | + set +e; set +o pipefail + : > /tmp/ci-preview-url + if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then + echo "构建未成功,跳过预览发布"; exit 0 + fi + WEB="" + for d in web/dist dist build out public web/build frontend/dist; do + if [ -f "$d/index.html" ]; then WEB="$d"; break; fi + done + if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi + tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; } + B="${BRIDGE_URL:-}" + for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do + if [ -n "$cand" ]; then + curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; } + fi + done + SHA="$(cat /tmp/ci-sha)" + echo "发布预览到 $B(产物目录 $WEB)" + curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" -H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' --data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json + cat /tmp/ci-pub.json; echo + URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)" + if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi + + - name: 通知微信 + if: always() + run: | + set +e; set +o pipefail + B="${BRIDGE_URL:-}" + for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do + if [ -n "$cand" ]; then + curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; } + fi + done + echo "使用 ci-bridge: $B" + S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S + echo $((S - T)) > /tmp/ci-duration + CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py diff --git a/.gitea/workflows/ci-diag.yml b/.gitea/workflows/ci-diag.yml deleted file mode 100644 index 8704fc7..0000000 --- a/.gitea/workflows/ci-diag.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: diag -# 临时诊断 workflow:摸清作业容器环境,跑通后删除 -on: - push: - workflow_dispatch: - -jobs: - diag: - runs-on: ubuntu-latest - steps: - - name: 环境自检 - run: | - echo "=== whoami / uid ==="; id - echo "=== pwd ==="; pwd - echo "=== 当前目录 ==="; ls -a - echo "=== /workspace ==="; ls -a /workspace 2>/dev/null || echo "(无 /workspace)" - echo "=== 环境变量名 ==="; env | cut -d= -f1 | sort | tr '\n' ' '; echo - echo "=== Gitea 上下文 ===" - echo "repo=$GITHUB_REPOSITORY ref=$GITHUB_REF_NAME sha=$GITHUB_SHA" - echo "server=$GITHUB_SERVER_URL event=$GITHUB_EVENT_NAME actor=$GITHUB_ACTOR" - echo "before=$GITHUB_EVENT_BEFORE" - if [ -n "${GITHUB_TOKEN:-}" ]; then echo "GITHUB_TOKEN 注入=YES len=${#GITHUB_TOKEN}"; else echo "GITHUB_TOKEN 注入=NO"; fi - echo "=== 工具 ===" - for t in git curl python3 pip node npm go uv make docker jq; do printf "%-8s " "$t"; command -v "$t" || echo "(缺失)"; done - echo "=== 版本 ==="; git --version; python3 -V || true; node -v 2>/dev/null || true; go version 2>/dev/null || true; uv --version 2>/dev/null || true - echo "=== 是否已是 git 仓库 ===" - git rev-parse --is-inside-work-tree 2>&1 || true - git log --oneline -3 2>&1 || true - echo "=== 默认路由 ==="; ip route 2>/dev/null | head -5 || true - echo "=== 到 ci-bridge 连通性 ===" - for c in "http://172.17.0.1:8899" "http://$(ip route show default 2>/dev/null | awk '{print $3; exit}'):8899" "http://192.168.50.134:8899"; do - printf "%-34s -> " "$c" - curl -s -m 4 "$c/healthz" | head -c 110 - echo - done - echo "=== 到 Gitea 服务端连通性 ===" - curl -s -o /dev/null -w "gitea http=%{http_code}\n" -m 6 "$GITHUB_SERVER_URL/api/v1/version" || echo "(fail)" - echo "=== 诊断结束 ===" \ No newline at end of file diff --git a/.gitea/workflows/ci-diag2.yml b/.gitea/workflows/ci-diag2.yml deleted file mode 100644 index 6c882bd..0000000 --- a/.gitea/workflows/ci-diag2.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: diag2 -# 临时诊断 v2:网络出口 / GITHUB_TOKEN / clone 可行性 / 工具链可安装性 -# 注意: act 以 bash -e -o pipefail 执行每步,故全程禁用会提前退出的管道 -on: - push: - workflow_dispatch: - -jobs: - diag2: - runs-on: ubuntu-latest - steps: - - name: 网络与令牌自检 - env: - GT: ${{ secrets.GITHUB_TOKEN }} - run: | - set +e - echo "=== GITHUB_TOKEN(secrets 上下文) ===" - if [ -n "${GT}" ]; then echo " 已注入 len=${#GT}"; else echo " 未注入"; fi - - echo "=== 外网出口 ===" - for u in https://pypi.org/simple/ https://go.dev/dl/ https://registry.npmjs.org/ https://goproxy.cn/ https://github.com/ http://192.168.50.223:3000/; do - c=$(curl -s -o /dev/null -m 8 -w '%{http_code}' "$u") - echo " $u -> ${c:-ERR}" - done - - echo "=== DNS ===" - getent hosts go.dev || echo " go.dev 解析失败" - getent hosts goproxy.cn || echo " goproxy.cn 解析失败" - - echo "=== 基础镜像里有没有 ip/ss ===" - command -v ip || echo " ip 缺失" - command -v ss || echo " ss 缺失" - - echo "=== docker.sock 是否挂进容器 ===" - ls -la /var/run/docker.sock 2>&1 || echo " 无 docker.sock" - docker ps 2>&1 | head -n 3 || true - - echo "=== 尝试用 GITHUB_TOKEN 手动 clone ===" - mkdir -p /tmp/ck && cd /tmp/ck - if [ -n "${GT}" ]; then - git clone --quiet --depth 1 "http://x-access-token:${GT}@192.168.50.223:3000/${GITHUB_REPOSITORY}.git" repo 2>&1 || echo " token clone 失败" - else - echo " 跳过(无 token)" - fi - echo "=== 匿名 clone(公开库)===" - git clone --quiet --depth 1 "http://192.168.50.223:3000/${GITHUB_REPOSITORY}.git" repo2 2>&1 || echo " 匿名 clone 失败" - ls -a repo repo2 2>&1 | head -n 20 || true - - echo "=== 工具链可安装性 ===" - pip download --no-deps -q -d /tmp/pipck uv >/dev/null 2>&1 && echo " pip 可取 uv: OK" || echo " pip 取 uv: 失败" - curl -s -o /dev/null -m 20 -w ' go tarball http=%{http_code} size=%{size_download}\n' -r 0-1000 https://goproxy.cn/ || true - echo "=== 诊断 v2 结束 ===" \ No newline at end of file