Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 47 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,48 @@ jobs:
# ── iOS ────────────────────────────────────────────────────────────────────
ios:
name: iOS ${{ matrix.ios }} / Xcode ${{ matrix.xcode }}
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 30
strategy:
matrix:
include:
- xcode: "16.3"
ios: "18.3"
simulator: "iPhone 16"
- xcode: "27.1"
ios: "27.0"
simulator: "iPhone 18 Pro"

steps:
- uses: actions/checkout@v4

- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
run: |
sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
xcodebuild -version

- name: Build & Test (iOS)
# pipefail is not set by default, so a failing test would be hidden by a pipe.
# xcpretty is dropped for the same reason: a missing tool fails the job.
run: |
set -o pipefail
xcodebuild test \
-scheme Nexio \
-destination "platform=iOS Simulator,name=${{ matrix.simulator }},OS=${{ matrix.ios }}" \
-enableCodeCoverage YES \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
| xcpretty
CODE_SIGNING_REQUIRED=NO

# ── macOS ──────────────────────────────────────────────────────────────────
macos:
name: macOS / Swift Package
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 20

steps:
- uses: actions/checkout@v4

- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Select Xcode 27.1
run: |
sudo xcode-select -s /Applications/Xcode_27.1.app
xcodebuild -version

- name: Build
run: swift build -c release
Expand All @@ -57,33 +65,52 @@ jobs:

- name: Generate coverage report
run: |
set -euo pipefail
test_binary=$(find .build -type f \
-path '*/NexioTests.xctest/Contents/MacOS/NexioTests' \
-print -quit)
profile=$(find .build -type f \
-path '*/codecov/default.profdata' \
-print -quit)
if [ -z "$test_binary" ]; then
echo "Could not find the NexioTests executable under .build." >&2
exit 1
fi
if [ -z "$profile" ]; then
echo "Could not find the Swift code coverage profile under .build." >&2
exit 1
fi
xcrun llvm-cov export \
.build/debug/NexioPackageTests.xctest/Contents/MacOS/NexioPackageTests \
-instr-profile .build/debug/codecov/default.profdata \
"$test_binary" \
-instr-profile "$profile" \
-ignore-filename-regex ".build|Tests" \
-format lcov > coverage.lcov
test -s coverage.lcov # fail if the export produced nothing

- name: Upload to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.lcov
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

# ── Swift 6 strict concurrency ────────────────────────────────────────────
swift6:
name: Swift 6 strict concurrency check
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 20

steps:
- uses: actions/checkout@v4

- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Select Xcode 27.1
run: |
sudo xcode-select -s /Applications/Xcode_27.1.app
xcodebuild -version

- name: Build with strict concurrency
- name: Build library and tests with strict concurrency
# --build-tests compiles the test target too, so concurrency errors in tests fail CI.
run: |
swift build \
swift build --build-tests \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
10 changes: 2 additions & 8 deletions .github/workflows/danger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
jobs:
danger:
name: Danger Swift
runs-on: macos-15
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
Expand All @@ -17,13 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app

- name: Install Danger Swift
run: brew install danger/tap/danger-swift

- name: Run Danger
run: danger-swift ci
uses: danger/swift@fcb64d7a1bbdb1f3f6ab28a5b48eda751a29f04b # Node 24 fix
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 changes: 46 additions & 0 deletions .github/workflows/pr-agent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: PR Agent

on:
pull_request:
branches: [main]
types: [opened, reopened, ready_for_review, synchronize]

permissions:
contents: read
issues: write
pull-requests: write

concurrency:
group: pr-agent-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
review:
name: AI pull request review
runs-on: ubuntu-latest
if: github.event.sender.type != 'Bot'
steps:
- name: Check for the model API key
id: api-key
env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
run: |
if [ -n "$OPENAI_KEY" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "PR-Agent skipped: configure the OPENAI_KEY Actions secret to enable reviews."
fi

- name: Review pull request with PR-Agent
if: steps.api-key.outputs.configured == 'true'
uses: the-pr-agent/pr-agent@v0.47.0
env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
github_action_config.auto_review: "true"
github_action_config.auto_describe: "false"
github_action_config.auto_improve: "false"
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review"]'
github_action_config.handle_push_trigger: "true"
github_action_config.push_commands: '["/review"]'
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ Nexio uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

No public signatures changed. The entries below are behavior fixes and internal changes.

### Fixed
- HTTP 5xx and 429 responses are now retried when a retry policy is configured. Previously they were never retried.
- Per-endpoint `auth` is no longer overwritten by a registered `AuthInterceptor`. `.some(.none)` sends no token.
- Calling a refresh endpoint from inside an `AuthInterceptor` provider no longer re-enters the same provider.
- POST and PATCH are no longer retried by default. Only idempotent methods retry.
- Cancelling a request stops retries and throws `CancellationError`, instead of retrying or reporting a failure.
- `NexioImage` no longer shows the failure image when a load is cancelled because the view moved on.
- `RetryPolicy` backoff is capped at 60 seconds, which prevents an integer overflow trap on large exponential delays.
- `configure(_:)` can now disable retry. Previously the retry setting stuck once enabled.
- `configure(_:)` invalidates the previous `URLSession` instead of leaking it.
- Retry is capped at 10 retries per request, so a custom interceptor can't retry indefinitely.

### Changed
- `NexioConfig.retry` is now held by the client and applied inside the request pipeline. It no longer inserts a `RetryInterceptor` into the interceptor list.
- JSON decoding happens off the `NexioClient` actor.
- Image decoding in `ImageLoader` happens off the actor.
- `ImageLoader.prefetch(_:)` fetches at most 4 images at a time.
- `URLError.cancelled` surfaces as `CancellationError`, not `NexioError.unknown`.

## [1.0.0] - 2026-06-07

### Added
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ docs/short-description
- [ ] `CHANGELOG.md` updated under `[Unreleased]`
- [ ] PR description explains *what* changed and *why*

## Automated PR reviews

This repository can run [PR-Agent](https://github.com/The-PR-Agent/pr-agent) on pull requests targeting `main`. To enable reviews, add an `OPENAI_KEY` repository secret under **Settings → Secrets and variables → Actions**. PR-Agent reviews newly opened and updated pull requests and posts its feedback to the PR. The workflow skips reviews when the secret is not configured and cannot access repository secrets for pull requests from forks.

PR-Agent sends pull request content to the configured model provider. Model API usage may incur charges; review the provider's data and pricing terms before enabling it. The workflow requests only read access to repository contents and write access to pull requests and issues so it can post review feedback.

## Reporting Issues

Use GitHub Issues. For security vulnerabilities, see [SECURITY.md](SECURITY.md).
12 changes: 7 additions & 5 deletions Dangerfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ if additions + deletions > bigPRThreshold {
warn("PR is large (\(additions + deletions) lines). Consider splitting.")
}

// Require CHANGELOG update for non-trivial PRs
// Require CHANGELOG update for non-trivial PRs.
// Trivial means the title starts with a listed prefix, not that it contains one.
let title = danger.github.pullRequest.title
let trivialPrefixes = ["[trivial]", "docs:", "chore:"]
let isTrivial = trivialPrefixes.contains { title.hasPrefix($0) }

let hasChangelogUpdate = danger.git.modifiedFiles.contains("CHANGELOG.md")
|| danger.git.createdFiles.contains("CHANGELOG.md")
let isTrivial = danger.github.pullRequest.title.contains("[trivial]")
|| danger.github.pullRequest.title.contains("docs:")
|| danger.github.pullRequest.title.contains("chore:")
if !hasChangelogUpdate && !isTrivial {
warn("No `CHANGELOG.md` update. Add an entry under `[Unreleased]` or prefix title with `chore:`/`docs:` to skip.")
}

// Require PR description
let bodyLength = danger.github.pullRequest.body?.count ?? 0
let bodyLength = danger.github.pullRequest.body?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0
if bodyLength < 20 {
fail("PR description is too short. Explain what changed and why.")
}
Expand Down
4 changes: 2 additions & 2 deletions Example/NexioExample.swiftpm/AppModule/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ struct ContentView: View {
config.logLevel = .all
await NexioClient.shared.configure(config)

// NOTE: setting `config.retry` alone does not enable retries yet —
// register the interceptor explicitly to see retry behaviour in action.
// Register the interceptor explicitly here to demonstrate custom retry
// callbacks. The built-in config.retry policy also works independently.
await NexioClient.shared.addInterceptor(RetryInterceptor(policy: .standard))
await NexioClient.shared.addInterceptor(ConsoleLogInterceptor())
await NexioClient.shared.addInterceptor(HeaderCaptureInterceptor())
Expand Down
2 changes: 1 addition & 1 deletion Example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ AppModule/

Two SDK gaps were worked around here rather than papered over silently:

- **`config.retry` is inert** (`NexioConfig.retry` is stored but never wired to a `RetryInterceptor`) — `ContentView.configureNexio()` registers `RetryInterceptor(policy: .standard)` explicitly, with a `// NOTE:` comment explaining why.
- **`config.retry`** is applied by the built-in request pipeline. `ContentView.configureNexio()` registers `RetryInterceptor(policy: .standard)` explicitly to demonstrate custom retry callbacks.
- **`Endpoint.auth: AuthStrategy?` `.none` ambiguity** (`.none` resolves to `Optional.none`/"inherit", not `AuthStrategy.none`/"skip") — `EndpointAuthOverrideEndpoint` uses `.apiKey(...)` instead, with a doc comment spelling out the footgun and the `.some(.none)` workaround.

`Support/Interceptors.swift` also shows the Swift 6–correct way to hand data from a `Sendable` `Interceptor` back to `@MainActor` view models (actor-backed stores) — the SDK's own `AuthTests.CapturingInterceptor` gets this wrong and fails to compile under strict concurrency.
Loading
Loading