From f39525891c417cf4ce0bd8276d6f52502289c65f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 17:37:49 -0600 Subject: [PATCH 1/5] fix: defer passive proof display refreshes --- Bitkit/AppScene.swift | 12 +- .../PaykitPaymentRequestService.swift | 3 +- BitkitTests/PaykitPaymentActivityTests.swift | 9 +- .../PaykitPaymentRequestServiceTests.swift | 149 ++++++++++++------ .../next/proof-display-admission.fixed.md | 1 + 5 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 changelog.d/next/proof-display-admission.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b26bb14e7..a0163e0bf 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -698,11 +698,13 @@ struct AppScene: View { .task(id: pendingPaykitProofRefresh) { guard let request = pendingPaykitProofRefresh else { return } do { - try await PaykitPaymentActivity.shared.waitUntilIdle() + try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true) } catch { return } guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return } - await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true, automatic: false) - guard !Task.isCancelled else { return } + let refreshed = await refreshIncomingPaykitPaymentRequests( + mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false + ) + guard !Task.isCancelled, refreshed else { return } paykitProofRefresh.complete(request) } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in @@ -1424,6 +1426,7 @@ struct AppScene: View { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, onlyWhileActive: Bool = false, automatic: Bool = true ) async -> Bool { @@ -1455,7 +1458,8 @@ struct AppScene: View { guard canRefresh() else { return false } let identity = session.publicKey let refreshed = await paykitPaymentRequestManager.refresh( - mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh + mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, + refreshPriority: refreshPriority, canRefresh: canRefresh ) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 926c8eb88..42eb3ff98 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1803,13 +1803,14 @@ final class PaykitPaymentRequestManager { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { await refresh( excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh, - messagePriority: forceFresh ? .ordered : messagePriority, + messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority), canRefresh: canRefresh ) } diff --git a/BitkitTests/PaykitPaymentActivityTests.swift b/BitkitTests/PaykitPaymentActivityTests.swift index af3dddaf9..612260967 100644 --- a/BitkitTests/PaykitPaymentActivityTests.swift +++ b/BitkitTests/PaykitPaymentActivityTests.swift @@ -50,13 +50,14 @@ final class PaykitPaymentActivityTests: XCTestCase { XCTAssertTrue(didRefresh) } - func testProofInvalidationsCoalesceUntilForegroundAndPaymentIdle() async throws { + func testProofInvalidationsCoalesceUntilForegroundAndPaymentReviewIdle() async throws { let profile = PubkyProfileManager() profile.publicKey = "pubky\(String(repeating: "z", count: 52))" let session = try XCTUnwrap(profile.currentSession) var state = PaykitPaymentProofRefreshState() let activity = PaykitPaymentActivity() let payment = activity.begin() + let review = activity.beginReview() for _ in 0 ..< 3 { state.invalidate(session: session) XCTAssertNil(state.request(session: session, isActive: false)) @@ -64,13 +65,17 @@ final class PaykitPaymentActivityTests: XCTestCase { let request = try XCTUnwrap(state.request(session: session, isActive: true)) var refreshes = 0 let work = Task { - try await activity.waitUntilIdle() + try await activity.waitUntilIdle(includingReviews: true) refreshes += 1 state.complete(request) } await Task.yield() XCTAssertEqual(refreshes, 0) activity.end(payment) + await Task.yield() + XCTAssertEqual(refreshes, 0) + XCTAssertEqual(state.request(session: session, isActive: true), request) + activity.endReview(review) try await work.value XCTAssertEqual(refreshes, 1) XCTAssertNil(state.request(session: session, isActive: true)) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 432348345..aeafa9235 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -364,31 +364,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { - let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: [record]) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh(messagePriority: .background) } - try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh(messagePriority: .background) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } - await sdk.setRecords([]) - let refreshStarted = expectation(description: "Request state refresh started") - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true, messagePriority: .background) - } - await fulfillment(of: [refreshStarted], timeout: 1) - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + await sdk.setRecords([]) + let refreshStarted = expectation(description: "Request state refresh started") + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, messagePriority: .background, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - XCTAssertTrue(manager.pendingRequests.isEmpty) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) - let priorities = await sdk.operationPriorities - XCTAssertEqual(priorities["pending"], [.background, .ordered]) - XCTAssertEqual(priorities["receive"], [.background, .ordered]) - XCTAssertEqual(priorities["observation"], [.background, .ordered]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + let priorities = await sdk.operationPriorities + let expectedPriority = refreshPriority ?? .ordered + XCTAssertEqual(priorities["pending"], [.background, expectedPriority]) + XCTAssertEqual(priorities["receive"], [.background, expectedPriority]) + XCTAssertEqual(priorities["observation"], [.background, expectedPriority]) + } } func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { @@ -503,31 +507,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshDoesNotOutliveCancellationOrIdentityChange() async throws { - for invalidation in ["cancel", "clear", "identity"] { - let sdk = PaymentRequestSdkMock(records: []) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh() } - try await waitUntil { await sdk.paymentRequestListIsPaused() } - let refreshStarted = expectation(description: invalidation) - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true) - } - await fulfillment(of: [refreshStarted], timeout: 1) + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + for invalidation in ["cancel", "clear", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshStarted = expectation(description: invalidation) + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) - switch invalidation { - case "cancel": fresh.cancel() - case "clear": manager.clear() - default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") - } - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + switch invalidation { + case "cancel": fresh.cancel() + case "clear": manager.clear() + default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + } + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) - XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) + XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + } } } @@ -4170,6 +4177,48 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) } + func testBackgroundStateChangeRefreshDefersWithoutConsumingSnapshotOrBlockingRecovery() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + let loaded = await manager.refresh(mode: .stored) + XCTAssertTrue(loaded) + let pending = manager.pendingRequests + await sdk.pauseNextPaymentRequestList() + let admitted = Task { await manager.refresh(mode: .stored) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let started = expectation(description: "Proof display refresh waiting for admitted snapshot") + let fresh = Task { + started.fulfill() + return await manager.refresh(mode: .stored, forceFresh: true, refreshPriority: .background) + } + await fulfillment(of: [started], timeout: 1) + await sdk.setBackgroundObservationDeferred(true) + await sdk.setRecords([]) + await sdk.resumePaymentRequestList() + let admittedResult = await admitted.value + let deferredResult = await fresh.value + XCTAssertTrue(admittedResult) + XCTAssertFalse(deferredResult) + XCTAssertEqual(manager.pendingRequests, pending) + + let recovered = await manager.refresh(mode: .stored, forceFresh: true) + XCTAssertTrue(recovered) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.setBackgroundObservationDeferred(false) + let latest = try paymentRequestRecord(id: "latest-request") + await sdk.setRecords([latest]) + let retried = await manager.refresh(mode: .stored) + XCTAssertTrue(retried) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [latest.paymentRequestId]) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["observation"], [.background, .background, .background, .ordered, .background]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 4) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + } + func testRefreshContinuesWhenPendingResponseDeliveryFails() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) await sdk.failNextProcess() @@ -9360,6 +9409,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var incomingRecords: [PaymentRequestRecord] = [] private var paymentRequestListCallCount = 0 private var paymentRequestListError: Error? + private var isBackgroundObservationDeferred = false private var peerRecords: [LinkedPeerRecord] = [] private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true @@ -9488,6 +9538,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func observeState(priority: PaykitSdkOperationLock.Priority, expectedIdentity: String?) async throws -> PaykitStateObservation { operationPriorities["observation", default: []].append(priority) + if priority == .background, isBackgroundObservationDeferred { + throw PaykitPaymentRequestError.operationInProgress + } let identity = activeIdentity if let expectedIdentity { guard PubkyPublicKeyFormat.matches(identity, expectedIdentity) else { throw PubkyServiceError.identityChanged } @@ -9534,6 +9587,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym paymentRequestListError = error } + func setBackgroundObservationDeferred(_ deferred: Bool) { + isBackgroundObservationDeferred = deferred + } + func identityStatus() -> IdentityStatus? { IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } diff --git a/changelog.d/next/proof-display-admission.fixed.md b/changelog.d/next/proof-display-admission.fixed.md new file mode 100644 index 000000000..7585296c7 --- /dev/null +++ b/changelog.d/next/proof-display-admission.fixed.md @@ -0,0 +1 @@ +Background payment-request updates now give priority to payment review and submission. From 6153563f7f73b44ed1a5f99c8606a3fab89e2bf4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 17:39:48 -0600 Subject: [PATCH 2/5] chore: rename changelog fragment --- .../next/{proof-display-admission.fixed.md => 1066.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{proof-display-admission.fixed.md => 1066.fixed.md} (100%) diff --git a/changelog.d/next/proof-display-admission.fixed.md b/changelog.d/next/1066.fixed.md similarity index 100% rename from changelog.d/next/proof-display-admission.fixed.md rename to changelog.d/next/1066.fixed.md From c8bab77fa30fa4084aee77db3602a900796c5565 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 23 Oct 2026 09:04:48 +0200 Subject: [PATCH 3/5] fix: skip allowance work when request refresh defers --- Bitkit/AppScene.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 537f279e2..1db93432c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1488,6 +1488,7 @@ struct AppScene: View { mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, refreshPriority: refreshPriority, canRefresh: canRefresh ) + guard refreshed else { return false } // Deleting the profile withdraws the payment lists an automatic payment would consume. if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { await paykitAllowanceManager.refresh() From 342b5841ebec0ae8385b262de996f3f33f2a9644 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 11:12:00 +0200 Subject: [PATCH 4/5] fix: keep payment confirmation busy after unlock --- .../Wallets/Send/SendConfirmationView.swift | 9 ++-- .../SendConfirmationLifecycleTests.swift | 51 ++++++++++++++++++- BitkitTests/SendConfirmationSwipeTests.swift | 15 ++++++ changelog.d/next/1039.fixed.md | 2 +- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 799e2305b..564d721c6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -382,7 +382,8 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: hasStartedAutomaticPayment, isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed, - isPreparingRequest: preparingRequest != nil + isPreparingRequest: preparingRequest != nil, + isSubmittingPayment: isSubmittingPayment ), isSubmitted: hasStartedAutomaticPayment, swipeProgress: $swipeProgress @@ -806,6 +807,7 @@ struct SendConfirmationView: View { } private func submitPayment(isAutomatic: Bool = false) async throws { + guard !isSubmittingPayment else { return } guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } if isAutomatic, isFeeRateMissing { guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { @@ -1012,9 +1014,10 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: Bool, isFeeRateMissing: Bool, feeRateLoadFailed: Bool, - isPreparingRequest: Bool = false + isPreparingRequest: Bool = false, + isSubmittingPayment: Bool = false ) -> Bool { - isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) + isSubmittingPayment || isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } private func openManualCoinSelection() async throws { diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 4f23ba88f..62dafc610 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -460,19 +460,66 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(setup.pinChecks, [false], "The payment starts once and is not cancelled while it awaits the PIN") } + func testRecreatedConfirmationDoesNotAuthorizeWhilePaymentIsSubmitting() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let pinEnabled = settings.pinEnabled + addTeardownBlock { @MainActor in settings.pinEnabled = pinEnabled } + settings.pinEnabled = true + settings.requirePinForPayments = true + settings.useBiometrics = false + settings.coinSelectionMethod = .autopilot + let app = AppViewModel() + let wallet = WalletViewModel() + let sheets = SheetViewModel() + sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil) + app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkytest", isInitialSubscriptionPayment: true) + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", paymentHash: Data(), amountSatoshis: 6007, + timestampSeconds: 0, expirySeconds: 0, isExpired: false, + description: nil, networkType: .regtest, payeeNodeId: nil + ) + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 6007 + wallet.totalLightningSats = 1_000_000 + let setup = SendConfirmationTestSetup() + setup.isPending = false + var isSubmittingPayment = true + var path: [SendRoute] = [] + + for _ in 0 ..< 2 { + let window = host( + app: app, + wallet: wallet, + sheets: sheets, + path: Binding(get: { path }, set: { path = $0 }), + setup: setup, + isSubmittingPayment: Binding(get: { isSubmittingPayment }, set: { isSubmittingPayment = $0 }) + ) + try await Task.sleep(for: .milliseconds(500)) + close(window) + } + + XCTAssertTrue(setup.pinChecks.isEmpty, "Restoring an in-flight payment must not request authorization again") + XCTAssertTrue(path.isEmpty, "Ignoring the duplicate entry must preserve the original payment's navigation") + XCTAssertTrue(isSubmittingPayment, "The original payment owns the retained submission flag") + } + private func host( app: AppViewModel, wallet: WalletViewModel, sheets: SheetViewModel, path: Binding<[SendRoute]>, navigation: SendConfirmationTestNavigation? = nil, - setup: SendConfirmationTestSetup? = nil + setup: SendConfirmationTestSetup? = nil, + isSubmittingPayment: Binding = .constant(false) ) -> UIWindow { let hwSend = HwSendCoordinator() let makeConfirmation = { (isSetupPending: Bool) in SendConfirmationView( navigationPath: path, - isSubmittingPayment: .constant(false), + isSubmittingPayment: isSubmittingPayment, hwSend: hwSend, requestPinCheck: { guard let setup else { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 47985501b..b0e71074f 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -150,6 +150,21 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testManualPaymentKeepsTheSwipeBusyWhenConfirmationIsRecreated() { + XCTAssertTrue(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: true + )) + XCTAssertFalse(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: false + )) + } + func testSwipeStopsItsSpinnerOnceTheFeeRateLoadFailed() { XCTAssertFalse(SendConfirmationView.isSwipeLoading( hasStartedAutomaticPayment: false, diff --git a/changelog.d/next/1039.fixed.md b/changelog.d/next/1039.fixed.md index a46649f74..ac1d67e07 100644 --- a/changelog.d/next/1039.fixed.md +++ b/changelog.d/next/1039.fixed.md @@ -1 +1 @@ -Fixed payment requests losing their amount or getting stuck on loading after unlocking the app. +Fixed payment requests losing their amount or getting stuck on loading after unlocking the app, and kept in-flight payments busy after unlock. From 981aa2e4c9db3aa6801f91d4af1ada9bd919b603 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 19:46:23 +0200 Subject: [PATCH 5/5] ci: reject stale pull request merge checkouts --- .github/workflows/e2e-tests.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b0f739e28..e200fb2e5 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -73,6 +73,23 @@ jobs: echo "Xcode Version:" xcodebuild -version + - name: Verify current PR merge checkout + if: github.event_name == 'pull_request' || inputs.pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }} + run: | + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || exit 1 + tested=$(git rev-parse HEAD) + head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq 'select(.base.ref == "master") | .head.sha') + base=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq '.object.sha') + expected="$base $head" + actual=$(gh api "repos/$GITHUB_REPOSITORY/commits/$tested" --jq '[.parents[].sha] | join(" ")') + if [[ ! "$expected" =~ ^[0-9a-f]{40}\ [0-9a-f]{40}$ || "$actual" != "$expected" ]]; then + echo "::error::Checked-out merge does not match the PR's current base and head" + exit 1 + fi + - name: Install xcbeautify run: | brew install xcbeautify