diff --git a/.github/workflows/detect-api-changes.yml b/.github/workflows/detect-api-changes.yml index 0363128..fffb243 100644 --- a/.github/workflows/detect-api-changes.yml +++ b/.github/workflows/detect-api-changes.yml @@ -31,34 +31,61 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - name: 👾 Define Diff Versions run: | - NEW="${{ env.source }}~${{ env.githubRepo }}" - if [[ '${{ github.head_ref || env.noTargetBranch }}' == release/* ]] + NEW="${{ env.source }}~${{ env.headGithubRepo }}" + OLD="${{ env.target }}~${{ env.baseGithubRepo }}" + + if [[ '${{ env.targetBranchName || env.noTargetBranch }}' == release/* ]] then LATEST_TAG=$(git describe --tags --abbrev=0) - OLD="$LATEST_TAG~${{ env.githubRepo }}" - else - OLD="${{ env.target }}~${{ env.githubRepo }}" + OLD="$LATEST_TAG~${{ env.baseGithubRepo }}" fi - echo "OLD=$OLD" - echo "NEW=$NEW" - # Providing the output to the environment echo "OLD_VERSION=$OLD" >> $GITHUB_ENV echo "NEW_VERSION=$NEW" >> $GITHUB_ENV env: source: '${{ github.event.inputs.new || github.head_ref }}' target: '${{ github.event.inputs.old || github.event.pull_request.base.ref }}' - githubRepo: '${{github.server_url}}/${{github.repository}}.git' + headGithubRepo: '${{github.server_url}}/${{ github.event.pull_request.head.repo.full_name || github.repository}}.git' + baseGithubRepo: '${{github.server_url}}/${{github.repository}}.git' noTargetBranch: 'no target branch' + targetBranchName: '${{ github.head_ref }}' + + - name: 🧰 Build Swift CLI + run: swift build --configuration release + + - name: 🏃 Run Diff + run: | + NEW=${{ env.NEW_VERSION }} + OLD=${{ env.OLD_VERSION }} + PLATFORM="macOS" + PROJECT_FOLDER=${{ github.workspace }} + BINARY_PATH="$(swift build --configuration release --show-bin-path)/public-api-diff" + + echo "▶️ Running binary at $BINARY_PATH" + $BINARY_PATH project --new "$NEW" --old "$OLD" --platform "$PLATFORM" --output "$PROJECT_FOLDER/api_comparison.md" --log-output "$PROJECT_FOLDER/logs.txt" + cat "$PROJECT_FOLDER/logs.txt" + + if [[ ${{ env.HEAD_GITHUB_REPO != env.BASE_GITHUB_REPO }} ]]; then + echo "---" >> $GITHUB_STEP_SUMMARY + echo "> [!IMPORTANT]" >> $GITHUB_STEP_SUMMARY + echo "> **Commenting on pull requests from forks is not possible** due to insufficient permissions." >> $GITHUB_STEP_SUMMARY + echo "> Once merged, the output will be posted as an auto-updating comment under the pull request." >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + fi + + cat "$PROJECT_FOLDER/api_comparison.md" >> $GITHUB_STEP_SUMMARY - - name: 🔍 Detect Changes - uses: ./ # Uses the action.yml that's at the root of the repository - id: public_api_diff + # We only want to comment if we're in a Pull Request and if the Pull Request is not from a forked Repository + # Forked Repositories have different rights for security reasons and thus it's not possible to comment on PRs without lowering the security + # once the tool is merged the base repo rights apply and the script can comment on PRs as expected. + - if: ${{ github.event.pull_request.base.ref != '' && env.HEAD_GITHUB_REPO == env.BASE_GITHUB_REPO }} + name: 📝 Comment on PR + uses: thollander/actions-comment-pull-request@v3 with: - platform: "macOS" - new: ${{ env.NEW_VERSION }} - old: ${{ env.OLD_VERSION }} + file-path: "${{ github.workspace }}/api_comparison.md" + comment-tag: api_changes + mode: recreate diff --git a/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer+Targets.swift b/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer+Targets.swift new file mode 100644 index 0000000..f86583f --- /dev/null +++ b/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer+Targets.swift @@ -0,0 +1,200 @@ +// +// SwiftPackageFileAnalyzer+Targets.swift +// public-api-diff +// +// Created by Alexander Guretzki on 10/02/2025. +// + +import Foundation + +import PADCore +import PADLogging + +import FileHandlingModule +import ShellModule +import SwiftPackageFileHelperModule + +extension SwiftPackageFileAnalyzer { + + internal func analyzeTargets( + old: [SwiftPackageDescription.Target], + new: [SwiftPackageDescription.Target], + oldProjectBasePath: String, + newProjectBasePath: String + ) throws -> [Change] { + guard old != new else { return [] } + + let oldTargetNames = Set(old.map(\.name)) + let newTargetNames = Set(new.map(\.name)) + + let added = newTargetNames.subtracting(oldTargetNames) + let removed = oldTargetNames.subtracting(newTargetNames) + let consistent = Set(oldTargetNames).intersection(Set(newTargetNames)) + + var changes = [Change]() + + changes += added.compactMap { addition in + guard let addedTarget = new.first(where: { $0.name == addition }) else { return nil } + return .init( + changeType: .addition(description: addedTarget.description), + parentPath: Constants.packageFileName(child: "targets") + ) + } + + try consistent.forEach { productName in + guard + let oldTarget = old.first(where: { $0.name == productName }), + let newTarget = new.first(where: { $0.name == productName }) + else { return } + + changes += try analyzeTarget( + oldTarget: oldTarget, + newTarget: newTarget, + oldProjectBasePath: oldProjectBasePath, + newProjectBasePath: newProjectBasePath + ) + } + + changes += removed.compactMap { removal in + guard let removedTarget = old.first(where: { $0.name == removal }) else { return nil } + return .init( + changeType: .removal(description: removedTarget.description), + parentPath: Constants.packageFileName(child: "targets") + ) + } + + return changes + } + + private func analyzeTarget( + oldTarget: SwiftPackageDescription.Target, + newTarget: SwiftPackageDescription.Target, + oldProjectBasePath: String, + newProjectBasePath: String + ) throws -> [Change] { + guard oldTarget != newTarget else { return [] } + + var listOfChanges = analyzeDependencies( + oldTarget: oldTarget, + newTarget: newTarget + ) + + listOfChanges += try analyzeTargetResources( + oldResources: oldTarget.resources ?? [], + newResources: newTarget.resources ?? [], + oldProjectBasePath: oldProjectBasePath, + newProjectBasePath: newProjectBasePath + ) + + if oldTarget.path != newTarget.path { + listOfChanges += ["Changed path from \"\(oldTarget.path)\" to \"\(newTarget.path)\""] + } + + if oldTarget.type != newTarget.type { + listOfChanges += ["Changed type from `.\(oldTarget.type.description)` to `.\(newTarget.type.description)`"] + } + + guard oldTarget.description != newTarget.description || !listOfChanges.isEmpty else { return [] } + + return [.init( + changeType: .modification( + oldDescription: oldTarget.description, + newDescription: newTarget.description + ), + parentPath: Constants.packageFileName(child: "targets"), + listOfChanges: listOfChanges + )] + + } +} + +// MARK: - SwiftPackageDescription.Target.Resource + +private extension SwiftPackageFileAnalyzer { + + func analyzeDependencies( + oldTarget: SwiftPackageDescription.Target, + newTarget: SwiftPackageDescription.Target + ) -> [String] { + + let oldTargetDependencies = Set(oldTarget.targetDependencies ?? []) + let newTargetDependencies = Set(newTarget.targetDependencies ?? []) + + let addedTargetDependencies = newTargetDependencies.subtracting(oldTargetDependencies) + let removedTargetDependencies = oldTargetDependencies.subtracting(newTargetDependencies) + + let oldProductDependencies = Set(oldTarget.productDependencies ?? []) + let newProductDependencies = Set(newTarget.productDependencies ?? []) + + let addedProductDependencies = newProductDependencies.subtracting(oldProductDependencies) + let removedProductDependencies = oldProductDependencies.subtracting(newProductDependencies) + + var listOfChanges = [String]() + listOfChanges += addedTargetDependencies.map { "Added dependency .target(name: \"\($0)\")" } + listOfChanges += addedProductDependencies.map { "Added dependency .product(name: \"\($0)\", ...)" } + listOfChanges += removedTargetDependencies.map { "Removed dependency .target(name: \"\($0)\")" } + listOfChanges += removedProductDependencies.map { "Removed dependency .product(name: \"\($0)\", ...)" } + return listOfChanges + } + + func analyzeTargetResources( + oldResources old: [SwiftPackageDescription.Target.Resource], + newResources new: [SwiftPackageDescription.Target.Resource], + oldProjectBasePath: String, + newProjectBasePath: String + ) throws -> [String] { + + let oldResources = old.map { resource in + var updated = resource + updated.path = updated.path.trimmingPrefix(oldProjectBasePath) + return updated + } + + let newResources = new.map { resource in + var updated = resource + updated.path = updated.path.trimmingPrefix(newProjectBasePath) + return updated + } + + let oldResourcePaths = Set(oldResources.map(\.path)) + let newResourcePaths = Set(newResources.map(\.path)) + + let addedResourcePaths = newResourcePaths.subtracting(oldResourcePaths) + let consistentResourcePaths = oldResourcePaths.intersection(newResourcePaths) + let removedResourcePaths = oldResourcePaths.subtracting(newResourcePaths) + + var listOfChanges = [String]() + + listOfChanges += addedResourcePaths.compactMap { path in + guard let resource = newResources.first(where: { $0.path.trimmingPrefix(newProjectBasePath) == path }) else { return nil } + return "Added resource \(resource.description)" + } + + listOfChanges += consistentResourcePaths.compactMap { path in + guard + let newResource = newResources.first(where: { $0.path == path }), + let oldResource = oldResources.first(where: { $0.path == path }), + newResource.description != oldResource.description + else { return nil } + + return "Changed resource from `\(oldResource.description)` to `\(newResource.description)`" + } + + listOfChanges += removedResourcePaths.compactMap { path in + guard let resource = oldResources.first(where: { $0.path == path }) else { return nil } + return "Removed resource \(resource.description)" + } + + return listOfChanges + } +} + +// MARK: - Convenience Extension + +private extension String { + func trimmingPrefix(_ prefix: String) -> String { + var trimmed = self + trimmed.trimPrefix(prefix) + return trimmed + } +} diff --git a/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer.swift b/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer.swift index e236ec1..07f4e8b 100644 --- a/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer.swift +++ b/Sources/PublicModules/PADPackageFileAnalyzer/SwiftPackageFileAnalyzer.swift @@ -18,9 +18,9 @@ public struct SwiftPackageFileAnalyzer: SwiftPackageFileAnalyzing { private let fileHandler: any FileHandling private let shell: any ShellHandling - private let logger: (any Logging)? + internal let logger: (any Logging)? - private enum Constants { + internal enum Constants { static let packageFileName = "Package.swift" static func packageFileName(child: String) -> String { ".\(child)" @@ -280,142 +280,6 @@ private extension SwiftPackageFileAnalyzer { )] } - // MARK: - Targets - - private func analyzeTargets( - old: [SwiftPackageDescription.Target], - new: [SwiftPackageDescription.Target], - oldProjectBasePath: String, - newProjectBasePath: String - ) throws -> [Change] { - guard old != new else { return [] } - - let oldTargetNames = Set(old.map(\.name)) - let newTargetNames = Set(new.map(\.name)) - - let added = newTargetNames.subtracting(oldTargetNames) - let removed = oldTargetNames.subtracting(newTargetNames) - let consistent = Set(oldTargetNames).intersection(Set(newTargetNames)) - - var changes = [Change]() - - changes += added.compactMap { addition in - guard let addedTarget = new.first(where: { $0.name == addition }) else { return nil } - return .init( - changeType: .addition(description: addedTarget.description), - parentPath: Constants.packageFileName(child: "targets") - ) - } - - try consistent.forEach { productName in - guard - let oldTarget = old.first(where: { $0.name == productName }), - let newTarget = new.first(where: { $0.name == productName }) - else { return } - - changes += try analyzeTarget( - oldTarget: oldTarget, - newTarget: newTarget, - oldProjectBasePath: oldProjectBasePath, - newProjectBasePath: newProjectBasePath - ) - } - - changes += removed.compactMap { removal in - guard let removedTarget = old.first(where: { $0.name == removal }) else { return nil } - return .init( - changeType: .removal(description: removedTarget.description), - parentPath: Constants.packageFileName(child: "targets") - ) - } - - return changes - } - - private func analyzeTarget( - oldTarget: SwiftPackageDescription.Target, - newTarget: SwiftPackageDescription.Target, - oldProjectBasePath: String, - newProjectBasePath: String - ) throws -> [Change] { - guard oldTarget != newTarget else { return [] } - - // MARK: Target Resources - - let oldResourcePaths = Set((oldTarget.resources?.map(\.path) ?? []).map { $0.trimmingPrefix(oldProjectBasePath) }) - let newResourcePaths = Set((newTarget.resources?.map(\.path) ?? []).map { $0.trimmingPrefix(newProjectBasePath) }) - - let addedResourcePaths = newResourcePaths.subtracting(oldResourcePaths) - let consistentResourcePaths = oldResourcePaths.intersection(newResourcePaths) - let removedResourcePaths = oldResourcePaths.subtracting(newResourcePaths) - - // MARK: Target Dependencies - - let oldTargetDependencies = Set(oldTarget.targetDependencies ?? []) - let newTargetDependencies = Set(newTarget.targetDependencies ?? []) - - let addedTargetDependencies = newTargetDependencies.subtracting(oldTargetDependencies) - let removedTargetDependencies = oldTargetDependencies.subtracting(newTargetDependencies) - - // MARK: Product Dependencies - - let oldProductDependencies = Set(oldTarget.productDependencies ?? []) - let newProductDependencies = Set(newTarget.productDependencies ?? []) - - let addedProductDependencies = newProductDependencies.subtracting(oldProductDependencies) - let removedProductDependencies = oldProductDependencies.subtracting(newProductDependencies) - - // MARK: Compiling list of changes - - var listOfChanges = [String]() - - listOfChanges += addedResourcePaths.compactMap { path in - guard let resource = newTarget.resources?.first(where: { $0.path == path }) else { return nil } - return "Added resource \(resource.description)" - } - - listOfChanges += consistentResourcePaths.compactMap { path in - guard - let newResource = newTarget.resources?.first(where: { $0.path == path }), - let oldResource = oldTarget.resources?.first(where: { $0.path == path }) - else { return nil } - - return "Changed resource from `\(oldResource.description)` to `\(newResource.description)`" - } - - listOfChanges += removedResourcePaths.compactMap { path in - guard let resource = oldTarget.resources?.first(where: { $0.path == path }) else { return nil } - return "Removed resource \(resource.description)" - } - - listOfChanges += addedTargetDependencies.map { "Added dependency .target(name: \"\($0)\")" } - listOfChanges += addedProductDependencies.map { "Added dependency .product(name: \"\($0)\", ...)" } - - if oldTarget.path != newTarget.path { - listOfChanges += ["Changed path from \"\(oldTarget.path)\" to \"\(newTarget.path)\""] - } - - if oldTarget.type != newTarget.type { - listOfChanges += ["Changed type from `.\(oldTarget.type.description)` to `.\(newTarget.type.description)`"] - } - - listOfChanges += removedTargetDependencies.map { "Removed dependency .target(name: \"\($0)\")" } - listOfChanges += removedProductDependencies.map { "Removed dependency .product(name: \"\($0)\", ...)" } - - - guard oldTarget.description != newTarget.description || !listOfChanges.isEmpty else { return [] } - - return [.init( - changeType: .modification( - oldDescription: oldTarget.description, - newDescription: newTarget.description - ), - parentPath: Constants.packageFileName(child: "targets"), - listOfChanges: listOfChanges - )] - - } - // MARK: - Dependencies private func analyzeDependencies( @@ -497,11 +361,3 @@ private extension SwiftPackageFileAnalyzer { )] } } - -private extension String { - func trimmingPrefix(_ prefix: String) -> String { - var trimmed = self - trimmed.trimPrefix(prefix) - return trimmed - } -} diff --git a/Sources/PublicModules/PADSwiftInterfaceDiff/SwiftInterfaceParser/SwiftInterfaceParser.swift b/Sources/PublicModules/PADSwiftInterfaceDiff/SwiftInterfaceParser/SwiftInterfaceParser.swift index 828bf67..2bfac2d 100644 --- a/Sources/PublicModules/PADSwiftInterfaceDiff/SwiftInterfaceParser/SwiftInterfaceParser.swift +++ b/Sources/PublicModules/PADSwiftInterfaceDiff/SwiftInterfaceParser/SwiftInterfaceParser.swift @@ -15,10 +15,10 @@ import SwiftSyntax class SwiftInterfaceParser: SyntaxVisitor, SwiftInterfaceParsing { // TODO: Handle (Nice to have) + // - IfConfigClauseListSyntax // - DeinitializerDeclSyntax // - PrecedenceGroupDeclSyntax // - OperatorDeclSyntax - // - IfConfigClauseListSyntax // - ... (There are more but not important right now) private var scope: Scope = .root(elements: []) diff --git a/Sources/Shared/Package/SwiftPackageFileHelperModule/SwiftPackageDescription.swift b/Sources/Shared/Package/SwiftPackageFileHelperModule/SwiftPackageDescription.swift index 8aa5333..52c5db7 100644 --- a/Sources/Shared/Package/SwiftPackageFileHelperModule/SwiftPackageDescription.swift +++ b/Sources/Shared/Package/SwiftPackageFileHelperModule/SwiftPackageDescription.swift @@ -263,7 +263,7 @@ extension SwiftPackageDescription.Target: CustomStringConvertible { package extension SwiftPackageDescription.Target { struct Resource: Codable, Equatable { - package let path: String + package var path: String package let rule: Rule } } diff --git a/action.yml b/action.yml index 96d042b..5d1c5a7 100644 --- a/action.yml +++ b/action.yml @@ -68,13 +68,13 @@ runs: $BINARY_PATH project --new "$NEW" --old "$OLD" --platform "$PLATFORM" --output "$PROJECT_FOLDER/api_comparison.md" --log-output "$PROJECT_FOLDER/logs.txt" cat "$PROJECT_FOLDER/logs.txt" - # if [[ ${{ env.HEAD_GITHUB_REPO != env.BASE_GITHUB_REPO }} ]]; then + if [[ ${{ env.HEAD_GITHUB_REPO != env.BASE_GITHUB_REPO }} ]]; then echo "---" >> $GITHUB_STEP_SUMMARY echo "> [!IMPORTANT]" >> $GITHUB_STEP_SUMMARY echo "> **Commenting on pull requests from forks is not possible** due to insufficient permissions." >> $GITHUB_STEP_SUMMARY echo "> Once merged, the output will be posted as an auto-updating comment under the pull request." >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY - # fi + fi cat "$PROJECT_FOLDER/api_comparison.md" >> $GITHUB_STEP_SUMMARY shell: bash