Skip to content

Package analyzer fixes #100

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 17 commits into from
Feb 11, 2025
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
59 changes: 43 additions & 16 deletions .github/workflows/detect-api-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading