diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc864a..acb6648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index d015b69..47b985c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9de2d13..e282afc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 627384e..0f41a48 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 下次升级依赖时就会编译失败。 diff --git a/Scripts/validate_route_contract.swift b/Scripts/validate_route_contract.swift index 617dcad..954666f 100644 --- a/Scripts/validate_route_contract.swift +++ b/Scripts/validate_route_contract.swift @@ -15,7 +15,13 @@ struct RouteContract: Decodable { } let validPresentations: Set = ["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)) @@ -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")