Skip to content

Commit 814da3b

Browse files
authored
ci: adopt swift-format + warnings/doc-link gates (#325)
Port the format and CI tooling from SwiftSync: - .swift-format (120 cols, 4-space) and a one-time pre-commit hook (.githooks/pre-commit, enabled via scripts/setup.sh) that formats staged Swift files. - CI jobs: swift-format check, a warnings gate (fails on any first-party warning:), and a dead-doc-link check (scripts/check-doc-links.py), alongside the existing go-httpbin build & test. - Reformat Sources/ and Tests/ to satisfy the format check. - Fix README link to the deleted CHANGELOG.md (-> GitHub releases) so the new doc-link gate is green.
1 parent f188b18 commit 814da3b

54 files changed

Lines changed: 1147 additions & 599 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.githooks/pre-commit

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/sh
2+
# Formats staged Swift files with swift-format before each commit.
3+
# Enable once per clone: ./scripts/setup.sh (or git config core.hooksPath .githooks)
4+
set -e
5+
6+
# NUL-delimited so paths with spaces survive. ACM excludes deletions.
7+
files=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.swift' | tr '\0' '\n')
8+
[ -z "$files" ] && exit 0
9+
10+
if ! command -v swift >/dev/null 2>&1; then
11+
echo "pre-commit: swift toolchain not found; skipping swift-format." >&2
12+
exit 0
13+
fi
14+
15+
echo "$files" | while IFS= read -r f; do
16+
[ -n "$f" ] || continue
17+
# A file with unstaged changes is partially staged; formatting in place and
18+
# re-adding would pull the unstaged hunks into the commit. Skip it — CI's
19+
# format check is the backstop for anything the hook can't safely touch.
20+
if git diff --quiet -- "$f"; then
21+
swift format --in-place "$f"
22+
git add -- "$f"
23+
else
24+
echo "pre-commit: '$f' is partially staged; skipping format (run swift format manually)." >&2
25+
fi
26+
done

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ env:
1313
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
1414

1515
jobs:
16+
swift-format-check:
17+
name: swift-format check
18+
runs-on: macos-15
19+
steps:
20+
- uses: actions/checkout@v5
21+
- name: Check formatting
22+
run: |
23+
swift format --in-place --recursive Sources Tests
24+
if ! git diff --exit-code; then
25+
echo "::error::Code is not formatted. Run: swift format --in-place --recursive Sources Tests (and commit)."
26+
exit 1
27+
fi
28+
1629
build-and-test:
1730
name: macOS Swift Build & Test
1831
runs-on: macos-15
@@ -40,3 +53,27 @@ jobs:
4053
env:
4154
HTTPBIN_BASE_URL: http://127.0.0.1:8080
4255
run: swift test -v
56+
57+
warnings:
58+
name: Warnings gate
59+
runs-on: macos-15
60+
steps:
61+
- uses: actions/checkout@v5
62+
# No SPM cache on purpose: a fresh build recompiles everything so warnings are
63+
# actually re-emitted (an incremental build would skip unchanged files).
64+
- name: Build and fail on first-party warnings
65+
run: |
66+
set -o pipefail
67+
swift build --build-tests 2>&1 | tee build.log
68+
if grep -E "warning:" build.log | grep -vE "\.build/(checkouts|artifacts)"; then
69+
echo "::error::Build emitted warnings in first-party code. Fix them (the lines above)."
70+
exit 1
71+
fi
72+
73+
doc-links:
74+
name: Doc link check
75+
runs-on: macos-15
76+
steps:
77+
- uses: actions/checkout@v5
78+
- name: Check relative markdown links resolve
79+
run: python3 scripts/check-doc-links.py

.swift-format

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"version": 1,
3+
"lineLength": 120,
4+
"indentation": {
5+
"spaces": 4
6+
},
7+
"maximumBlankLines": 1,
8+
"respectsExistingLineBreaks": true,
9+
"lineBreakBeforeEachArgument": false
10+
}

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ what's specific to this repo. Release history and the v8 (major) notes live in `
1111
- `swift build` — library only; CI also fails on any first-party `warning:`.
1212
- Integration tests need go-httpbin (`TestConfig.httpbinBaseURL`, default `http://127.0.0.1:8080`). Without it
1313
they fail fast (connection refused) — intended, not a flake. The offline / `fake*` suites need no server.
14+
- Formatting: `.swift-format` (120 cols, 4-space). Run `./scripts/setup.sh` once per clone to enable the
15+
`.githooks/pre-commit` hook (formats staged Swift files); CI's format check is the backstop.
1416
- CI: `macos-15` / Xcode 26.3 / Swift 6.2.x (stricter region-isolation than local 6.3 — CI is the gate).
17+
Jobs: swift-format check, build & test (go-httpbin), a warnings gate, and a dead-doc-link check
18+
(`scripts/check-doc-links.py`).
1519

1620
## Source map
1721

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ In your `Package.swift`:
638638

639639
and add `"Networking"` to your target's dependencies. In Xcode, use **File Add Package Dependencies…** and enter `https://github.com/3lvis/Networking.git`.
640640

641-
> **Upgrading from 7.x?** 8.0.0 is a major, breaking release (async/`await` + `Result`, Swift 6, typed request bodies, a categorized error model, an event stream, and request interceptors). See [`CHANGELOG.md`](CHANGELOG.md) for the full migration.
641+
> **Upgrading from 7.x?** 8.0.0 is a major, breaking release (async/`await` + `Result`, Swift 6, typed request bodies, a categorized error model, an event stream, and request interceptors). See the [release notes](https://github.com/3lvis/Networking/releases) for the full migration.
642642

643643
## Running the tests
644644

Sources/Networking/CacheExpiry.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@ final class CacheExpiry: @unchecked Sendable {
1313
}
1414

1515
var ttl: Duration {
16-
lock.lock(); defer { lock.unlock() }
16+
lock.lock()
17+
defer { lock.unlock() }
1718
return .seconds(ttlSeconds)
1819
}
1920

2021
func setTTL(_ ttl: Duration) {
21-
lock.lock(); defer { lock.unlock() }
22+
lock.lock()
23+
defer { lock.unlock() }
2224
ttlSeconds = ttl.seconds
2325
}
2426

2527
// Cold = the file's last use (its modification date) is older than the TTL. A missing date (no file)
2628
// is treated as not-expired so a present-but-unreadable entry isn't dropped spuriously.
2729
func isExpired(fileDate: Date?) -> Bool {
2830
guard let fileDate else { return false }
29-
lock.lock(); defer { lock.unlock() }
31+
lock.lock()
32+
defer { lock.unlock() }
3033
return Date().timeIntervalSince(fileDate) > ttlSeconds
3134
}
3235
}

Sources/Networking/CacheStore.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import Foundation
21
import CryptoKit
2+
import Foundation
33

44
// Owns both cache tiers: the in-memory `NSCache` (warm, pressure-evicted by iOS) over the on-disk shard
55
// layout (cold, durable). A non-actor, synchronous type so the nonisolated cache reads
@@ -33,8 +33,11 @@ final class CacheStore: @unchecked Sendable {
3333
let finalPath = "\(folderPath)/\(component)"
3434

3535
guard let url = URL(string: finalPath),
36-
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
37-
throw NSError(domain: folderName, code: 9999, userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"])
36+
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
37+
else {
38+
throw NSError(
39+
domain: folderName, code: 9999,
40+
userInfo: [NSLocalizedDescriptionKey: "Couldn't build a cache URL for: \(finalPath)"])
3841
}
3942

4043
let folderURL = cachesURL.appendingPathComponent(URL(string: folderPath)!.absoluteString)
@@ -87,7 +90,8 @@ final class CacheStore: @unchecked Sendable {
8790
if let object = memory.object(forKey: key as AnyObject) {
8891
return object
8992
} else if FileManager.default.exists(at: destinationURL) {
90-
let fileDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
93+
let fileDate = try? destinationURL.resourceValues(forKeys: [.contentModificationDateKey])
94+
.contentModificationDate
9195
if expiry.isExpired(fileDate: fileDate) {
9296
try FileManager.default.remove(at: destinationURL)
9397
return nil
@@ -102,7 +106,8 @@ final class CacheStore: @unchecked Sendable {
102106
memory.setObject(returnedObject as AnyObject, forKey: key as AnyObject)
103107
// Re-warm: bump the file's mtime so an entry in active use never expires. Only happens
104108
// on a memory miss, so it's ~once per entry per launch — no explicit debounce needed.
105-
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: destinationURL.path)
109+
try? FileManager.default.setAttributes(
110+
[.modificationDate: Date()], ofItemAtPath: destinationURL.path)
106111
}
107112

108113
return returnedObject
@@ -180,7 +185,9 @@ final class CacheStore: @unchecked Sendable {
180185
}
181186

182187
private static func folderURL(named folderName: String) -> URL? {
183-
guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
188+
guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
189+
return nil
190+
}
184191
return cachesURL.appendingPathComponent(URL(string: folderName)!.absoluteString)
185192
}
186193

@@ -195,12 +202,20 @@ final class CacheStore: @unchecked Sendable {
195202
let maxAge = expiry.ttl.seconds
196203

197204
let cursorURL = domainURL.appendingPathComponent(Self.sweepCursorFileName)
198-
let cursor = (try? String(contentsOf: cursorURL, encoding: .utf8)).flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } ?? 0
205+
let cursor =
206+
(try? String(contentsOf: cursorURL, encoding: .utf8)).flatMap {
207+
Int($0.trimmingCharacters(in: .whitespacesAndNewlines))
208+
} ?? 0
199209
let shardURL = domainURL.appendingPathComponent(String(cursor % Self.shardCount, radix: 16))
200210

201-
if let files = try? FileManager.default.contentsOfDirectory(at: shardURL, includingPropertiesForKeys: [.contentModificationDateKey], options: []) {
211+
if let files = try? FileManager.default.contentsOfDirectory(
212+
at: shardURL, includingPropertiesForKeys: [.contentModificationDateKey], options: [])
213+
{
202214
for file in files {
203-
guard let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { continue }
215+
guard
216+
let modified = try? file.resourceValues(forKeys: [.contentModificationDateKey])
217+
.contentModificationDate
218+
else { continue }
204219
if now.timeIntervalSince(modified) > maxAge {
205220
try? FileManager.default.removeItem(at: file)
206221
}
@@ -209,7 +224,9 @@ final class CacheStore: @unchecked Sendable {
209224

210225
// Pre-sharding versions wrote files directly under the domain root; clear those strays (the cursor
211226
// file and the shard subdirectories stay).
212-
if let rootEntries = try? FileManager.default.contentsOfDirectory(at: domainURL, includingPropertiesForKeys: [.isRegularFileKey], options: []) {
227+
if let rootEntries = try? FileManager.default.contentsOfDirectory(
228+
at: domainURL, includingPropertiesForKeys: [.isRegularFileKey], options: [])
229+
{
213230
for entry in rootEntries where entry.lastPathComponent != Self.sweepCursorFileName {
214231
if (try? entry.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
215232
try? FileManager.default.removeItem(at: entry)

Sources/Networking/DownloadResponse.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
23
#if os(macOS)
34
import AppKit.NSImage
45
#else
@@ -38,7 +39,8 @@ public struct ImageResponse: ImageDownloadable, @unchecked Sendable {
3839
public let headers: [String: AnyCodable]
3940
public let image: Image
4041

41-
public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> ImageResponse {
42+
public static func makeDownloadResult(image: Image, statusCode: Int, headers: [String: AnyCodable]) -> ImageResponse
43+
{
4244
ImageResponse(statusCode: statusCode, headers: headers, image: image)
4345
}
4446
}

Sources/Networking/FakeRequest.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,21 @@ struct FakeRequest {
1515
let statusCode: Int
1616
let delay: Double
1717

18-
init(payload: Payload, responseType: Networking.ResponseType, headerFields: [String : String]?, statusCode: Int, delay: Double) {
18+
init(
19+
payload: Payload, responseType: Networking.ResponseType, headerFields: [String: String]?, statusCode: Int,
20+
delay: Double
21+
) {
1922
self.payload = payload
2023
self.responseType = responseType
2124
self.headerFields = headerFields
2225
self.statusCode = statusCode
2326
self.delay = delay
2427
}
2528

26-
static func find(ofType type: Networking.RequestType, forPath path: String, in collection: [Networking.RequestType: [String: FakeRequest]]) throws -> FakeRequest? {
29+
static func find(
30+
ofType type: Networking.RequestType, forPath path: String,
31+
in collection: [Networking.RequestType: [String: FakeRequest]]
32+
) throws -> FakeRequest? {
2733
guard let requests = collection[type] else { return nil }
2834
guard path.count > 0 else { return nil }
2935

@@ -60,15 +66,19 @@ struct FakeRequest {
6066
}
6167
guard replacedPath == path else { continue }
6268
// Substitute the captured path values into the JSON body string (e.g. "{userID}" -> "10").
63-
guard case let .data(data) = fakeRequest.payload,
64-
var responseString = String(data: data, encoding: .utf8) else { continue }
69+
guard case .data(let data) = fakeRequest.payload,
70+
var responseString = String(data: data, encoding: .utf8)
71+
else { continue }
6572

6673
for (key, value) in replacedValues {
6774
responseString = responseString.replacingOccurrences(of: key, with: value)
6875
}
6976

7077
guard let stringData = responseString.data(using: .utf8) else { continue }
71-
return FakeRequest(payload: .data(stringData), responseType: fakeRequest.responseType, headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay)
78+
return FakeRequest(
79+
payload: .data(stringData), responseType: fakeRequest.responseType,
80+
headerFields: fakeRequest.headerFields, statusCode: fakeRequest.statusCode, delay: fakeRequest.delay
81+
)
7282
}
7383
}
7484

Sources/Networking/HTTPInterceptor.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,14 @@ public struct RetryInterceptor: HTTPInterceptor {
9191
let exchange = try await next(request)
9292
let shouldRetry = methodAllowsRetry && retryableStatusCodes.contains(exchange.response.statusCode)
9393
guard shouldRetry, attempt < maxAttempts else { return exchange }
94-
let delay = Self.retryAfterDelay(from: exchange.response).map { Swift.min($0, maxDelay) }
94+
let delay =
95+
Self.retryAfterDelay(from: exchange.response).map { Swift.min($0, maxDelay) }
9596
?? backoffDelay(forAttempt: attempt)
9697
try await Task.sleep(for: delay)
9798
attempt += 1
98-
} catch let error as URLError where attempt < maxAttempts && methodAllowsRetry && NetworkingError.isRetryableTransport(error) {
99+
} catch let error as URLError
100+
where attempt < maxAttempts && methodAllowsRetry && NetworkingError.isRetryableTransport(error)
101+
{
99102
try await Task.sleep(for: backoffDelay(forAttempt: attempt))
100103
attempt += 1
101104
}
@@ -112,7 +115,8 @@ public struct RetryInterceptor: HTTPInterceptor {
112115
// Retry-After is either a count of seconds or an HTTP-date (RFC 9110).
113116
static func retryAfterDelay(from response: HTTPURLResponse) -> Duration? {
114117
guard let value = response.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespaces),
115-
!value.isEmpty else { return nil }
118+
!value.isEmpty
119+
else { return nil }
116120
if let seconds = Int(value) {
117121
return .seconds(max(0, seconds))
118122
}
@@ -149,8 +153,9 @@ public struct ResponseValidatorInterceptor: HTTPInterceptor {
149153
switch validate(exchange) {
150154
case .valid:
151155
return exchange
152-
case let .invalid(reason):
153-
throw NetworkingError.validation(reason: reason, ResponseMetadata(response: exchange.response, body: exchange.data))
156+
case .invalid(let reason):
157+
throw NetworkingError.validation(
158+
reason: reason, ResponseMetadata(response: exchange.response, body: exchange.data))
154159
}
155160
}
156161
}

0 commit comments

Comments
 (0)