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
30 changes: 24 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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
Expand Down
18 changes: 11 additions & 7 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }}
Expand Down
5 changes: 2 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down Expand Up @@ -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"]),
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Tests/FormatoEMDTests/VeloxSpectrumSignalsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion Tests/FormatoHDF5Tests/HDF5AppendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tools/consumer-check/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions tools/consumer-check/Sources/consumer-check/main.swift
Original file line number Diff line number Diff line change
@@ -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<T>(_ value: T?) throws -> T {
Expand Down Expand Up @@ -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)
Loading