Skip to content

Commit 6e119b1

Browse files
committed
Support cross-PR testing for Swift packages
This allows Swift Packages to be tested in combination with PRs updating their dependencies. To reference a linked PR, `Linked PR: <link to PR>` needs to be added to the PR description, eg: ``` Linked PR: swiftlang/swift-syntax#2859 ```
1 parent 6110e2a commit 6e119b1

File tree

4 files changed

+288
-1
lines changed

4 files changed

+288
-1
lines changed

.github/workflows/pull_request.yml

-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,3 @@ jobs:
1111
with:
1212
api_breakage_check_enabled: false
1313
license_header_check_project_name: "Swift.org"
14-
format_check_enabled: false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
15+
#if canImport(FoundationNetworking)
16+
// FoundationNetworking is a separate module in swift-foundation but not swift-corelibs-foundation.
17+
import FoundationNetworking
18+
#endif
19+
20+
#if canImport(WinSDK)
21+
import WinSDK
22+
#endif
23+
24+
struct GenericError: Error, CustomStringConvertible {
25+
var description: String
26+
27+
init(_ description: String) {
28+
self.description = description
29+
}
30+
}
31+
32+
/// Escape the given command to be printed for log output.
33+
func escapeCommand(_ executable: URL, _ arguments: [String]) -> String {
34+
return ([executable.path] + arguments).map {
35+
if $0.contains(" ") {
36+
return "'\($0)'"
37+
}
38+
return $0
39+
}.joined(separator: " ")
40+
}
41+
42+
/// Launch a subprocess with the given command and wait for it to finish
43+
func run(_ executable: URL, _ arguments: String..., workingDirectory: URL? = nil) throws {
44+
print("Running \(escapeCommand(executable, arguments)) (working directory: \(workingDirectory?.path ?? "<nil>"))")
45+
let process = Process()
46+
process.executableURL = executable
47+
process.arguments = arguments
48+
if let workingDirectory {
49+
process.currentDirectoryURL = workingDirectory
50+
}
51+
52+
try process.run()
53+
process.waitUntilExit()
54+
guard process.terminationStatus == 0 else {
55+
throw GenericError(
56+
"\(escapeCommand(executable, arguments)) failed with non-zero exit code: \(process.terminationStatus)"
57+
)
58+
}
59+
}
60+
61+
/// Find the executable with the given name in PATH.
62+
public func lookup(executable: String) throws -> URL {
63+
#if os(Windows)
64+
let pathSeparator: Character = ";"
65+
let executable = executable + ".exe"
66+
#else
67+
let pathSeparator: Character = ":"
68+
#endif
69+
for pathVariable in ["PATH", "Path"] {
70+
guard let pathString = ProcessInfo.processInfo.environment[pathVariable] else {
71+
continue
72+
}
73+
for searchPath in pathString.split(separator: pathSeparator) {
74+
let candidateUrl = URL(fileURLWithPath: String(searchPath)).appendingPathComponent(executable)
75+
if FileManager.default.isExecutableFile(atPath: candidateUrl.path) {
76+
return candidateUrl
77+
}
78+
}
79+
}
80+
throw GenericError("Did not find \(executable)")
81+
}
82+
83+
func downloadData(from url: URL) async throws -> Data {
84+
return try await withCheckedThrowingContinuation { continuation in
85+
URLSession.shared.dataTask(with: url) { data, _, error in
86+
if let error {
87+
continuation.resume(throwing: error)
88+
return
89+
}
90+
guard let data else {
91+
continuation.resume(throwing: GenericError("Received no data for \(url)"))
92+
return
93+
}
94+
continuation.resume(returning: data)
95+
}
96+
.resume()
97+
}
98+
}
99+
100+
/// The JSON fields of the `https://api.github.com/repos/<repository>/pulls/<prNumber>` endpoint that we care about.
101+
struct PRInfo: Codable {
102+
struct Base: Codable {
103+
/// The name of the PR's base branch.
104+
let ref: String
105+
}
106+
/// The base branch of the PR
107+
let base: Base
108+
109+
/// The PR's description.
110+
let body: String?
111+
}
112+
113+
/// - Parameters:
114+
/// - repository: The repository's name, eg. `swiftlang/swift-syntax`
115+
func getPRInfo(repository: String, prNumber: String) async throws -> PRInfo {
116+
guard let prInfoUrl = URL(string: "https://api.github.com/repos/\(repository)/pulls/\(prNumber)") else {
117+
throw GenericError("Failed to form URL for GitHub API")
118+
}
119+
120+
do {
121+
let data = try await downloadData(from: prInfoUrl)
122+
return try JSONDecoder().decode(PRInfo.self, from: data)
123+
} catch {
124+
throw GenericError("Failed to load PR info from \(prInfoUrl): \(error)")
125+
}
126+
}
127+
128+
/// Information about a PR that should be tested with this PR.
129+
struct CrossRepoPR {
130+
/// The owner of the repository, eg. `swiftlang`
131+
let repositoryOwner: String
132+
133+
/// The name of the repository, eg. `swift-syntax`
134+
let repositoryName: String
135+
136+
/// The PR number that's referenced.
137+
let prNumber: String
138+
}
139+
140+
/// Retrieve all PRs that are referenced from PR `prNumber` in `repository`.
141+
/// `repository` is the owner and repo name joined by `/`, eg. `swiftlang/swift-syntax`.
142+
func getCrossRepoPrs(repository: String, prNumber: String) async throws -> [CrossRepoPR] {
143+
var result: [CrossRepoPR] = []
144+
let prInfo = try await getPRInfo(repository: repository, prNumber: prNumber)
145+
for line in prInfo.body?.split(separator: "\n") ?? [] {
146+
guard line.lowercased().starts(with: "linked pr:") else {
147+
continue
148+
}
149+
// We can't use Swift's Regex here because this script needs to run on Windows with Swift 5.9, which doesn't support
150+
// Swift Regex.
151+
var remainder = line[...]
152+
guard let ownerRange = remainder.firstRange(of: "swiftlang/") ?? remainder.firstRange(of: "apple/") else {
153+
continue
154+
}
155+
let repositoryOwner = remainder[ownerRange].dropLast()
156+
remainder = remainder[ownerRange.upperBound...]
157+
let repositoryName = remainder.prefix { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
158+
if repositoryName.isEmpty {
159+
continue
160+
}
161+
remainder = remainder.dropFirst(repositoryName.count)
162+
if remainder.starts(with: "/pull/") {
163+
remainder = remainder.dropFirst(6)
164+
} else if remainder.starts(with: "#") {
165+
remainder = remainder.dropFirst()
166+
} else {
167+
continue
168+
}
169+
let pullRequestNum = remainder.prefix { $0.isNumber }
170+
if pullRequestNum.isEmpty {
171+
continue
172+
}
173+
result.append(
174+
CrossRepoPR(
175+
repositoryOwner: String(repositoryOwner),
176+
repositoryName: String(repositoryName),
177+
prNumber: String(pullRequestNum)
178+
)
179+
)
180+
}
181+
return result
182+
}
183+
184+
func main() async throws {
185+
guard ProcessInfo.processInfo.arguments.count >= 3 else {
186+
throw GenericError(
187+
"""
188+
Expected two arguments:
189+
- Repository name, eg. `swiftlang/swift-syntax
190+
- PR number
191+
"""
192+
)
193+
}
194+
let repository = ProcessInfo.processInfo.arguments[1]
195+
let prNumber = ProcessInfo.processInfo.arguments[2]
196+
197+
let crossRepoPrs = try await getCrossRepoPrs(repository: repository, prNumber: prNumber)
198+
if !crossRepoPrs.isEmpty {
199+
print("Detected cross-repo PRs")
200+
for crossRepoPr in crossRepoPrs {
201+
print(" - \(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)#\(crossRepoPr.prNumber)")
202+
}
203+
}
204+
205+
for crossRepoPr in crossRepoPrs {
206+
let git = try lookup(executable: "git")
207+
let swift = try lookup(executable: "swift")
208+
let baseBranch = try await getPRInfo(
209+
repository: "\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)",
210+
prNumber: crossRepoPr.prNumber
211+
).base.ref
212+
213+
let workspaceDir = URL(fileURLWithPath: "..").resolvingSymlinksInPath()
214+
let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName)
215+
try run(
216+
git,
217+
"clone",
218+
"https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",
219+
"\(crossRepoPr.repositoryName)",
220+
workingDirectory: workspaceDir
221+
)
222+
try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir)
223+
try run(git, "checkout", baseBranch, workingDirectory: repoDir)
224+
try run(git, "reset", "--hard", "pr_merge", workingDirectory: repoDir)
225+
try run(
226+
swift,
227+
"package",
228+
"config",
229+
"set-mirror",
230+
"--package-url",
231+
"https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",
232+
"--mirror-url",
233+
repoDir.path
234+
)
235+
}
236+
}
237+
238+
do {
239+
try await main()
240+
} catch {
241+
print(error)
242+
#if os(Windows)
243+
_Exit(1)
244+
#else
245+
exit(1)
246+
#endif
247+
}

.github/workflows/swift_package_test.yml

+22
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ on:
4949
type: boolean
5050
description: "Boolean to enable windows testing. Defaults to true"
5151
default: true
52+
enable_cross_pr_testing:
53+
type: boolean
54+
description: "Whether PRs can be tested in combination with other PRs by mentioning them as `Linked PR: <link to PR>` in the PR description"
55+
default: false
5256

5357
jobs:
5458
linux-build:
@@ -68,6 +72,12 @@ jobs:
6872
run: swift --version
6973
- name: Checkout repository
7074
uses: actions/checkout@v4
75+
- name: Check out related PRs
76+
if: ${{ inputs.enable_windows_checks && github.event_name == 'pull_request' }}
77+
run: |
78+
apt-get update && apt-get install -y curl
79+
curl -s https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/cross-pr-checkout.swift > /tmp/cross-pr-checkout.swift
80+
swift /tmp/cross-pr-checkout.swift "${{ github.repository }}" "${{ github.event.number }}"
7181
- name: Set environment variables
7282
if: ${{ inputs.linux_env_vars }}
7383
run: |
@@ -120,6 +130,18 @@ jobs:
120130
Invoke-Program swift --version
121131
Invoke-Program swift test --version
122132
Invoke-Program cd C:\source\
133+
'@ >> $env:TEMP\test-script\run.ps1
134+
135+
if ("${{ inputs.enable_windows_checks && github.event_name == 'pull_request' }}" -eq "true") {
136+
echo @'
137+
Invoke-WebRequest https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/cross-pr-checkout.swift -OutFile $env:TEMP\cross-pr-checkout.swift
138+
# Running in script mode fails on Windows (https://github.com/swiftlang/swift/issues/77263), compile and run the script.
139+
Invoke-Program swiftc -sdk $env:SDKROOT $env:TEMP\cross-pr-checkout.swift -o $env:TEMP\cross-pr-checkout.exe
140+
Invoke-Program $env:TEMP\cross-pr-checkout.exe "${{ github.repository }}" "${{ github.event.number }}"
141+
'@ >> $env:TEMP\test-script\run.ps1
142+
}
143+
144+
echo @'
123145
${{ inputs.windows_pre_build_command }}
124146
Invoke-Program ${{ inputs.windows_build_command }} ${{ (contains(matrix.swift_version, 'nightly') && inputs.swift_nightly_flags) || inputs.swift_flags }}
125147
'@ >> $env:TEMP\test-script\run.ps1

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,25 @@ pre_build_command: "apt-get update -y -q && apt-get install -y -q example"
7373

7474
macOS and Windows platform support will be available soon.
7575

76+
#### Cross-PR testing
77+
78+
To support testing of PRs together with PRs for one of the package’s dependencies, set add the following to your PR job.
79+
80+
```yaml
81+
with:
82+
enable_cross_pr_testing: true
83+
```
84+
85+
To reference a linked PR, add `Linked PR: <link to PR>` to the PR description, eg.
86+
87+
```
88+
Linked PR: https://github.com/swiftlang/swift-syntax/pull/2859
89+
// or alternatively
90+
Linked PR: swiftlang/swift-syntax#2859
91+
```
92+
93+
Enabling cross-PR testing will add about 10s to PR testing time.
94+
7695
## Running workflows locally
7796

7897
You can run the Github Actions workflows locally using

0 commit comments

Comments
 (0)