Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ jobs:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Validate route contracts
- name: Validate route contracts on pull requests
if: github.event_name == 'pull_request'
run: |
git show "${{ github.event.pull_request.base.sha }}:RouteContracts.json" > /tmp/RouteContracts.base.json
swift Scripts/validate_route_contract.swift RouteContracts.json --baseline /tmp/RouteContracts.base.json
- name: Validate route contracts on master
if: github.event_name != 'pull_request'
run: swift Scripts/validate_route_contract.swift RouteContracts.json
- name: Check public API compatibility
if: github.event_name == 'pull_request'
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes are documented here. This project follows [Semantic Versioni

## [Unreleased]

## [2.4.4] - 2026-07-18

### Added

- PR CI now compares route contracts with the exact base commit and rejects removed routes, path changes, removed presentation styles, required parameters, or supported contract versions.

## [2.4.3] - 2026-07-18

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ In plain terms, route contract CI treats a URL as an interface agreement between

To use it, update four things in the same PR whenever a public link changes: the Feature URL parser, `RouteContracts.json`, the README/caller examples, and any required migration notes. CI catches structural catalog mistakes; whether an old URL can be removed and how old clients migrate must still be explicit in PR review and release notes. That distinction prevents “automatic validation” from being mistaken for “automatic compatibility.”

In addition, PR CI compares the catalog with the exact base commit. It rejects a removed route, changed path template, removed presentation style, removed required parameter, or removed supported contract version. This makes a public-link breaking change visible before merge; make such a change only in a major release with a migration plan.

### Public API compatibility CI

In plain terms, this is the same safety net for Swift code that route-contract CI is for URLs. An app may import `URLRouter` or `URLRouterPolicyProvider` and call a public type or method for years. Removing or changing that API without a major-version migration breaks the app at its next dependency update.
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ let observability = ModuleRouteObservability(observers: [AppRouteObserver()])

怎么用:新增或修改公开链接时,按同一个 PR 同步修改四处:Feature 的 URL 解析器、`RouteContracts.json`、README/调用方示例、以及必要的迁移说明。CI 擅长阻止目录本身的结构错误;是否可以删除旧链接、旧版本如何迁移,则仍应在 PR 审查和发布说明中明确,这是为了避免把“自动检查”误当成“自动兼容”。

此外,PR CI 会把当前目录与该 PR 的精确基线提交比较。它会拒绝删除路由、修改路径模板、移除展示方式、移除必填参数或移除支持的协议版本。这样公开链接的破坏性修改会在合并前暴露;这类修改只能在主版本发布时进行,并配套迁移方案。

### 公开 API 兼容性 CI

先用人话说:这就是给 Swift 代码准备的“接口不被偷偷改坏”检查,作用和路由契约 CI 对 URL 的保护一样。App 可能长期导入 `URLRouter` 或 `URLRouterPolicyProvider`,并调用某个公开类型或方法;如果库在小版本升级中删除或改掉它,App 下次升级依赖时就会编译失败。
Expand Down
40 changes: 39 additions & 1 deletion Scripts/validate_route_contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ struct RouteContract: Decodable {
}

let validPresentations: Set<String> = ["push", "tab", "sheet", "fullScreenCover"]
let manifestPath = CommandLine.arguments.dropFirst().first ?? "RouteContracts.json"
let arguments = Array(CommandLine.arguments.dropFirst())
let manifestPath = arguments.first ?? "RouteContracts.json"
let baselinePath: String? = {
guard let flagIndex = arguments.firstIndex(of: "--baseline") else { return nil }
let valueIndex = arguments.index(after: flagIndex)
return valueIndex < arguments.endIndex ? arguments[valueIndex] : nil
}()

do {
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
Expand Down Expand Up @@ -59,6 +65,38 @@ do {
}
}

if let baselinePath {
let baselineData = try Data(contentsOf: URL(fileURLWithPath: baselinePath))
let baseline = try JSONDecoder().decode(RouteContractManifest.self, from: baselineData)
let currentRoutes = Dictionary(uniqueKeysWithValues: manifest.routes.map {
("\($0.moduleID)/\($0.routeID)", $0)
})

for baselineRoute in baseline.routes {
let key = "\(baselineRoute.moduleID)/\(baselineRoute.routeID)"
guard let currentRoute = currentRoutes[key] else {
failures.append("Breaking route contract: removed \(key).")
continue
}
guard baselineRoute.pathTemplate == currentRoute.pathTemplate else {
failures.append("Breaking route contract: changed pathTemplate for \(key).")
continue
}
guard Set(baselineRoute.presentations).isSubset(of: Set(currentRoute.presentations)) else {
failures.append("Breaking route contract: removed presentation for \(key).")
continue
}
if !Set(baselineRoute.requiredQueryItems).isSubset(of: Set(currentRoute.requiredQueryItems)) {
failures.append("Breaking route contract: removed required query item for \(key).")
}
}

let removedVersions = Set(baseline.supportedVersions).subtracting(manifest.supportedVersions)
for version in removedVersions.sorted() {
failures.append("Breaking route contract: removed supported version \(version).")
}
}

guard failures.isEmpty else {
throw NSError(domain: "RouteContract", code: 1, userInfo: [
NSLocalizedDescriptionKey: failures.joined(separator: "\n")
Expand Down