diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb47a67..aa8a215 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,39 @@ 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 + # 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: + - 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/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 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..15cdb73 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 == [21] && !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.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.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") +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)