Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Detect public api changes #1708

Merged
merged 36 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
752515e
Adding scripts to detect api changes
goergisn Jun 6, 2024
5787fb8
changing github action
goergisn Jun 6, 2024
b3b3765
adjusted script
goergisn Jun 6, 2024
ffebcc2
More changes
goergisn Jun 6, 2024
3cadff7
fixing script
goergisn Jun 6, 2024
d2cf211
small sanitation
goergisn Jun 6, 2024
a8598f1
Remove diffing
goergisn Jun 6, 2024
f4f7754
adding module name
goergisn Jun 7, 2024
dcdc099
Try comment phase
goergisn Jun 7, 2024
3d2cef1
Playing around
goergisn Jun 7, 2024
9a0d19c
Playing around
goergisn Jun 7, 2024
2fe25be
more playing around
goergisn Jun 7, 2024
8944308
Fixing commenting
goergisn Jun 7, 2024
0268bfd
Nicer formatting
goergisn Jun 7, 2024
65f026b
sorting changes by changeDescription
goergisn Jun 7, 2024
b497b0e
removing unused code
goergisn Jun 7, 2024
e187390
Recreating comment to make sure it's one of the last
goergisn Jun 7, 2024
2f2ff62
trying out loops
goergisn Jun 7, 2024
8a321d4
adding complete check
goergisn Jun 7, 2024
9b3659c
formatting
goergisn Jun 10, 2024
43c0de6
Fixed formatting
goergisn Jun 10, 2024
ca20039
fixing git diff
goergisn Jun 10, 2024
09b6eac
trying something different
goergisn Jun 10, 2024
a006d7d
trying something else
goergisn Jun 10, 2024
e2f76d0
maybe this time
goergisn Jun 10, 2024
4b620ba
whatever
goergisn Jun 10, 2024
654e5b9
maybe this works
goergisn Jun 10, 2024
2deb186
Improving comparison
goergisn Jun 10, 2024
6f16208
Updating workflow
goergisn Jun 10, 2024
b1f847c
Removed testing code
goergisn Jun 10, 2024
1606cfa
Trigger action
goergisn Jun 12, 2024
da662b1
fixing
goergisn Jun 12, 2024
308aeea
Removing spi internal additions/removals from log
goergisn Jun 12, 2024
c62c607
extracting find function to Element extension
goergisn Jun 13, 2024
9fd987f
Adding documentation
goergisn Jun 13, 2024
afe29a7
Remove scripts folder from spell check
goergisn Jun 13, 2024
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
39 changes: 39 additions & 0 deletions .github/workflows/detect_api_changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Detect public API changes

on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
push:
branches:
- develop

jobs:

build:
runs-on: macos-14-xlarge # Apple Silicon Runner

steps:
- uses: actions/checkout@v4
- uses: n1hility/cancel-previous-runs@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Select latest Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.1'

- name: 🔍 Detect Changes
run: |
Scripts/generate_public_interface_definition.sh ${branch} ${baseRepo}
env:
branch: '${{github.event.pull_request.base.ref}}'
baseRepo: '${{github.server_url}}/${{github.repository}}.git'

- name: 📝 Comment on PR
uses: thollander/actions-comment-pull-request@v2
with:
filePath: "${{ github.workspace }}/api_comparison.md"
comment_tag: api_changes
mode: recreate
270 changes: 270 additions & 0 deletions Scripts/compare_public_interface_definition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
#!/usr/bin/env xcrun swift

//
// Copyright (c) 2024 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//

import Foundation

let currentDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

let old = CommandLine.arguments[1]
let new = CommandLine.arguments[2]
let moduleName = CommandLine.arguments[3]

struct Conformance: Codable, Equatable {
var printedName: String

enum CodingKeys: String, CodingKey {
case printedName
}
}

class Element: Codable, Equatable, CustomDebugStringConvertible {
let kind: String
let name: String
let mangledName: String?
let printedName: String
let declKind: String?

let children: [Element]?
let spiGroupNames: [String]?
let declAttributes: [String]?
let conformances: [Conformance]?

var parent: Element?

enum CodingKeys: String, CodingKey {
case kind
case name
case printedName
case mangledName
case children
case spiGroupNames = "spi_group_names"
case declKind
case declAttributes
case conformances
}

var debugDescription: String {
var definition = ""
spiGroupNames?.forEach {
definition += "@_spi(\($0)) "
}
definition += "public "

if declAttributes?.contains("Final") == true {
definition += "final "
}

if let declKind {
if declKind == "Constructor" {
definition += "func "
} else {
definition += "\(declKind.lowercased()) "
}
}

definition += "\(printedName)"

if let conformanceNames = conformances?.map({ $0.printedName }), !conformanceNames.isEmpty {
definition += " : \(conformanceNames.joined(separator: ", "))"
}

return definition
}

var isSpiInternal: Bool {
!(spiGroupNames ?? []).isEmpty
}

public static func == (lhs: Element, rhs: Element) -> Bool {
lhs.mangledName == rhs.mangledName
&& lhs.children == rhs.children
&& lhs.spiGroupNames == rhs.spiGroupNames
}

var parentPath: String {
var parent = self.parent
var path = [parent?.name]
while parent != nil {
parent = parent?.parent
path += [parent?.name]
}

var sanitizedPath = path.compactMap { $0 }

if sanitizedPath.last == "TopLevel" {
sanitizedPath.removeLast()
}

return sanitizedPath.reversed().joined(separator: ".")
}
}

extension [Element] {
func firstElementMatchingName(of otherElement: Element) -> Element? {
first(where: { ($0.mangledName ?? $0.name) == (otherElement.mangledName ?? otherElement.name) })
}
}

class Definition: Codable, Equatable {
let root: Element

enum CodingKeys: String, CodingKey {
case root = "ABIRoot"
}

public static func == (lhs: Definition, rhs: Definition) -> Bool {
lhs.root == rhs.root
}
}

struct Change {
enum ChangeType {
case addition
case removal
case change

var icon: String {
switch self {
case .addition:
return "❇️ "
case .removal:
return "😶‍🌫️"
case .change:
return "🔀"
}
}
}

var changeType: ChangeType
var parentName: String
var changeDescription: String
}

func recursiveCompare(element lhs: Element, to rhs: Element, oldFirst: Bool) -> [Change] {
if lhs == rhs {
return []
}

if lhs.isSpiInternal && rhs.isSpiInternal {
// If both elements are spi internal we can ignore them as they are not in the public interface
return []
}

var changes = [Change]()

// TODO: Add check if accessor changed (e.g. changed from get/set to get only...)

if oldFirst, (
lhs.printedName != rhs.printedName ||
lhs.spiGroupNames != rhs.spiGroupNames ||
lhs.conformances != rhs.conformances ||
lhs.declAttributes != rhs.declAttributes
) {
// TODO: Show what exactly changed (name, spi, conformance, declAttributes, ...) as a bullet list maybe (add a `changeList` property to `Change`)
changes += [.init(changeType: .change, parentName: lhs.parentPath, changeDescription: "`\(lhs)` ➡️ `\(rhs)`")]
}

if lhs.children == rhs.children {
return changes
}

changes += lhs.children?.flatMap { lhsElement in
if let rhsChildForName = rhs.children?.firstElementMatchingName(of: lhsElement) {
return recursiveCompare(element: lhsElement, to: rhsChildForName, oldFirst: oldFirst)
} else {
if lhsElement.isSpiInternal { return [] }

if oldFirst {
return [.init(changeType: .removal, parentName: lhsElement.parentPath, changeDescription: "`\(lhsElement)` was removed")]
} else {
return [.init(changeType: .addition, parentName: lhsElement.parentPath, changeDescription: "`\(lhsElement)` was added")]
}
}
} ?? []

return changes
}

func setupRelationships(for element: Element, parent: Element?) {
element.children?.forEach {
$0.parent = element
setupRelationships(for: $0, parent: element)
}
}

func compare() throws {
let oldFileUrl = currentDirectory.appending(path: old)
let newFileUrl = currentDirectory.appending(path: new)

let decodedOldDefinition = try JSONDecoder().decode(
Definition.self,
from: Data(contentsOf: oldFileUrl)
)

let decodedNewDefinition = try JSONDecoder().decode(
Definition.self,
from: Data(contentsOf: newFileUrl)
)

if decodedOldDefinition == decodedNewDefinition {
try persistComparison(fileContent: "## 🫧 `\(moduleName)`\n- No changes detected")
return
}

setupRelationships(for: decodedOldDefinition.root, parent: nil)
setupRelationships(for: decodedNewDefinition.root, parent: nil)

let changes = recursiveCompare(
element: decodedOldDefinition.root,
to: decodedNewDefinition.root,
oldFirst: true
) + recursiveCompare(
element: decodedNewDefinition.root,
to: decodedOldDefinition.root,
oldFirst: false
)

var groupedChanges = [String: [Change]]()

changes.forEach {
groupedChanges[$0.parentName] = (groupedChanges[$0.parentName] ?? []) + [$0]
}

var fileContent = ["## 👀 `\(moduleName)`\n"]

groupedChanges.keys.sorted().forEach { key in
fileContent += ["### \(key)"]
groupedChanges[key]?.sorted(by: { $0.changeDescription < $1.changeDescription }).forEach {
fileContent += ["- \($0.changeType.icon) \($0.changeDescription)"]
}
}

try persistComparison(fileContent: fileContent.joined(separator: "\n"))
}

func persistComparison(fileContent: String) throws {
print(fileContent)
let outputPath = currentDirectory.appendingPathComponent("api_comparison.md")

if FileManager.default.fileExists(atPath: outputPath.path) {
guard let data = (fileContent + "\n\n").data(using: String.Encoding.utf8) else { return }
let fileHandle = try FileHandle(forWritingTo: outputPath)
fileHandle.seekToEndOfFile()
fileHandle.write(data)
fileHandle.closeFile()
} else {
guard let data = ("# Public API Changes\n" + fileContent + "\n\n").data(using: String.Encoding.utf8) else { return }
try data.write(to: outputPath, options: .atomicWrite)
}
}

do {
try compare()
} catch {
print(error)
}
78 changes: 78 additions & 0 deletions Scripts/generate_public_interface_definition.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/bash

BRANCH=$1
REPO=$2
TARGET="x86_64-apple-ios17.4-simulator"
SDK="`xcrun --sdk iphonesimulator --show-sdk-path`"
MODULE_NAMES=("Adyen" "AdyenDropIn" "AdyenActions" "AdyenCard" "AdyenEncryption" "AdyenComponents" "AdyenSession" "AdyenWeChatPay" "AdyenCashAppPay" "AdyenTwint" "AdyenDelegatedAuthentication")
COMPARISON_VERSION_DIR_NAME="comparison_version_$BRANCH"
DERIVED_DATA_PATH=".build"
SDK_DUMP_INPUT_PATH="$DERIVED_DATA_PATH/Build/Products/Debug-iphonesimulator"

echo "Branch: " $BRANCH
echo "Repo: " $REPO
echo "Target: " $TARGET
echo "Dir: " $COMPARISON_VERSION_DIR_NAME

echo "Modules:"
for MODULE in ${MODULE_NAMES[@]}
do
echo "-" $MODULE
done

rm -rf .build
rm -rf comparison_version

function cleanup() {
rm -rf .build
rm -rf $COMPARISON_VERSION_DIR_NAME
mv Adyen.xcode_proj Adyen.xcodeproj
}

function setupComparisonRepo() {
rm -rf $COMPARISON_VERSION_DIR_NAME
mkdir $COMPARISON_VERSION_DIR_NAME
cd $COMPARISON_VERSION_DIR_NAME
git clone -b $BRANCH $REPO

cd adyen-ios
mv Adyen.xcodeproj Adyen.xcode_proj # We have to obscure the project file so `xcodebuild` uses the Package.swift to build the module
}

trap cleanup EXIT

# TODO: Compile a list of changed modules from the git diff
# TODO: Generate a Package.swift library that contains all targets/modules so we don't have to do multiple slow `xcodebuild`

mv Adyen.xcodeproj Adyen.xcode_proj

echo "↘️ Checking out comparison version"
setupComparisonRepo # We're now in the comparison repository directory

echo "👷 Building & Diffing"
for MODULE in ${MODULE_NAMES[@]}
do

echo "🛠️ [$MODULE] Building comparison project"
xcodebuild -derivedDataPath $DERIVED_DATA_PATH -sdk $SDK -scheme $MODULE -destination "platform=iOS,name=Any iOS Device" -target $TARGET -quiet

echo "📋 [$MODULE] Generating comparison api_dump"
xcrun swift-api-digester -dump-sdk -module $MODULE -o ../../api_dump_comparison.json -I $SDK_DUMP_INPUT_PATH -sdk $SDK -target $TARGET

cd ../..

echo "🛠️ [$MODULE] Building updated project"
xcodebuild -derivedDataPath $DERIVED_DATA_PATH -sdk $SDK -scheme $MODULE -destination "platform=iOS,name=Any iOS Device" -target $TARGET -quiet

echo "📋 [$MODULE] Generating new api_dump"
xcrun swift-api-digester -dump-sdk -module $MODULE -o api_dump.json -I $SDK_DUMP_INPUT_PATH -sdk $SDK -target $TARGET

echo "🕵️ [$MODULE] Diffing"
./Scripts/compare_public_interface_definition.swift api_dump_comparison.json api_dump.json $MODULE

# Reset and move into comparison dir again for the next iteration
rm api_dump.json
rm api_dump_comparison.json
cd $COMPARISON_VERSION_DIR_NAME/adyen-ios

done
1 change: 1 addition & 0 deletions spell-check-excluded-files-list
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Demo/Common/IntegrationExamples/DemoAddressLookupProvider.swift
Demo/Common/IntegrationExamples/MapkitAddressLookupProvider.swift
Tests/*
UITests/*
Scripts/*
Loading