From 264c3cdbb75308bf924187aec1764424497b7c08 Mon Sep 17 00:00:00 2001 From: Bradley Windybank Date: Thu, 9 Jul 2026 12:53:34 +1200 Subject: [PATCH 1/4] Migrate Swift Lambda build to Amazon Linux 2023 provided.al2 is EOL: AWS blocks new-function creation Jul 31 2026 and updates Aug 31 2026, and recommends migrating to an AL2023-based runtime. Also fixes the CI swift-test job, which was failing outright: GitHub Actions now defaults to Node 24 for marketplace actions, and Node 24 needs a newer glibc than AL2 ships, so actions/checkout couldn't even run inside the old container. swift:6.3-amazonlinux2023 has a current glibc and ships git + openssl-devel already, so the CI workflow's manual install step is no longer needed either. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Ex6D4aZJirG2b5yhjZsNR --- .github/workflows/ci-cd.yml | 6 ++---- Makefile | 4 ++-- README.md | 10 +++++----- scripts/build.sh | 6 +++--- template.yaml | 4 ++-- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 148e9b1..f30dc68 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -14,11 +14,9 @@ concurrency: jobs: swift-test: name: Swift tests - runs-on: ubuntu-24.04-arm # free arm64 runner (public repo); matches prod arch - container: swift:6.3-amazonlinux2 # exact prod toolchain — same image scripts/build.sh uses + runs-on: ubuntu-24.04-arm # free arm64 runner (public repo); matches prod arch + container: swift:6.3-amazonlinux2023 # exact prod toolchain — same image scripts/build.sh uses; already bundles git + openssl-devel steps: - - name: Install git + openssl (AL2 image, needed by SwiftPM/deps) - run: yum -y install git openssl openssl-devel - uses: actions/checkout@v4 - name: swift test run: swift test diff --git a/Makefile b/Makefile index 8f42f4c..6cb3512 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -# SAM custom-runtime build target for the Swift Lambda (provided.al2). +# SAM custom-runtime build target for the Swift Lambda (provided.al2023). # `sam build` invokes `make build-NZImageApiFunction` with ARTIFACTS_DIR set; it then # zips ARTIFACTS_DIR itself (no package.sh needed). scripts/build.sh cross-compiles a -# static arm64 Amazon Linux 2 binary in Docker (pinned --platform linux/arm64 to match +# static arm64 Amazon Linux 2023 binary in Docker (pinned --platform linux/arm64 to match # Architectures: [arm64] in template.yaml). build-NZImageApiFunction: ./scripts/build.sh diff --git a/README.md b/README.md index ace31cb..fe8a2f3 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ The deployment script handles building with Docker and packaging with UPX: **Manual build steps:** ```bash -# 1. Build using Docker (compiles for Amazon Linux 2) +# 1. Build using Docker (compiles for Amazon Linux 2023) ./scripts/build.sh # 2. Package with UPX compression @@ -269,7 +269,7 @@ The deployment script handles building with Docker and packaging with UPX: 2. Select your function (or create new) 3. Upload `.build/lambda/NZImageApiLambda/lambda.zip` 4. Configure: - - Runtime: `provided.al2` (Amazon Linux 2) + - Runtime: `provided.al2023` (Amazon Linux 2023) - Handler: `bootstrap` - Architecture: `arm64` - Timeout: 60 seconds @@ -292,7 +292,7 @@ aws lambda update-function-configuration \ ```bash aws lambda create-function \ --function-name NZImageApiLambda \ - --runtime provided.al2 \ + --runtime provided.al2023 \ --role YOUR_LAMBDA_ROLE_ARN \ --handler bootstrap \ --zip-file fileb://.build/lambda/NZImageApiLambda/lambda.zip \ @@ -370,8 +370,8 @@ The design rationale (why free static secrets instead of Amazon Cognito) is reco ## Build Details The build process: -- Uses Docker with `swift:6.0-amazonlinux2` image -- Compiles for AWS Lambda's Amazon Linux 2 runtime +- Uses Docker with `swift:6.3-amazonlinux2023` image +- Compiles for AWS Lambda's Amazon Linux 2023 runtime - Statically links the Swift standard library - Compresses the binary with UPX (~70% size reduction: 106MB → 32MB) - Creates a `bootstrap` executable (required by AWS custom runtime) diff --git a/scripts/build.sh b/scripts/build.sh index 3ded04c..b5c0a5b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -7,11 +7,11 @@ echo "Cleaning previous build artifacts..." rm -rf .build/checkouts .build/repositories .build/workspace-state.json # Build using Docker -echo "Building Lambda for Amazon Linux 2..." +echo "Building Lambda for Amazon Linux 2023..." docker run \ --rm \ --platform linux/arm64 \ --volume "$(pwd)/:/src" \ --workdir "/src/" \ - swift:6.3-amazonlinux2 \ - /bin/bash -c "yum -y update; yum -y install openssl openssl-devel -y; swift build --product NZImageApiLambda -c release -Xswiftc -static-stdlib" + swift:6.3-amazonlinux2023 \ + /bin/bash -c "dnf -y install openssl-devel; swift build --product NZImageApiLambda -c release -Xswiftc -static-stdlib" diff --git a/template.yaml b/template.yaml index d819918..1261c35 100644 --- a/template.yaml +++ b/template.yaml @@ -144,13 +144,13 @@ Resources: AlarmActions: - !Ref AlarmTopic - # --- Swift NZ Image API (provided.al2 custom runtime, built via the Makefile) ------- + # --- Swift NZ Image API (provided.al2023 custom runtime, built via the Makefile) ------- NZImageApiFunction: Type: AWS::Serverless::Function Properties: CodeUri: . Handler: bootstrap - Runtime: provided.al2 + Runtime: provided.al2023 Architectures: - arm64 MemorySize: 512 From 74373d9bedfd520e4f3a08643fea60e7a710d210 Mon Sep 17 00:00:00 2001 From: Bradley Windybank Date: Thu, 9 Jul 2026 13:06:11 +1200 Subject: [PATCH 2/4] Fix Linux build: guard Darwin-only Foundation/ImageIO APIs swift test had never actually run on Linux before (previously blocked by the CI checkout failure). It surfaced real portability gaps: on Linux, Foundation splits URLSession/URLRequest/HTTPURLResponse into a separate FoundationNetworking module, and ImageIO doesn't exist at all. Guard the imports/usages with #if canImport(...), matching the pattern already used in NetworkRequestManager.swift/ValidatedRequestManager.swift. No behavior change on macOS; ImageResolutionChecker (a macOS-only local dev CLI, never run in CI) gets a Linux stub that returns nil so the package still compiles. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Ex6D4aZJirG2b5yhjZsNR --- Sources/Testing/ImageResolutionChecker/main.swift | 8 ++++++++ Sources/Testing/LambdaTesting/LambdaTesting.swift | 3 +++ .../DigitalNZAPIDataSourcePageMathTests.swift | 3 +++ .../NetworkRequestManagerValidationTests.swift | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/Sources/Testing/ImageResolutionChecker/main.swift b/Sources/Testing/ImageResolutionChecker/main.swift index 391b65d..7b6a60c 100644 --- a/Sources/Testing/ImageResolutionChecker/main.swift +++ b/Sources/Testing/ImageResolutionChecker/main.swift @@ -7,7 +7,9 @@ import Alamofire import Foundation +#if canImport(ImageIO) import ImageIO +#endif import OrderedCollections import RichError @@ -79,6 +81,7 @@ struct ResolutionCheckOutput: Codable { // MARK: - Image Resolution Checker func getImageResolution(from url: URL) async -> ImageResolution? { + #if canImport(ImageIO) do { let (data, _) = try await URLSession.shared.data(from: url) @@ -94,6 +97,11 @@ func getImageResolution(from url: URL) async -> ImageResolution? { } catch { return nil } + #else + // ImageIO is Darwin-only; this dev CLI tool isn't built/run on Linux (see CLAUDE.md's + // "Sources/Testing/*" CI exclusion), but Linux `swift test` still compiles this target. + return nil + #endif } func checkImageURL(_ url: URL?) async -> ImageCheckResult? { diff --git a/Sources/Testing/LambdaTesting/LambdaTesting.swift b/Sources/Testing/LambdaTesting/LambdaTesting.swift index e585c6f..5e4d52b 100644 --- a/Sources/Testing/LambdaTesting/LambdaTesting.swift +++ b/Sources/Testing/LambdaTesting/LambdaTesting.swift @@ -11,6 +11,9 @@ import Darwin #else import Glibc #endif +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif // MARK: - Lambda Response diff --git a/Tests/NZImageApiLambdaTests/DigitalNZAPIDataSourcePageMathTests.swift b/Tests/NZImageApiLambdaTests/DigitalNZAPIDataSourcePageMathTests.swift index 9dfd91b..53c007c 100644 --- a/Tests/NZImageApiLambdaTests/DigitalNZAPIDataSourcePageMathTests.swift +++ b/Tests/NZImageApiLambdaTests/DigitalNZAPIDataSourcePageMathTests.swift @@ -8,6 +8,9 @@ import Foundation import XCTest @testable import NZImageApiLambda +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Stands in for the real network-backed `ValidatedRequestManager`. Returns a canned /// `NZRecordsResponse` for both the initial (`per_page=0`, count-only) and secondary diff --git a/Tests/NZImageApiLambdaTests/NetworkRequestManagerValidationTests.swift b/Tests/NZImageApiLambdaTests/NetworkRequestManagerValidationTests.swift index fbc5e8d..d0f965d 100644 --- a/Tests/NZImageApiLambdaTests/NetworkRequestManagerValidationTests.swift +++ b/Tests/NZImageApiLambdaTests/NetworkRequestManagerValidationTests.swift @@ -6,8 +6,12 @@ // testable without any actual network request. // +import Foundation import XCTest @testable import NZImageApiLambda +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif final class NetworkRequestManagerValidationTests: XCTestCase { private func response(statusCode: Int, contentType: String?) -> HTTPURLResponse { From 7578ca7086f31de467c9f1d9e1f66fbfc1b0148e Mon Sep 17 00:00:00 2001 From: Bradley Windybank Date: Thu, 9 Jul 2026 13:13:15 +1200 Subject: [PATCH 3/4] Fix Linux build: alias stdout/stderr as nonisolated(unsafe) Another gap swift test never caught before running on Linux for the first time: Glibc's stdout/stderr overlay (unlike Darwin's) doesn't mark these extern C globals Sendable, so Swift 6 strict concurrency rejects direct references in CollectionReviewer, ImageResolutionChecker, and CollectionLister. These are single-threaded interactive/batch CLI tools, so aliasing the globals as nonisolated(unsafe) locals (the SE-0412-documented escape hatch) is safe and requires no behavior change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Ex6D4aZJirG2b5yhjZsNR --- Sources/Testing/CollectionLister/main.swift | 12 +++++++++--- Sources/Testing/CollectionReviewer/main.swift | 15 +++++++++++---- Sources/Testing/ImageResolutionChecker/main.swift | 12 +++++++++--- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Sources/Testing/CollectionLister/main.swift b/Sources/Testing/CollectionLister/main.swift index 23a929c..2860e8a 100644 --- a/Sources/Testing/CollectionLister/main.swift +++ b/Sources/Testing/CollectionLister/main.swift @@ -12,6 +12,12 @@ import Foundation import FoundationNetworking #endif +// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them +// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded +// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented +// escape hatch) is safe. +nonisolated(unsafe) private let standardError = stderr + // MARK: - Models struct FacetsResponse: Codable { @@ -47,7 +53,7 @@ struct CollectionListOutput: Codable { struct CollectionListerApp { static func main() async throws { guard let apiKey = ProcessInfo.processInfo.environment["DIGITALNZ_API_KEY"] else { - fputs("Error: DIGITALNZ_API_KEY environment variable not set\n", stderr) + fputs("Error: DIGITALNZ_API_KEY environment variable not set\n", standardError) exit(1) } @@ -75,12 +81,12 @@ struct CollectionListerApp { // Check for API errors if let errors = response.errors, !errors.isEmpty { - fputs("API returned errors: \(errors.joined(separator: ", "))\n", stderr) + fputs("API returned errors: \(errors.joined(separator: ", "))\n", standardError) exit(1) } guard let facetDict = response.search?.facets?.primaryCollection else { - fputs("Error: No facet data returned from API\n", stderr) + fputs("Error: No facet data returned from API\n", standardError) exit(1) } diff --git a/Sources/Testing/CollectionReviewer/main.swift b/Sources/Testing/CollectionReviewer/main.swift index a18fc8d..c527920 100644 --- a/Sources/Testing/CollectionReviewer/main.swift +++ b/Sources/Testing/CollectionReviewer/main.swift @@ -13,6 +13,13 @@ import Synchronization import FoundationNetworking #endif +// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them +// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded +// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented +// escape hatch) is safe. +nonisolated(unsafe) private let standardOutput = stdout +nonisolated(unsafe) private let standardError = stderr + // MARK: - Progress Tracking struct ProgressState: Sendable { @@ -229,7 +236,7 @@ func printClickableLink(_ url: URL, label: String) { func getSelectionInput(prompt: String) -> SelectionStatus { print(prompt, terminator: " ") - fflush(stdout) + fflush(standardOutput) while true { if let input = readLine()?.lowercased().trimmingCharacters(in: .whitespaces) { @@ -249,13 +256,13 @@ func getSelectionInput(prompt: String) -> SelectionStatus { } } print("Please enter y/n/u/m/s: ", terminator: "") - fflush(stdout) + fflush(standardOutput) } } func getTextInput(prompt: String) -> String { print(prompt, terminator: " ") - fflush(stdout) + fflush(standardOutput) return readLine() ?? "" } @@ -297,7 +304,7 @@ struct CollectionReviewerApp { progressState.withLock { $0.filePath = "\(currentDir)/Research/.collection-reviewer-progress" } guard fileManager.fileExists(atPath: detailsPath) else { - fputs("Error: Could not find \(detailsPath)\n", stderr) + fputs("Error: Could not find \(detailsPath)\n", standardError) exit(1) } diff --git a/Sources/Testing/ImageResolutionChecker/main.swift b/Sources/Testing/ImageResolutionChecker/main.swift index 7b6a60c..483aadd 100644 --- a/Sources/Testing/ImageResolutionChecker/main.swift +++ b/Sources/Testing/ImageResolutionChecker/main.swift @@ -17,6 +17,12 @@ import RichError import FoundationNetworking #endif +// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them +// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded +// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented +// escape hatch) is safe. +nonisolated(unsafe) private let standardError = stderr + // MARK: - Models struct NZRecordsResult: Codable, Sendable { @@ -180,20 +186,20 @@ struct ImageResolutionCheckerApp { let arguments = CommandLine.arguments guard arguments.count >= 2 else { - fputs("Usage: ImageResolutionChecker \n", stderr) + fputs("Usage: ImageResolutionChecker \n", standardError) exit(1) } let collection = arguments[1] guard let apiKey = ProcessInfo.processInfo.environment["DIGITALNZ_API_KEY"] else { - fputs("Error: DIGITALNZ_API_KEY environment variable not set\n", stderr) + fputs("Error: DIGITALNZ_API_KEY environment variable not set\n", standardError) exit(1) } // Fetch a result from Digital NZ guard let result = try await fetchDigitalNZResult(collection: collection, apiKey: apiKey) else { - fputs("Error: No results found for collection '\(collection)'\n", stderr) + fputs("Error: No results found for collection '\(collection)'\n", standardError) exit(1) } From 9d7484136728fa05252346cb1efb267dd7830c29 Mon Sep 17 00:00:00 2001 From: Bradley Windybank Date: Thu, 9 Jul 2026 13:24:34 +1200 Subject: [PATCH 4/4] Fix stdout/stderr concurrency fix: @preconcurrency import Glibc first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nonisolated(unsafe) on my own wrapper globals alone wasn't enough — the diagnostic was on the *read* of Glibc's stdout/stderr in the initializer, which nonisolated(unsafe) on the LHS doesn't launder. Per https://github.com/swiftlang/swift/issues/77866, the actual fix is @preconcurrency import Glibc, and critically it has to be the first import in the file — otherwise Foundation's own transitive (non-preconcurrency) Glibc import wins and the diagnostic persists. Keeping nonisolated(unsafe) on the wrapper lets too: that addresses a separate, independent diagnostic (a new global of non-Sendable type UnsafeMutablePointer). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Ex6D4aZJirG2b5yhjZsNR --- Sources/Testing/CollectionLister/main.swift | 14 ++++++++++---- Sources/Testing/CollectionReviewer/main.swift | 14 ++++++++++---- Sources/Testing/ImageResolutionChecker/main.swift | 14 ++++++++++---- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Sources/Testing/CollectionLister/main.swift b/Sources/Testing/CollectionLister/main.swift index 2860e8a..eebf3a1 100644 --- a/Sources/Testing/CollectionLister/main.swift +++ b/Sources/Testing/CollectionLister/main.swift @@ -5,6 +5,14 @@ // Lists all image collections from Digital NZ and their counts // +// Must be the first import: Glibc's stdout/stderr aren't marked Sendable, so Swift 6 strict +// concurrency flags any reference to them. @preconcurrency suppresses that, but only if this +// import is resolved before Foundation's own (non-preconcurrency) transitive import of Glibc — +// see https://github.com/swiftlang/swift/issues/77866. +#if canImport(Glibc) +@preconcurrency import Glibc +#endif + import Alamofire import Foundation @@ -12,10 +20,8 @@ import Foundation import FoundationNetworking #endif -// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them -// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded -// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented -// escape hatch) is safe. +// nonisolated(unsafe) because this is a new global of a non-Sendable type +// (UnsafeMutablePointer); safe here since this CLI is single-threaded terminal I/O. nonisolated(unsafe) private let standardError = stderr // MARK: - Models diff --git a/Sources/Testing/CollectionReviewer/main.swift b/Sources/Testing/CollectionReviewer/main.swift index c527920..b1797ab 100644 --- a/Sources/Testing/CollectionReviewer/main.swift +++ b/Sources/Testing/CollectionReviewer/main.swift @@ -5,6 +5,14 @@ // Interactive review tool for all collections // +// Must be the first import: Glibc's stdout/stderr aren't marked Sendable, so Swift 6 strict +// concurrency flags any reference to them. @preconcurrency suppresses that, but only if this +// import is resolved before Foundation's own (non-preconcurrency) transitive import of Glibc — +// see https://github.com/swiftlang/swift/issues/77866. +#if canImport(Glibc) +@preconcurrency import Glibc +#endif + import Foundation import LambdaTesting import Synchronization @@ -13,10 +21,8 @@ import Synchronization import FoundationNetworking #endif -// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them -// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded -// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented -// escape hatch) is safe. +// nonisolated(unsafe) because these are new globals of a non-Sendable type +// (UnsafeMutablePointer); safe here since this CLI is single-threaded terminal I/O. nonisolated(unsafe) private let standardOutput = stdout nonisolated(unsafe) private let standardError = stderr diff --git a/Sources/Testing/ImageResolutionChecker/main.swift b/Sources/Testing/ImageResolutionChecker/main.swift index 483aadd..314e54b 100644 --- a/Sources/Testing/ImageResolutionChecker/main.swift +++ b/Sources/Testing/ImageResolutionChecker/main.swift @@ -5,6 +5,14 @@ // Checks image resolutions for Digital NZ collections // +// Must be the first import: Glibc's stdout/stderr aren't marked Sendable, so Swift 6 strict +// concurrency flags any reference to them. @preconcurrency suppresses that, but only if this +// import is resolved before Foundation's own (non-preconcurrency) transitive import of Glibc — +// see https://github.com/swiftlang/swift/issues/77866. +#if canImport(Glibc) +@preconcurrency import Glibc +#endif + import Alamofire import Foundation #if canImport(ImageIO) @@ -17,10 +25,8 @@ import RichError import FoundationNetworking #endif -// stdout/stderr are extern C globals; Glibc's overlay (unlike Darwin's) doesn't mark them -// Sendable, so Swift 6 strict concurrency flags direct references. This CLI is single-threaded -// interactive terminal I/O, so aliasing them as nonisolated(unsafe) (the SE-0412-documented -// escape hatch) is safe. +// nonisolated(unsafe) because this is a new global of a non-Sendable type +// (UnsafeMutablePointer); safe here since this CLI is single-threaded terminal I/O. nonisolated(unsafe) private let standardError = stderr // MARK: - Models