# erlang_pay:纯 Erlang 第三方支付库 #12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # imboy 质量门 — Erlang lint + Contract + Secrets | |
| # | |
| # 关联:.claude/plans/quality-loop.md v1.3 T4.1(v1.1 §301: 三仓各一份;v1.3 §490: erlang 用 make 链) | |
| # 与 backend-ci.yml 互补:本 workflow 仅跑质量门(lint/secrets/contract),不跑 build/eunit/dialyze | |
| # Ratchet 上限(debt-baseline-20260509.md):elvis 8824 / gitleaks 0(2026-06-10 收紧,历史 9 条经 .gitleaksignore 豁免)/ redocly 4 errors+9 warnings | |
| # 创建:2026-05-09 / iteration 74 / T4.1 | |
| name: imboy Quality Gate | |
| on: | |
| push: | |
| branches: [main, dev] | |
| pull_request: | |
| branches: [main, dev] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| jobs: | |
| # ---------------------------------------------------------------- | |
| # 1) Erlang lint — elvis 5.0.3 风格检查(不跑 dialyze/xref,那是业务 CI) | |
| # ---------------------------------------------------------------- | |
| erlang-lint: | |
| name: Erlang lint (elvis) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Erlang/OTP | |
| uses: erlef/setup-beam@v1 | |
| with: | |
| otp-version: '28' | |
| - name: Install elvis | |
| run: | | |
| # elvis 5.0.3 from brew formula source (escript build via rebar3) | |
| set -euo pipefail | |
| mkdir -p ~/bin | |
| curl -fsSL https://github.com/inaka/elvis/releases/download/5.0.3/elvis -o ~/bin/elvis || \ | |
| (echo "fallback: build from source"; \ | |
| git clone --depth 1 -b 5.0.3 https://github.com/inaka/elvis /tmp/elvis && \ | |
| cd /tmp/elvis && rebar3 escriptize && cp _build/default/bin/elvis ~/bin/elvis) | |
| chmod +x ~/bin/elvis | |
| echo "$HOME/bin" >> "$GITHUB_PATH" | |
| ~/bin/elvis --version || true | |
| - name: Run elvis (ratchet — 当前 baseline 8824 violations / 239 FAIL files) | |
| run: | | |
| set -euo pipefail | |
| make lint-erlang 2>&1 | tee elvis-output.txt | |
| # 去 ANSI 色码再统计(elvis 默认 colored output) | |
| sed -E $'s/\x1b\\[[0-9;]*m//g' elvis-output.txt > elvis-clean.txt | |
| # 用 awk 匹配(不依赖 BSD/GNU grep 的 [[:space:]] 行为差异) | |
| # violations: " - At line ..." 起头(4+ 空格 + dash + space + "At" + " line") | |
| violations=$(awk '/^[[:space:]]+- At line/{c++} END{print c+0}' elvis-clean.txt) | |
| # FAIL 文件: "# path [FAIL]" 起头 | |
| fail_files=$(awk '/^# .* \[FAIL\]/{c++} END{print c+0}' elvis-clean.txt) | |
| echo "elvis_violations=$violations / fail_files=$fail_files" | |
| if [ "$violations" -gt 8824 ]; then | |
| echo "::error::Elvis violations $violations exceed baseline 8824 (ratchet)" | |
| exit 1 | |
| fi | |
| if [ "$fail_files" -gt 239 ]; then | |
| echo "::error::Elvis FAIL files $fail_files exceed baseline 239 (ratchet)" | |
| exit 1 | |
| fi | |
| # ---------------------------------------------------------------- | |
| # 2) Contract — OpenAPI lint(imboy 是契约真源) | |
| # ---------------------------------------------------------------- | |
| contract-lint: | |
| name: Contract lint (redocly) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '24' | |
| - name: Install Redocly CLI | |
| run: npm i -g @redocly/cli@latest | |
| - name: Lint OpenAPI (ratchet — 4 errors / 9 warnings baseline) | |
| run: | | |
| set -euo pipefail | |
| if ! redocly lint api/openapi.yaml; then | |
| echo "::warning::Redocly lint failed — see baseline ratchet (4 errors / 9 warnings)" | |
| errors=$(redocly lint api/openapi.yaml 2>&1 | grep -oE "[0-9]+ errors" | head -1 | awk '{print $1}') | |
| errors=${errors:-0} | |
| if [ "$errors" -gt 4 ]; then | |
| echo "::error::OpenAPI errors $errors exceed baseline 4 (ratchet)" | |
| exit 1 | |
| fi | |
| fi | |
| # T3.2 split (2026-05-16):multi-file $ref bundle 验证 | |
| # 失败信号:跨文件 $ref 解析错误 / 文件缺失 / YAML 锚点污染(codegen 杀手) | |
| - name: Bundle OpenAPI multi-file → single file | |
| run: | | |
| set -euo pipefail | |
| redocly bundle api/openapi.yaml -o api/openapi.bundled.yaml | |
| # 防御:bundle 后不应出现 YAML alias 锚点 (*ref_N / &ref_N), | |
| # 否则下游 openapi-generator (dart/ts) 会生成异名类型。 | |
| if grep -nE "(^|[[:space:]])\*ref_|&ref_" api/openapi.bundled.yaml; then | |
| echo "::error::Bundle contains YAML aliases — component filename must match PascalCase key" | |
| exit 1 | |
| fi | |
| echo "::notice::Bundle clean — $(wc -l < api/openapi.bundled.yaml) lines" | |
| - name: Upload bundled OpenAPI artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: openapi-bundled | |
| path: api/openapi.bundled.yaml | |
| retention-days: 14 | |
| # ---------------------------------------------------------------- | |
| # 3) oasdiff — OpenAPI breaking change 检测(仅 PR) | |
| # v1.1 §331: oasdiff job 仅放在 imboy(契约真源仓) | |
| # v1.3 T4.2: PR 提交时对比 base vs head 的 openapi.yaml,breaking 即 fail | |
| # ---------------------------------------------------------------- | |
| oasdiff-breaking: | |
| name: OpenAPI breaking change check | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - name: Checkout PR head | |
| uses: actions/checkout@v4 | |
| with: | |
| path: head | |
| - name: Checkout PR base | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.pull_request.base.sha }} | |
| path: base | |
| - name: Install oasdiff | |
| run: | | |
| set -euo pipefail | |
| curl -fsSL https://github.com/oasdiff/oasdiff/releases/download/v1.15.2/oasdiff_1.15.2_linux_amd64.tar.gz \ | |
| | tar -xz -C /tmp oasdiff | |
| /tmp/oasdiff --version | |
| # T3.2 split (2026-05-16):multi-file 适配 — oasdiff 不会自动解析跨文件 $ref, | |
| # 必须先用 redocly bundle 把 base/head 各自合并为单文件再做 diff,否则 | |
| # 跨文件 $ref 会被当作字符串比较,导致漏报或误报。 | |
| - name: Setup Node + Redocly for bundle | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '24' | |
| - name: Install Redocly CLI | |
| run: npm i -g @redocly/cli@latest | |
| - name: Bundle base & head OpenAPI | |
| run: | | |
| set -euo pipefail | |
| # base 仓在 phase 1 之前可能没有 api/components/,bundle 失败时降级 | |
| # 为直接使用 base/api/openapi.yaml;head 总是新结构,必须 bundle 成功。 | |
| if [ -d base/api/components ]; then | |
| redocly bundle base/api/openapi.yaml -o /tmp/oas.base.yaml | |
| else | |
| echo "::notice::Base lacks api/components/ — falling back to raw openapi.yaml" | |
| cp base/api/openapi.yaml /tmp/oas.base.yaml | |
| fi | |
| redocly bundle head/api/openapi.yaml -o /tmp/oas.head.yaml | |
| - name: Diff base vs head openapi.yaml (changelog summary) | |
| run: | | |
| set -euo pipefail | |
| /tmp/oasdiff diff /tmp/oas.base.yaml /tmp/oas.head.yaml --format markup || true | |
| - name: Breaking changes (fail PR if any) | |
| run: | | |
| set -euo pipefail | |
| if /tmp/oasdiff breaking /tmp/oas.base.yaml /tmp/oas.head.yaml --fail-on ERR; then | |
| echo "::notice::No breaking changes detected" | |
| else | |
| echo "::error::Breaking change detected in api/openapi.yaml" | |
| echo "::error::请在 PR 描述中说明影响范围 + 升 major version + 通知 imboyapp/admin 客户端" | |
| exit 1 | |
| fi | |
| # ---------------------------------------------------------------- | |
| # 4) Secrets — gitleaks | |
| # ---------------------------------------------------------------- | |
| secrets-scan: | |
| name: Secrets scan (gitleaks) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # gitleaks 需要历史 | |
| # 历史 9 条泄露已逐条定性并经 .gitleaksignore 精确豁免(指纹级), | |
| # 详见 docs/operations/security-leak-assessment-2026-06.md;新增泄露即红。 | |
| - name: Run gitleaks (ratchet — baseline 0) | |
| uses: gitleaks/gitleaks-action@v2 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| config-path: .gitleaks.toml | |
| - name: Verify no new leaks | |
| run: | | |
| set -euo pipefail | |
| gitleaks detect --no-git --config .gitleaks.toml --redact --no-banner \ | |
| --report-format json --report-path /tmp/leaks.json || true | |
| count=$(jq '. | length' /tmp/leaks.json 2>/dev/null || echo 0) | |
| echo "leaks=$count" | |
| if [ "$count" -gt 0 ]; then | |
| echo "::error::Gitleaks found $count leaks, baseline is 0 (see docs/operations/security-leak-assessment-2026-06.md)" | |
| exit 1 | |
| fi | |
| # ---------------------------------------------------------------- | |
| # 5) Architecture gates — 层边界 + 文件大小 + 静默吞错检查 | |
| # 2026-06-03: 源自 verified-audit-final.md P1-5 | |
| # ---------------------------------------------------------------- | |
| architecture-gates: | |
| name: Architecture gates (layer / size / silent-catch) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check layer boundaries | |
| run: | | |
| set -euo pipefail | |
| echo "=== API → Repo (must be 0) ===" | |
| api_repo=$(grep -rn "_repo:" src/api/ --include="*.erl" | grep -v "%%" | wc -l || echo 0) | |
| echo "API→Repo violations: $api_repo" | |
| if [ "$api_repo" -gt 0 ]; then | |
| echo "::error::API layer must not call Repo directly ($api_repo violations)" | |
| exit 1 | |
| fi | |
| echo "=== Repo → Logic (must be 0) ===" | |
| repo_logic=$(grep -rn "_logic:" src/repo/ --include="*.erl" | grep -v "%%" | wc -l || echo 0) | |
| echo "Repo→Logic violations: $repo_logic" | |
| if [ "$repo_logic" -gt 0 ]; then | |
| echo "::error::Repo layer must not call Logic directly ($repo_logic violations)" | |
| exit 1 | |
| fi | |
| - name: Check file size (hand-written .erl must be ≤ 800 lines) | |
| run: | | |
| set -euo pipefail | |
| # Baseline: current oversized files (2026-06-03) | |
| # adm_group_handler 2218, imboy_policy 2185, channel_handler 999, | |
| # imboy_router 908, websocket_handler 847, adm_channel_handler 817 | |
| oversized=$(find src -name "*.erl" ! -name "imboy_pb.erl" -exec wc -l {} + \ | |
| | awk '$1 > 800 && !/total/ {print}' || true) | |
| if [ -n "$oversized" ]; then | |
| count=$(echo "$oversized" | wc -l | tr -d ' ') | |
| echo "::warning::$count files exceed 800 lines (baseline 6 files, ratchet)" | |
| echo "$oversized" | |
| # Fail only if NEW oversized files appear (baseline = 6) | |
| if [ "$count" -gt 6 ]; then | |
| echo "::error::New oversized files detected (baseline 6, found $count)" | |
| exit 1 | |
| fi | |
| fi | |
| - name: Check silent error swallowing (catch _:_ must not appear) | |
| run: | | |
| set -euo pipefail | |
| silent=$(grep -rn "catch _:_ ->" src/ --include="*.erl" | grep -v "%%" | wc -l || echo 0) | |
| echo "Silent catch _:_ count: $silent" | |
| if [ "$silent" -gt 0 ]; then | |
| echo "::error::Found $silent silent catch _:_ — use catch _:Err + logger:warning instead" | |
| grep -rn "catch _:_ ->" src/ --include="*.erl" | grep -v "%%" | |
| exit 1 | |
| fi | |
| api-ds-ratchet: | |
| name: API→DS 层违规不得增长 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: 检查 API→DS 违规数(上限 131) | |
| run: | | |
| COUNT=$(grep -rn "_ds:" src/api/ --include="*.erl" \ | |
| | grep -v "%%\|auth_ds:\|config_ds:" | wc -l | tr -d ' ') | |
| echo "当前 API→DS 违规: $COUNT (上限 131)" | |
| if [ "$COUNT" -gt "131" ]; then | |
| echo "FAIL: 违规增加 $COUNT > 131" | |
| exit 1 | |
| fi |