diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ea40b32..9edab19 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -43,4 +43,3 @@ jobs: with: api_breakage_check_enabled: false license_header_check_project_name: "Swift.org" - format_check_enabled: false diff --git a/.github/workflows/scripts/cross-pr-checkout.swift b/.github/workflows/scripts/cross-pr-checkout.swift new file mode 100644 index 0000000..7a4550a --- /dev/null +++ b/.github/workflows/scripts/cross-pr-checkout.swift @@ -0,0 +1,247 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +#if canImport(FoundationNetworking) +// FoundationNetworking is a separate module in swift-foundation but not swift-corelibs-foundation. +import FoundationNetworking +#endif + +#if canImport(WinSDK) +import WinSDK +#endif + +struct GenericError: Error, CustomStringConvertible { + var description: String + + init(_ description: String) { + self.description = description + } +} + +/// Escape the given command to be printed for log output. +func escapeCommand(_ executable: URL, _ arguments: [String]) -> String { + return ([executable.path] + arguments).map { + if $0.contains(" ") { + return "'\($0)'" + } + return $0 + }.joined(separator: " ") +} + +/// Launch a subprocess with the given command and wait for it to finish +func run(_ executable: URL, _ arguments: String..., workingDirectory: URL? = nil) throws { + print("Running \(escapeCommand(executable, arguments)) (working directory: \(workingDirectory?.path ?? ""))") + let process = Process() + process.executableURL = executable + process.arguments = arguments + if let workingDirectory { + process.currentDirectoryURL = workingDirectory + } + + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw GenericError( + "\(escapeCommand(executable, arguments)) failed with non-zero exit code: \(process.terminationStatus)" + ) + } +} + +/// Find the executable with the given name in PATH. +public func lookup(executable: String) throws -> URL { + #if os(Windows) + let pathSeparator: Character = ";" + let executable = executable + ".exe" + #else + let pathSeparator: Character = ":" + #endif + for pathVariable in ["PATH", "Path"] { + guard let pathString = ProcessInfo.processInfo.environment[pathVariable] else { + continue + } + for searchPath in pathString.split(separator: pathSeparator) { + let candidateUrl = URL(fileURLWithPath: String(searchPath)).appendingPathComponent(executable) + if FileManager.default.isExecutableFile(atPath: candidateUrl.path) { + return candidateUrl + } + } + } + throw GenericError("Did not find \(executable)") +} + +func downloadData(from url: URL) async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + URLSession.shared.dataTask(with: url) { data, _, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let data else { + continuation.resume(throwing: GenericError("Received no data for \(url)")) + return + } + continuation.resume(returning: data) + } + .resume() + } +} + +/// The JSON fields of the `https://api.github.com/repos//pulls/` endpoint that we care about. +struct PRInfo: Codable { + struct Base: Codable { + /// The name of the PR's base branch. + let ref: String + } + /// The base branch of the PR + let base: Base + + /// The PR's description. + let body: String? +} + +/// - Parameters: +/// - repository: The repository's name, eg. `swiftlang/swift-syntax` +func getPRInfo(repository: String, prNumber: String) async throws -> PRInfo { + guard let prInfoUrl = URL(string: "https://api.github.com/repos/\(repository)/pulls/\(prNumber)") else { + throw GenericError("Failed to form URL for GitHub API") + } + + do { + let data = try await downloadData(from: prInfoUrl) + return try JSONDecoder().decode(PRInfo.self, from: data) + } catch { + throw GenericError("Failed to load PR info from \(prInfoUrl): \(error)") + } +} + +/// Information about a PR that should be tested with this PR. +struct CrossRepoPR { + /// The owner of the repository, eg. `swiftlang` + let repositoryOwner: String + + /// The name of the repository, eg. `swift-syntax` + let repositoryName: String + + /// The PR number that's referenced. + let prNumber: String +} + +/// Retrieve all PRs that are referenced from PR `prNumber` in `repository`. +/// `repository` is the owner and repo name joined by `/`, eg. `swiftlang/swift-syntax`. +func getCrossRepoPrs(repository: String, prNumber: String) async throws -> [CrossRepoPR] { + var result: [CrossRepoPR] = [] + let prInfo = try await getPRInfo(repository: repository, prNumber: prNumber) + for line in prInfo.body?.split(separator: "\n") ?? [] { + guard line.lowercased().starts(with: "linked pr:") else { + continue + } + // We can't use Swift's Regex here because this script needs to run on Windows with Swift 5.9, which doesn't support + // Swift Regex. + var remainder = line[...] + guard let ownerRange = remainder.firstRange(of: "swiftlang/") ?? remainder.firstRange(of: "apple/") else { + continue + } + let repositoryOwner = remainder[ownerRange].dropLast() + remainder = remainder[ownerRange.upperBound...] + let repositoryName = remainder.prefix { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + if repositoryName.isEmpty { + continue + } + remainder = remainder.dropFirst(repositoryName.count) + if remainder.starts(with: "/pull/") { + remainder = remainder.dropFirst(6) + } else if remainder.starts(with: "#") { + remainder = remainder.dropFirst() + } else { + continue + } + let pullRequestNum = remainder.prefix { $0.isNumber } + if pullRequestNum.isEmpty { + continue + } + result.append( + CrossRepoPR( + repositoryOwner: String(repositoryOwner), + repositoryName: String(repositoryName), + prNumber: String(pullRequestNum) + ) + ) + } + return result +} + +func main() async throws { + guard ProcessInfo.processInfo.arguments.count >= 3 else { + throw GenericError( + """ + Expected two arguments: + - Repository name, eg. `swiftlang/swift-syntax + - PR number + """ + ) + } + let repository = ProcessInfo.processInfo.arguments[1] + let prNumber = ProcessInfo.processInfo.arguments[2] + + let crossRepoPrs = try await getCrossRepoPrs(repository: repository, prNumber: prNumber) + if !crossRepoPrs.isEmpty { + print("Detected cross-repo PRs") + for crossRepoPr in crossRepoPrs { + print(" - \(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)#\(crossRepoPr.prNumber)") + } + } + + for crossRepoPr in crossRepoPrs { + let git = try lookup(executable: "git") + let swift = try lookup(executable: "swift") + let baseBranch = try await getPRInfo( + repository: "\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)", + prNumber: crossRepoPr.prNumber + ).base.ref + + let workspaceDir = URL(fileURLWithPath: "..").resolvingSymlinksInPath() + let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName) + try run( + git, + "clone", + "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git", + "\(crossRepoPr.repositoryName)", + workingDirectory: workspaceDir + ) + try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir) + try run(git, "checkout", baseBranch, workingDirectory: repoDir) + try run(git, "reset", "--hard", "pr_merge", workingDirectory: repoDir) + try run( + swift, + "package", + "config", + "set-mirror", + "--package-url", + "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git", + "--mirror-url", + repoDir.path + ) + } +} + +do { + try await main() +} catch { + print(error) + #if os(Windows) + _Exit(1) + #else + exit(1) + #endif +} diff --git a/.github/workflows/swift_package_test.yml b/.github/workflows/swift_package_test.yml index b61a8f3..e218f19 100644 --- a/.github/workflows/swift_package_test.yml +++ b/.github/workflows/swift_package_test.yml @@ -64,6 +64,10 @@ on: type: boolean description: "Boolean to enable providing the GITHUB_TOKEN to downstream job." default: false + enable_cross_pr_testing: + type: boolean + description: "Whether PRs can be tested in combination with other PRs by mentioning them as `Linked PR: ` in the PR description" + default: false jobs: linux-build: @@ -88,6 +92,12 @@ jobs: if: ${{ inputs.needs_token }} run: | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV + - name: Check out related PRs + if: ${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' }} + run: | + apt-get update && apt-get install -y curl + curl -s https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/cross-pr-checkout.swift > /tmp/cross-pr-checkout.swift + swift /tmp/cross-pr-checkout.swift "${{ github.repository }}" "${{ github.event.number }}" - name: Set environment variables if: ${{ inputs.linux_env_vars }} run: | @@ -170,6 +180,18 @@ jobs: Invoke-Program swift --version Invoke-Program swift test --version Invoke-Program cd $Source + '@ >> $env:TEMP\test-script\run.ps1 + + if ("${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' }}" -eq "true") { + echo @' + 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 + # Running in script mode fails on Windows (https://github.com/swiftlang/swift/issues/77263), compile and run the script. + Invoke-Program swiftc -sdk $env:SDKROOT $env:TEMP\cross-pr-checkout.swift -o $env:TEMP\cross-pr-checkout.exe + Invoke-Program $env:TEMP\cross-pr-checkout.exe "${{ github.repository }}" "${{ github.event.number }}" + '@ >> $env:TEMP\test-script\run.ps1 + } + + echo @' ${{ inputs.windows_pre_build_command }} Invoke-Program ${{ inputs.windows_build_command }} ${{ (contains(matrix.swift_version, 'nightly') && inputs.swift_nightly_flags) || inputs.swift_flags }} '@ >> $env:TEMP\test-script\run.ps1 diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..41a022f --- /dev/null +++ b/.swift-format @@ -0,0 +1,18 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { + "spaces": 2 + }, + "lineBreakBeforeEachArgument": true, + "indentConditionalCompilationBlocks": false, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "NoBlockComments": false, + "OrderedImports": true, + "UseLetInEveryBoundCaseVariable": false, + "UseSynthesizedInitializer": false + } +} diff --git a/README.md b/README.md index 7d929a7..73705bc 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,25 @@ pre_build_command: "which example || (apt update -q && apt install -yq example" macOS platform support will be available soon. +#### Cross-PR testing + +To support testing of PRs together with PRs for one of the package’s dependencies, set add the following to your PR job. + +```yaml +with: + enable_cross_pr_testing: true +``` + +To reference a linked PR, add `Linked PR: ` to the PR description, eg. + +``` +Linked PR: https://github.com/swiftlang/swift-syntax/pull/2859 +// or alternatively +Linked PR: swiftlang/swift-syntax#2859 +``` + +Enabling cross-PR testing will add about 10s to PR testing time. + ## Running workflows locally You can run the Github Actions workflows locally using