工程实践

云端 Mac CI 的 iOS 开源许可清单门禁

云端 Mac CI 的 iOS 开源许可清单门禁

团队在依赖升级后顺利归档,却在发布前才发现新组件没有进入开源许可清单。问题通常不在编译,而在依赖来源分散:SwiftPM 有锁文件,CocoaPods 有另一套目录,仓库里还可能直接复制源码或加入二进制包。把检查放到云端 Mac CI,可以让同一份工程在固定工作区内生成清单、比较策略并保存证据,而不是靠发布负责人临时翻目录。

先定义门禁要回答的问题

一条可执行的门禁至少要回答四件事:本次构建实际解析了哪些组件、版本或提交是什么、许可证全文在哪里、该条件是否已经批准。不要只搜索文件名后直接放行,因为 LICENSE 存在不代表内容没有变化,也不代表组件真的被当前目标链接。

建议把结果拆成机器清单和人工报告。机器清单使用 JSON,记录组件名、来源、锁定版本、许可证类型、文本哈希与发现路径;人工报告使用 Markdown,展示新增、删除和变化项。两者都作为流水线产物保留。

门禁检查的是依赖事实与既定策略是否一致。发现未知项时应停止发布并请求复核,而不是根据许可证名称自动猜测处理方式。

从构建结果盘点真实依赖

先让 Xcode 完成依赖解析,再读取工作区实际使用的目录。这样可以避免扫描到开发者机器上的旧缓存。每个任务使用独立的 DerivedData,任务结束后再清理。

set -euo pipefail

ROOT="$(pwd)"
OUT="$ROOT/build/license-audit"
DD="$ROOT/build/DerivedData"
mkdir -p "$OUT"

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -resolvePackageDependencies \
  -derivedDataPath "$DD"

find "$DD/SourcePackages/checkouts" \
  -maxdepth 3 \
  -type f \
  \( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' \) \
  -print0 | sort -z > "$OUT/swiftpm-license-files.bin"

find "$ROOT/Pods" \
  -maxdepth 4 \
  -type f \
  \( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' \) \
  -print0 | sort -z > "$OUT/pods-license-files.bin"

二进制依赖不能只看文件扩展名。应同时登记下载地址对应的内部组件标识、校验和、版本和随包提供的声明。仓库内复制的源码则维护一份 compliance/manual-components.json,明确目录所有者,避免每次扫描都依赖模糊匹配。

锁文件必须同时入账

把 Package.resolved、Podfile.lock 和人工组件清单复制到报告目录。门禁要验证清单中的每个组件都能在某个锁定来源中找到,反向也要验证锁文件里的直接依赖没有从报告消失。若工程包含多个工作区,应逐个解析,不能只检查主应用。

规范化许可证并计算指纹

许可证文本经常只有换行符或尾部空格不同。可以先统一为 UTF-8、LF 换行并移除行尾空白,再计算 SHA-256。不要删除正文段落、版权行或年份,否则真实变化会被掩盖。

python3 - <<'PY'
from pathlib import Path
import hashlib
import json

root = Path("build/license-audit/licenses")
rows = []
for path in sorted(root.rglob("*")):
    if not path.is_file():
        continue
    text = path.read_text(encoding="utf-8", errors="strict")
    normalized = "\n".join(line.rstrip() for line in text.splitlines()).strip() + "\n"
    digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    rows.append({"path": str(path), "sha256": digest})
Path("build/license-audit/license-hashes.json").write_text(
    json.dumps(rows, ensure_ascii=False, indent=2) + "\n",
    encoding="utf-8"
)
PY

许可证类型识别与文本指纹要分开保存。类型用于匹配策略,指纹用于发现上游悄然修改。无法可靠识别时标记为 UNKNOWN,不得因为文件名看起来熟悉就自动归类。

用策略文件控制变化

策略文件应进入版本控制,并由明确的代码审查流程修改。可以按许可证标识设置 allow、review 和 deny,同时为特定组件记录已批准的文本哈希。检查结果建议采用以下判定:

情况 CI 结果 后续动作
已知组件,版本与文本未变 通过 保存报告
新版本,许可证指纹未变 复核 确认组件变更
新组件或未知许可证 阻断 补充来源与审批
已有组件的许可证文本变化 阻断 比较全文差异
锁文件有组件但报告缺失 阻断 修复发现规则

允许列表不应只写宽泛类型。组件可能附带额外 NOTICE、双重许可或单独的资源授权,因此报告要保留所有相关文件。对于二进制包,还要验证当前下载物的校验和与批准记录一致。

把报告接入合并与发布流程

日常合并请求可以执行依赖解析、差异检查和报告生成;发布任务再增加归档内容核对,确认最终产物对应同一份锁文件。推荐把基线放在 compliance/license-baseline.json,CI 只比较结构化字段,不比较终端彩色输出。

门禁失败时,日志应直接列出组件、旧值、新值和证据路径,避免只返回“许可检查失败”。报告目录至少包含锁文件副本、规范化文本、哈希清单、策略版本和差异摘要。ArmCLI 上的并发任务应各自使用独立工作目录,避免一个任务更新依赖后污染另一个任务的扫描结果。

上线前检查项

发布前确认依赖解析没有使用未记录的本地路径;所有直接与传递依赖都有版本来源;未知许可证数量为零;人工组件清单中的目录仍然存在;报告与归档使用相同提交;CI 产物中不包含访问令牌、私有下载地址或未脱敏环境变量。

这套流程的价值不只是生成一页声明,而是把依赖变化变成可审阅的工程事件。只要锁文件、许可证指纹和策略基线保持同步,团队就能在依赖进入发布分支时发现问题,而不是在归档完成后重新追查组件来源。

常见问题

只检查 Package.resolved 是否足够?

不够。Package.resolved 能确认 SwiftPM 依赖版本,但不能证明许可证文本已被发现,也不会覆盖 CocoaPods、仓库内复制的源码或二进制依赖。

哪些变化应该阻断发布构建?

新增依赖没有许可证文件、许可证类型不在允许列表、锁文件与扫描结果不一致,或同一组件的许可证文本发生变化时,都应先阻断并要求人工复核。

ArmCLI 云端 Mac

为下一次构建准备独享物理 Mac

按任务选择机型、机房与租期。节点全年正常运行,实际可用状态以控制台实时返回为准。