From af7b4bf7c62db7f1fc7b758779abd620374c6356 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 07:52:50 +0200 Subject: [PATCH 1/7] docs(skills): the credential-rejection teaching states the envelope, not the retired decode order The serverrequest generator's section and the review's no-defensive-error-for-credential-rejection check described WireError's trial decode, which 0.16.0 replaced with a typed envelope on both sides. Both now state the envelope. Plugin 2.67.0. --- .claude-plugin/plugin.json | 2 +- .../fosmvvm-review/checks/serverrequest.md | 2 +- .../fosmvvm-serverrequest-generator/SKILL.md | 31 ++++++++++--------- CHANGELOG.md | 7 +++++ 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 252039e..3c4544e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.66.0", + "version": "2.67.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/skills/fosmvvm-review/checks/serverrequest.md b/.claude/skills/fosmvvm-review/checks/serverrequest.md index 3f51220..f7a379c 100644 --- a/.claude/skills/fosmvvm-review/checks/serverrequest.md +++ b/.claude/skills/fosmvvm-review/checks/serverrequest.md @@ -71,7 +71,7 @@ public struct ResponseError: ServerRequestError { public let reason: String } ``` -**Detection:** Grep the `ResponseError` declarations and their documentation for reasoning about 401s, credential rejection, or `EmptyError` swallowing errors. Flag it: `WireError` decodes `CredentialRejectedError` strictly before the request's own error type, so the rejection is never reachable by the `ResponseError` and the defensive shape buys nothing. It also costs something — a permissive error decodes any abort body, so unrelated failures arrive wearing this operation's type. +**Detection:** Grep the `ResponseError` declarations and their documentation for reasoning about 401s, credential rejection, or `EmptyError` swallowing errors. Flag it: every error body crosses inside one typed envelope that names whether it carries the surface rejection or the request's own error (0.16.0; before that, the rejection was decoded strictly first), so the rejection is never reachable by the `ResponseError` and the defensive shape buys nothing. It also costs something — a permissive error decodes any abort body, so unrelated failures arrive wearing this operation's type. **Report this once for the whole area when the rationale has propagated**, listing every site in the body. A copied justification is one belief, not N defects, and filing it per request buries the fact that it spread. diff --git a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md index 9e85905..c04a770 100644 --- a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md +++ b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md @@ -165,29 +165,30 @@ A rejected credential reaches the client as `CredentialRejectedError`, a FOS-owned error, **regardless of what your `ResponseError` is**. Catch it; never branch on the 401. -This matters because of the decode order. `WireError` tries the well-known -surface errors *strictly before* the request's own `ResponseError`: +This holds because of the wire's shape, not a decode order. Every error body +crosses inside one typed envelope (0.16.0) — exactly one of the surface +rejection or the request's own `ResponseError` — encoded by the server's +`ErrorMiddleware` and decoded by the client: ```swift -package init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let rejection = try? container.decode(CredentialRejectedError.self) { - self = .surface(rejection) - } else { - self = try .response(container.decode(E.self)) - } +package enum WireError: Error, Codable { + case surface(CredentialRejectedError) + case response(E) } ``` -`CredentialRejectedError` wins that race unconditionally. So: +The envelope names which one it carries, so nothing is tried and nothing can +be mistaken for anything else — a permissive `ResponseError` (`EmptyError` +decodes from anything) cannot swallow a rejection, and a request error with a +field named `reason` cannot pun into one. So: - ✅ `EmptyError` is safe on a request behind a credential middleware. It cannot - swallow a rejection — the rejection is decoded first and never reaches it. + swallow a rejection — the envelope carries the rejection in its own case, and + `EmptyError` is only ever asked to decode the `response` case. - ❌ Do **not** add a permissive `String` field to a `ResponseError` "so a 401 - isn't swallowed." That was never a real risk, and the permissive field creates - a different one: an error that decodes anything will happily decode any - `Abort(reason:)` body as a successful match, so unrelated failures arrive - wearing your operation's error type. + isn't swallowed." That was never a real risk, and the field buys nothing: a + body that is not the envelope — Vapor's stock abort, a proxy's error page — + never reaches your error type at all; it falls to the status path. If your operation has no well-defined throw, use `EmptyError`. A required free-text `reason: String` is not a lighter-weight error — it is an error with diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2f5b1..a14ed92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Skill text teaches the envelope, not the decode order** — the serverrequest + generator's credential-rejection section and the review's + `no-defensive-error-for-credential-rejection` check both described the retired + trial-decode; both now state the typed envelope 0.16.0 ships. Plugin 2.67.0. + ## [0.16.0] - 2026-09-03 ### Added From b01ae5428a31282c8d835d25a515ca52e201324f Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 08:31:26 +0200 Subject: [PATCH 2/7] style: SwiftFormat 0.63.0 re-indents modifiers inside #if blocks --- .../SwiftUI Support/FormFieldView.swift | 46 +++++++++---------- .../FOSMVVM/SwiftUI Support/TestHost.swift | 10 ++-- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift b/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift index de2feb4..4101f56 100644 --- a/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift +++ b/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift @@ -276,7 +276,7 @@ private extension FormFieldView where Value == String { } .textContentType(.password) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(.never) + .textInputAutocapitalization(.never) #endif case .text, .location, .fullStreetAddress, .streetAddressLine1, .streetAddressLine2: @@ -293,10 +293,10 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences + ) + .textContentType(fieldModel.formField.textContentType) #endif case .name, .namePrefix, .givenName, .middleName, .familyName, .nameSuffix, .nickname, @@ -314,10 +314,10 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words + ) + .textContentType(fieldModel.formField.textContentType) #endif case .postalCode, .creditCardNumber, .oneTimeCode: @@ -334,8 +334,8 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never) + .textContentType(fieldModel.formField.textContentType) #endif case .telephoneNumber: @@ -352,7 +352,7 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.phonePad) + .keyboardType(.phonePad) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -372,7 +372,7 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(true) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.emailAddress) + .keyboardType(.emailAddress) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -408,8 +408,8 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences) + .textContentType(fieldModel.formField.textContentType) #endif case .name, .namePrefix, .givenName, .middleName, .familyName, .nameSuffix, .nickname, @@ -427,8 +427,8 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words) + .textContentType(fieldModel.formField.textContentType) #endif case .postalCode, .creditCardNumber, .oneTimeCode: @@ -445,10 +445,10 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never + ) + .textContentType(fieldModel.formField.textContentType) #endif case .telephoneNumber: @@ -465,7 +465,7 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.phonePad) + .keyboardType(.phonePad) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -485,7 +485,7 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(true) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.emailAddress) + .keyboardType(.emailAddress) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) diff --git a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift index 85a5876..dc080e9 100644 --- a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift +++ b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift @@ -186,11 +186,11 @@ private struct TestingView: View { var body: some View { testView - #if os(iOS) - .onAppear { - DismissKeyboardWindow.install() - } - #endif + #if os(iOS) + .onAppear { + DismissKeyboardWindow.install() + } + #endif } init(baseView: BaseView) { From f84267c5b9e24e3b10d24d530eb68a71544b7af9 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 08:31:26 +0200 Subject: [PATCH 3/7] ci: pin SwiftFormat to a release artifact (0.63.0) instead of brew's latest --- .claude/CLAUDE.md | 2 ++ .github/workflows/ci.yml | 18 +++++++++++++++++- CHANGELOG.md | 4 ++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 7b8a186..b22bdc5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,6 +16,8 @@ swift test swift test --filter TestClassName # Format code (auto-adds Apache 2.0 license header) +# CI pins SwiftFormat (SWIFTFORMAT_VERSION in .github/workflows/ci.yml); keep the +# local install at that version — a newer one formats differently and CI will say so. swiftformat . # Lint code diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c7ad0..21ada46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,24 @@ jobs: steps: - uses: actions/checkout@v4 + # SwiftFormat is PINNED, not brewed: a new SwiftFormat release changes + # what "formatted" means (0.63.0 re-indented every modifier inside an + # `#if` block), and a brew-latest CI reds an unrelated PR the morning it + # ships. Bump SWIFTFORMAT_VERSION here and `brew upgrade swiftformat` + # locally together; `swiftformat .` then lands the new rules as their own + # commit. SwiftLint stays on brew until it bites. - name: Install linters - run: brew install swiftformat swiftlint + env: + SWIFTFORMAT_VERSION: 0.63.0 + run: | + brew install swiftlint + curl -sSL -o "$RUNNER_TEMP/swiftformat.zip" \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/$SWIFTFORMAT_VERSION/swiftformat.artifactbundle.zip" + unzip -qo "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/swiftformat" + echo "$RUNNER_TEMP/swiftformat/swiftformat.artifactbundle/swiftformat-$SWIFTFORMAT_VERSION-macos/bin" >> "$GITHUB_PATH" + + - name: SwiftFormat version + run: swiftformat --version # --lint reports without writing. `swiftformat .` is the fix. # fileHeader is excluded here, not in .swiftformat: its template year is diff --git a/CHANGELOG.md b/CHANGELOG.md index a14ed92..def28c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **CI pins SwiftFormat** — `SWIFTFORMAT_VERSION` in `ci.yml` (0.63.0) installs + the release artifact instead of brew's latest, so a new SwiftFormat release + cannot red an unrelated PR the morning it ships. The 0.63.0 re-indent of + modifiers inside `#if` blocks lands here as its own commit. - **Skill text teaches the envelope, not the decode order** — the serverrequest generator's credential-rejection section and the review's `no-defensive-error-for-credential-rejection` check both described the retired From 660681d4ba5596f4ba372de660531089d87f065e Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 08:36:14 +0200 Subject: [PATCH 4/7] Revert "ci: pin SwiftFormat to a release artifact (0.63.0) instead of brew's latest" This reverts commit f84267c5b9e24e3b10d24d530eb68a71544b7af9. --- .claude/CLAUDE.md | 2 -- .github/workflows/ci.yml | 18 +----------------- CHANGELOG.md | 4 ---- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b22bdc5..7b8a186 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,8 +16,6 @@ swift test swift test --filter TestClassName # Format code (auto-adds Apache 2.0 license header) -# CI pins SwiftFormat (SWIFTFORMAT_VERSION in .github/workflows/ci.yml); keep the -# local install at that version — a newer one formats differently and CI will say so. swiftformat . # Lint code diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ada46..88c7ad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,24 +16,8 @@ jobs: steps: - uses: actions/checkout@v4 - # SwiftFormat is PINNED, not brewed: a new SwiftFormat release changes - # what "formatted" means (0.63.0 re-indented every modifier inside an - # `#if` block), and a brew-latest CI reds an unrelated PR the morning it - # ships. Bump SWIFTFORMAT_VERSION here and `brew upgrade swiftformat` - # locally together; `swiftformat .` then lands the new rules as their own - # commit. SwiftLint stays on brew until it bites. - name: Install linters - env: - SWIFTFORMAT_VERSION: 0.63.0 - run: | - brew install swiftlint - curl -sSL -o "$RUNNER_TEMP/swiftformat.zip" \ - "https://github.com/nicklockwood/SwiftFormat/releases/download/$SWIFTFORMAT_VERSION/swiftformat.artifactbundle.zip" - unzip -qo "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/swiftformat" - echo "$RUNNER_TEMP/swiftformat/swiftformat.artifactbundle/swiftformat-$SWIFTFORMAT_VERSION-macos/bin" >> "$GITHUB_PATH" - - - name: SwiftFormat version - run: swiftformat --version + run: brew install swiftformat swiftlint # --lint reports without writing. `swiftformat .` is the fix. # fileHeader is excluded here, not in .swiftformat: its template year is diff --git a/CHANGELOG.md b/CHANGELOG.md index def28c6..a14ed92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **CI pins SwiftFormat** — `SWIFTFORMAT_VERSION` in `ci.yml` (0.63.0) installs - the release artifact instead of brew's latest, so a new SwiftFormat release - cannot red an unrelated PR the morning it ships. The 0.63.0 re-indent of - modifiers inside `#if` blocks lands here as its own commit. - **Skill text teaches the envelope, not the decode order** — the serverrequest generator's credential-rejection section and the review's `no-defensive-error-for-credential-rejection` check both described the retired From 6fe82554dc38535c5b67d3be7c756ab4b474431a Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 08:55:09 +0200 Subject: [PATCH 5/7] style(TestHost): no blank source lines inside the multiline message literals SwiftFormat on the CI runners flags every blank line inside a multiline string literal (indent + trailingSpace). Each blank line becomes an explicit \n on the preceding line; the rendered messages are unchanged. --- .../FOSMVVM/SwiftUI Support/TestHost.swift | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift index dc080e9..aaddd27 100644 --- a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift +++ b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift @@ -350,32 +350,25 @@ enum TestHostDiagnostic { return """ ================================================================================ - FOSMVVM testHost(): cannot present the view under test. - + FOSMVVM testHost(): cannot present the view under test.\n The test harness asked for the view whose ViewModel is: - \(viewModelType) - + \(viewModelType)\n Registered ViewModels: - \(registeredList) - - \(cause) - - To fix, register the *View* (not the ViewModel) from your App's init(): - + \(registeredList)\n + \(cause)\n + To fix, register the *View* (not the ViewModel) from your App's init():\n @main struct MyApp: App { init() { MVVMEnvironment.registerTestingViews() } - } - + }\n private extension MVVMEnvironment { @MainActor static func registerTestingViews() { #if DEBUG registerTestView(MyView.self) // where MyView.VM == \(viewModelType) #endif } - } - + }\n See the documentation for MVVMEnvironment.registerTestView(_:). ================================================================================ """ @@ -391,19 +384,15 @@ enum TestHostDiagnostic { static func undecodableViewModel(viewModelType: String, error: any Error) -> String { """ ================================================================================ - FOSMVVM testHost(): cannot decode the ViewModel for the view under test. - + FOSMVVM testHost(): cannot decode the ViewModel for the view under test.\n ViewModel type requested by the test harness: - \(viewModelType) - + \(viewModelType)\n Decoding error: - \(error) - + \(error)\n The view registered for '\(viewModelType)' has a different VM associated type than the payload the test sent, or that payload is not valid JSON for it. Check that the view passed to MVVMEnvironment.registerTestView(_:) is the one whose VM is '\(viewModelType)', and that - the test's ViewModel generic argument matches it. - + the test's ViewModel generic argument matches it.\n See the documentation for MVVMEnvironment.registerTestView(_:). ================================================================================ """ From 5444f2f14c5eaf5ed9ea89a5f184de5aeefaee0c Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 08:55:09 +0200 Subject: [PATCH 6/7] ci: brew upgrade the linters so a preinstalled older SwiftFormat is not used --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c7ad0..bac6e60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,13 @@ jobs: steps: - uses: actions/checkout@v4 + # `brew install` leaves a preinstalled older formula alone, and the + # macos-latest image pool has carried two SwiftFormat versions at once; + # `brew upgrade` makes latest mean latest on every runner. - name: Install linters - run: brew install swiftformat swiftlint + run: | + brew install swiftformat swiftlint + brew upgrade swiftformat swiftlint # --lint reports without writing. `swiftformat .` is the fix. # fileHeader is excluded here, not in .swiftformat: its template year is From efbbfc97b7802d8f4cca4684c9c78a3ae1312585 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Thu, 3 Sep 2026 09:08:17 +0200 Subject: [PATCH 7/7] ci: brew update before installing the linters, so upgrade sees the current index --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac6e60..791e4d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: # `brew upgrade` makes latest mean latest on every runner. - name: Install linters run: | + brew update brew install swiftformat swiftlint brew upgrade swiftformat swiftlint