From 924dd8d6a70f0eaf2103d787984f803283eb05fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:10:43 +0000 Subject: [PATCH 1/5] ci: one pinned Xcode, macOS only, every product in the consumer check CI ran macos-15 + Xcode 26.0 and Docs macos-latest + the newest Xcode; both now run macos-26 with Xcode 26.6 via DEVELOPER_DIR, as the apps do. Package.swift drops the iOS 18 claim: FormatoCore's GuardedXML uses Foundation's macOS-only XMLDocument, so the package never built for iOS (#27); the README and the HDF5 article say macOS only. The consumer check now imports and reads through every product (adds FormatoEMPAD, FormatoMSA, FormatoMIB, Formato, FormatoPeek). A new xcframework-url job resolves the libhdf5 release zips by URL and checksum and builds FormatoHDF5 against them. Docs grants the pages permissions to the deploy job only. Fixes mac4DSTEM/FormatoKit#27 Part of mac4DSTEM/FormatoKit#12 Done: one toolchain, macOS only, consumer check over every product, URL resolution in CI. Next: a Mac session rebuilds the xcframework zips without AppleDouble entries and checks one archived app with codesign --strict. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWRjvbRGe75ep8jhBn --- .github/workflows/ci.yml | 28 ++++++++--- .github/workflows/docs.yml | 18 ++++--- Package.swift | 5 +- README.md | 7 ++- .../The bundled HDF5 library.md | 2 +- tools/consumer-check/Package.swift | 3 ++ .../Sources/consumer-check/main.swift | 49 +++++++++++++++++++ 7 files changed, 91 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb47a67..9534488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ # FormatoKit CI: the gate (tools/test.sh = `swift test -c release`, warning-checked) and the consumer check. # -# Written 2026-10-15 (0.0.3); enabling Actions on the repository is the owner's. The package declares macOS 15 / iOS 18 and -# tools 6.2 (0.2.1): the macos-15 image with Xcode 26.0 pinned below (Swift 6.2). The step fails if Swift is older than 6.2. +# The package declares macOS 15 and tools 6.2; the macos-26 image with Xcode 26.6 (Swift 6.3) is pinned below, as in docs.yml. +# The gate and the consumer check use the locally built libhdf5 XCFrameworks (FORMATO_LOCAL_XCFRAMEWORKS); the `xcframework-url` +# job resolves the release zips by URL and checksum, as a consumer does. # No libhdf5 step: FormatoHDF5 ships it. No References/: without the RosettaSciIO clone the pins tests run on the committed # copies of the pins and fixtures (tools/test.sh); the oracles are rerun locally, never in CI. # tools/step-1/names.sh (the 121 moved names) stays a local check: it reads the original package's tests from a sibling checkout (see the script). @@ -19,22 +20,37 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + # Pinned like the apps: the macos-26 image's Xcode 26.6 (Swift 6.3, macOS 26 SDK). Bump on purpose. + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + jobs: test: - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 90 steps: - uses: actions/checkout@v7 - - name: Select Xcode 26.0 (Swift 6.2) + - name: Toolchain on record run: | - sudo xcode-select -s /Applications/Xcode_26.0.app + xcode-select -p swift --version - swift --version | grep -E "Swift version 6\.([2-9]|[1-9][0-9])" || { echo "Swift 6.2 or newer needed"; exit 1; } - name: Gate (tools/test.sh, release) run: tools/test.sh - name: Consumer check (public API only, Debug, no warnings, libhdf5 from the package) run: tools/consumer-check/run.sh + xcframework-url: + name: libhdf5 XCFrameworks resolve by URL + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Resolve the release zips (checksums from Package.swift) and build FormatoHDF5 against them + run: | + unset FORMATO_LOCAL_XCFRAMEWORKS + swift package resolve + swift build -c release --product FormatoHDF5 + version: name: CHANGELOG version matches the tag runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 99d1cf4..80e6c79 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ # published to GitHub Pages at https://mac4dstem.github.io/FormatoKit/documentation/ on every tag. # On a pull request the site is built and uploaded as an artifact only, so a broken catalog fails before the tag. # -# No swift-docc-plugin: the kit takes no package dependencies. `xcodebuild docbuild` builds the archives +# No swift-docc-plugin: the kit adds no package dependency for its docs. `xcodebuild docbuild` builds the archives # from the package's own schemes; `docc merge` (Xcode 16+) joins them. Needs Pages enabled with source "GitHub Actions" # (repository Settings > Pages). Copied from CuboKit's pattern; the module list is read from the .docc catalogs. name: Docs @@ -16,23 +16,24 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: docs-${{ github.ref }} cancel-in-progress: true +env: + # Pinned like the apps: the macos-26 image's Xcode 26.6 (Swift 6.3, macOS 26 SDK). Bump on purpose. + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + jobs: build: - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 60 steps: - uses: actions/checkout@v7 - - name: Select the newest Xcode + - name: Toolchain on record run: | - xcode=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) - sudo xcode-select -s "$xcode" + xcode-select -p xcodebuild -version - name: Build the site run: tools/build-docs.sh .docs-site /FormatoKit @@ -44,6 +45,9 @@ jobs: needs: build if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/Package.swift b/Package.swift index 913c2c0..85cf2bd 100644 --- a/Package.swift +++ b/Package.swift @@ -12,8 +12,7 @@ // so an app that embeds the kit gets them embedded and signed with its own identity at archive time. The readers still dlopen libhdf5. // Built by tools/xcframework/build.sh; the release assets are the zips it writes (the checksums below are its checksums.txt). // With FORMATO_LOCAL_XCFRAMEWORKS set (tools/test.sh, CI and the consumer check set it) the targets are the local build in dist/xcframeworks. -// macOS 15 / iOS 18 (0.2.1). FormatoHDF5 and FormatoEMD load libhdf5 at run time and are macOS-only in practice: on iOS they -// build (the binary targets are macOS-only and conditional), but a Velox read refuses (no libhdf5 for iOS). The other modules are pure Swift. +// macOS 15 and later only: FormatoCore's guarded XML uses Foundation's macOS-only `XMLDocument`, and libhdf5 ships for macOS. import PackageDescription let settings: [SwiftSetting] = [ @@ -41,7 +40,7 @@ let binaryTargets: [Target] = xcframeworks.map { framework in let package = Package( name: "FormatoKit", - platforms: [.macOS(.v15), .iOS(.v18)], + platforms: [.macOS(.v15)], products: [ .library(name: "FormatoCore", targets: ["FormatoCore"]), .library(name: "FormatoDM", targets: ["FormatoDM"]), diff --git a/README.md b/README.md index bdb7d67..4a1b25a 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,9 @@ attributes to the file, root or BraggVectors group. ## Requirements -macOS 15 or later on Apple silicon, iOS 18 or later, Swift 6.2 tools (Xcode 26 or later). The pure-Swift modules (FormatoCore, FormatoDM, -FormatoTIFF, FormatoMRC/EMPAD/MSA) build for iOS 18. FormatoHDF5 and FormatoEMD load the shipped arm64 macOS libhdf5 at run time: they -are macOS-only in practice (on iOS they compile, and a Velox read refuses). The shipped binaries are arm64 only: an Intel -Mac cannot load them. +macOS 15 or later on Apple silicon, Swift 6.2 tools (Xcode 26 or later). iOS is not supported: FormatoCore uses Foundation's +macOS-only `XMLDocument`, and FormatoHDF5 and FormatoEMD load the shipped arm64 macOS libhdf5 at run time. The shipped binaries are +arm64 only: an Intel Mac cannot load them. **libhdf5 is inside the package** (HDF5 2.1.1 with libsz and libaec, arm64, as three binary-target XCFrameworks that Xcode embeds and signs in an app), so `swift build` and `swift test` need nothing installed. The Velox reader looks for libhdf5 in this order: the path in the `FORMATO_HDF5_PATH` environment variable, the host app's `Frameworks` folder, the shipped copy (the `FormatoLibHDF5` framework), then the loader's path. diff --git a/Sources/FormatoHDF5/FormatoHDF5.docc/The bundled HDF5 library.md b/Sources/FormatoHDF5/FormatoHDF5.docc/The bundled HDF5 library.md index 4ffc06d..7622e50 100644 --- a/Sources/FormatoHDF5/FormatoHDF5.docc/The bundled HDF5 library.md +++ b/Sources/FormatoHDF5/FormatoHDF5.docc/The bundled HDF5 library.md @@ -14,7 +14,7 @@ The Velox reader looks for libhdf5 in this order, and uses the first copy it can 4. The copy that ships with FormatoKit, given by ``FormatoHDF5Library/bundledLibraryPath``: the `FormatoLibHDF5` framework the process has loaded, or one in the host's `Frameworks` folder or beside the executable. 5. The loader's own search path. -The libraries are macOS-only (arm64, macOS 15 or later); on iOS the package builds and a read refuses. Set `FORMATO_HDF5_PATH` in the process environment before the process starts reading files. A host that embeds its own copy and leaves out the framework gets nil from ``FormatoHDF5Library/bundledLibraryPath``, not a crash, and the search goes on to the next candidate. +The libraries are macOS-only (arm64, macOS 15 or later), like the package. Set `FORMATO_HDF5_PATH` in the process environment before the process starts reading files. A host that embeds its own copy and leaves out the framework gets nil from ``FormatoHDF5Library/bundledLibraryPath``, not a crash, and the search goes on to the next candidate. ```swift import FormatoHDF5 diff --git a/tools/consumer-check/Package.swift b/tools/consumer-check/Package.swift index 5a662fb..5d78663 100644 --- a/tools/consumer-check/Package.swift +++ b/tools/consumer-check/Package.swift @@ -23,6 +23,9 @@ let package = Package( .product(name: "FormatoMRC", package: formatoKit), .product(name: "FormatoEMPAD", package: formatoKit), .product(name: "FormatoMSA", package: formatoKit), + .product(name: "FormatoMIB", package: formatoKit), + .product(name: "Formato", package: formatoKit), + .product(name: "FormatoPeek", package: formatoKit), ]), ], swiftLanguageModes: [.v6] diff --git a/tools/consumer-check/Sources/consumer-check/main.swift b/tools/consumer-check/Sources/consumer-check/main.swift index 6752f0b..6b3531c 100644 --- a/tools/consumer-check/Sources/consumer-check/main.swift +++ b/tools/consumer-check/Sources/consumer-check/main.swift @@ -1,11 +1,16 @@ // The consumer check's body: public API only, against the committed fixtures. Prints one line per failed check and a summary // `consumer-check summary: N checks, F failed`; exit 1 on any failure. import Foundation +import Formato import FormatoCore import FormatoDM import FormatoEMD +import FormatoEMPAD import FormatoHDF5 +import FormatoMIB import FormatoMRC +import FormatoMSA +import FormatoPeek import FormatoTIFF func need(_ value: T?) throws -> T { @@ -181,5 +186,49 @@ do { check(FormatoUnits.convertScaleUnits(1.5e-9, units: "m", factor: 100)?.units == "nm", "FormatoUnits") check(FormatoUnits.parse("µm")?.shortForm == "μm", "units parse") +// Every other product (MSA, EMPAD, MIB, the Formato loader, FormatoPeek): one public read each, on the committed fixtures. +func inflate(_ gz: URL, to destination: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") + process.arguments = ["-dc", gz.path] + let pipe = Pipe(); process.standardOutput = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw FormatoReadError.unsupported("gzip -dc \(gz.lastPathComponent) failed") } + try data.write(to: destination) +} +let msa = tests.appendingPathComponent("FormatoMSATests/Fixtures/public/example1.msa").path +do { + let spectrum = try MSAReader.read(path: msa) + check(spectrum.signal.data.shape == [20] && !spectrum.parameters.isEmpty, "MSAReader.read") +} catch { check(false, "MSAReader: \(error)") } +do { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("formato-consumer-4d-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let empad = tests.appendingPathComponent("FormatoEMPADTests/Fixtures") + try inflate(empad.appendingPathComponent("scan_x3_y2.raw.gz"), to: dir.appendingPathComponent("scan_x3_y2.raw")) + try FileManager.default.copyItem(at: empad.appendingPathComponent("modern.xml"), to: dir.appendingPathComponent("modern.xml")) + check(try EMPADReader(path: dir.appendingPathComponent("modern.xml").path).signal.shape == [2, 3, 128, 128], "EMPADReader") + let mib = tests.appendingPathComponent("FormatoMIBTests/Fixtures") + try inflate(mib.appendingPathComponent("processed12-trigger.mib.gz"), to: dir.appendingPathComponent("processed12-trigger.mib")) + try FileManager.default.copyItem(at: mib.appendingPathComponent("processed12-trigger.hdr"), + to: dir.appendingPathComponent("processed12-trigger.hdr")) + let merlin = try MIBReader(path: dir.appendingPathComponent("processed12-trigger.mib").path) + check(merlin.pixelCode == "U16" && merlin.signal.shape == [2, 3, 3, 4], "MIBReader") + check(FormatoLoader.sniff(path: dir.appendingPathComponent("processed12-trigger.mib").path) == .merlinMIB, "FormatoLoader.sniff MIB") +} catch { check(false, "EMPAD and MIB: \(error)") } +check(FormatoLoader.sniff(path: velox) == .veloxEMD && FormatoLoader.sniff(path: msa) == .msa, "FormatoLoader.sniff") +do { + let signals = try FormatoLoader.load(path: planted) + check(!signals.isEmpty && signals.contains { (try? $0.get()) != nil }, "FormatoLoader.load") +} catch { check(false, "FormatoLoader.load: \(error)") } +do { + let frame = try Peek.thumbnailFrame(url: URL(fileURLWithPath: planted), maxPixels: 64) + check(frame.width > 0 && frame.height > 0 && frame.pixels.count == frame.width * frame.height, "Peek.thumbnailFrame") + check(try Peek.summary(url: URL(fileURLWithPath: planted)).format.isEmpty == false, "Peek.summary") +} catch { check(false, "FormatoPeek: \(error)") } + print("consumer-check summary: \(checks) checks, \(failed) failed") exit(failed == 0 ? 0 : 1) From b6a00abfdee624b596e23cec7e016cb782ad83db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 16:28:31 +0000 Subject: [PATCH 2/5] ci: run the extra macOS builds after merge, not on every pull request The free plan's macOS runners are scarce. The required test job keeps its name and still runs on every pull request; the added consumer, iOS and URL-resolution builds move to a job that runs on push, tag and by hand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWRjvbRGe75ep8jhBn --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9534488..aa8a215 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: xcframework-url: name: libhdf5 XCFrameworks resolve by URL + # Not on pull requests: the free plan's macOS runners are scarce, so this runs on main, tags and by hand. + if: github.event_name != 'pull_request' runs-on: macos-26 timeout-minutes: 30 steps: From f4b0cdfcfd54b8dd30f8ca6f165cf0806d3abf05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 17:43:57 +0000 Subject: [PATCH 3/5] test: give two #expect closures explicit types for Xcode 26.6 Xcode 26.6's type checker gives up on `#expect(x.doubles() == (0..<12).map { Double($0 * k + c) })` ("unable to type-check this expression in reasonable time"), which Xcode 26.0 accepted. Explicit closure types; the expected values are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWRjvbRGe75ep8jhBn --- Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift | 2 +- Tests/FormatoHDF5Tests/HDF5AppendTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift b/Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift index 96ac14d..8a98794 100644 --- a/Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift +++ b/Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift @@ -125,7 +125,7 @@ extension VeloxEMDSerialized { let single = try read("sp-single", zone: "UTC") #expect(single.count == 1 && single[0].title == "EDS" && single[0].signalType == "EDS_TEM") #expect(single[0].data.dataType == .uint32 && single[0].data.shape == [12]) - #expect(single[0].data.doubles() == (0..<12).map { Double($0 * 7 + 1) }) + #expect(single[0].data.doubles() == (0..<12).map { (i: Int) -> Double in Double(i * 7 + 1) }) #expect(single[0].axes == [FormatoAxis(name: "Energy", size: 12, scale: 0.005, offset: -0.48, units: "keV", navigate: false)]) // 1490107072.25 s = 2017-03-21 14:37:52.25 UTC. #expect(single[0].metadata["General.date"]?.value.text == "2017-03-21") diff --git a/Tests/FormatoHDF5Tests/HDF5AppendTests.swift b/Tests/FormatoHDF5Tests/HDF5AppendTests.swift index 7d9b431..c3b981e 100644 --- a/Tests/FormatoHDF5Tests/HDF5AppendTests.swift +++ b/Tests/FormatoHDF5Tests/HDF5AppendTests.swift @@ -253,7 +253,7 @@ struct Model { let reader = try HDF5Reader(url: Self.fixture("base.h5")) #expect(try reader.children(of: "/") == ["empty", "grp1", "keep", "orphan", "strs"]) #expect(try reader.attribute("version", of: "/") == .integer(3)) - #expect(try reader.readDataset("/grp1/data").doubles() == (0..<12).map { Double($0 * 11 - 5) }) + #expect(try reader.readDataset("/grp1/data").doubles() == (0..<12).map { (i: Int) -> Double in Double(i * 11 - 5) }) } // MARK: Atomicity From a6928211fbc787af1b46b619d0d34bb68ec79c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:18:35 +0000 Subject: [PATCH 4/5] test: read the lazy signals' shape through their array in the consumer check FormatoLazySignal has no shape of its own; its array does. Fixes the build of the EMPAD and MIB checks added in this PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWRjvbRGe75ep8jhBn --- tools/consumer-check/Sources/consumer-check/main.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/consumer-check/Sources/consumer-check/main.swift b/tools/consumer-check/Sources/consumer-check/main.swift index 6b3531c..14a6831 100644 --- a/tools/consumer-check/Sources/consumer-check/main.swift +++ b/tools/consumer-check/Sources/consumer-check/main.swift @@ -210,13 +210,13 @@ do { let empad = tests.appendingPathComponent("FormatoEMPADTests/Fixtures") try inflate(empad.appendingPathComponent("scan_x3_y2.raw.gz"), to: dir.appendingPathComponent("scan_x3_y2.raw")) try FileManager.default.copyItem(at: empad.appendingPathComponent("modern.xml"), to: dir.appendingPathComponent("modern.xml")) - check(try EMPADReader(path: dir.appendingPathComponent("modern.xml").path).signal.shape == [2, 3, 128, 128], "EMPADReader") + check(try EMPADReader(path: dir.appendingPathComponent("modern.xml").path).signal.array.shape == [2, 3, 128, 128], "EMPADReader") let mib = tests.appendingPathComponent("FormatoMIBTests/Fixtures") try inflate(mib.appendingPathComponent("processed12-trigger.mib.gz"), to: dir.appendingPathComponent("processed12-trigger.mib")) try FileManager.default.copyItem(at: mib.appendingPathComponent("processed12-trigger.hdr"), to: dir.appendingPathComponent("processed12-trigger.hdr")) let merlin = try MIBReader(path: dir.appendingPathComponent("processed12-trigger.mib").path) - check(merlin.pixelCode == "U16" && merlin.signal.shape == [2, 3, 3, 4], "MIBReader") + check(merlin.pixelCode == "U16" && merlin.signal.array.shape == [2, 3, 3, 4], "MIBReader") check(FormatoLoader.sniff(path: dir.appendingPathComponent("processed12-trigger.mib").path) == .merlinMIB, "FormatoLoader.sniff MIB") } catch { check(false, "EMPAD and MIB: \(error)") } check(FormatoLoader.sniff(path: velox) == .veloxEMD && FormatoLoader.sniff(path: msa) == .msa, "FormatoLoader.sniff") From a79b888c67ad3ef95c58e365c91fd0fb77f61523 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:34:45 +0000 Subject: [PATCH 5/5] test: expect example1.msa's 21 points in the consumer check The file says NPOINTS 20 but carries 21 data rows; the reader, like RosettaSciIO (msa-pins.json: size 21), keeps all 21. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VkKZEWRjvbRGe75ep8jhBn --- tools/consumer-check/Sources/consumer-check/main.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/consumer-check/Sources/consumer-check/main.swift b/tools/consumer-check/Sources/consumer-check/main.swift index 14a6831..15cdb73 100644 --- a/tools/consumer-check/Sources/consumer-check/main.swift +++ b/tools/consumer-check/Sources/consumer-check/main.swift @@ -201,7 +201,7 @@ func inflate(_ gz: URL, to destination: URL) throws { let msa = tests.appendingPathComponent("FormatoMSATests/Fixtures/public/example1.msa").path do { let spectrum = try MSAReader.read(path: msa) - check(spectrum.signal.data.shape == [20] && !spectrum.parameters.isEmpty, "MSAReader.read") + check(spectrum.signal.data.shape == [21] && !spectrum.parameters.isEmpty, "MSAReader.read") } catch { check(false, "MSAReader: \(error)") } do { let dir = FileManager.default.temporaryDirectory.appendingPathComponent("formato-consumer-4d-\(UUID().uuidString)")