name: Publish
on:
push:
tags: ["v*"]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
version:
name: Sync version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Update Cargo.toml versions
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" bindings/python/Cargo.toml
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" bindings/nodejs/Cargo.toml
sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" bindings/nodejs/package.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" bindings/python/pyproject.toml
sed -i "s/<version>.*<\/version>/<version>$VERSION<\/version>/" bindings/dotnet/SpeechMarkdown.nuspec
- uses: actions/upload-artifact@v4
with:
name: version-sync
path: |
Cargo.toml
bindings/python/Cargo.toml
bindings/python/pyproject.toml
bindings/nodejs/Cargo.toml
bindings/nodejs/package.json
bindings/dotnet/SpeechMarkdown.nuspec
test:
name: Test
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --all
- run: cargo test --test ffi_test
build-native:
name: Build native (${{ matrix.target }})
needs: test
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
os: windows-latest
lib: speechmarkdown_rust.dll
- target: aarch64-pc-windows-msvc
os: windows-latest
lib: speechmarkdown_rust.dll
- target: x86_64-apple-darwin
os: macos-latest
lib: libspeechmarkdown_rust.dylib
- target: aarch64-apple-darwin
os: macos-latest
lib: libspeechmarkdown_rust.dylib
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
lib: libspeechmarkdown_rust.so
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
lib: libspeechmarkdown_rust.so
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Configure aarch64-linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- run: cargo build --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: native-${{ matrix.target }}
path: target/${{ matrix.target }}/release/${{ matrix.lib }}
publish-crate:
name: crates.io
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
- run: cargo publish --allow-dirty
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
build-wheels:
name: Python wheel (${{ matrix.target }})
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Copy README for PyPI
run: cp README.md bindings/python/README.md
- name: Build wheel
uses: PyO3/maturin-action@v1
with:
command: build
target: ${{ matrix.target }}
args: --release --out dist -i python3.13
working-directory: bindings/python
sccache: "false"
manylinux: auto
- name: Build sdist
if: matrix.target == 'x86_64-unknown-linux-gnu'
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist-sdist
working-directory: bindings/python
sccache: "false"
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.target }}
path: |
bindings/python/dist/*
bindings/python/dist-sdist/*
publish-pypi:
name: PyPI
needs: build-wheels
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
path: wheels-staging
pattern: wheels-*
merge-multiple: true
- name: Collect wheels
run: mkdir -p dist && find wheels-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec mv {} dist/ \;
- uses: pypa/gh-action-pypi-publish@release/v1
build-npm:
name: Build npm (${{ matrix.target }})
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-latest
target: aarch64-pc-windows-msvc
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
permissions:
contents: read
defaults:
run:
working-directory: bindings/nodejs
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Install cross-compilation tools (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Install musl tools
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- run: npm install @napi-rs/cli
- run: npx napi build --platform --release --target ${{ matrix.target }}
- uses: actions/upload-artifact@v4
with:
name: npm-${{ matrix.target }}
path: bindings/nodejs/*.node
publish-npm:
name: npm
needs: build-npm
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: bindings/nodejs
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- uses: actions/download-artifact@v4
with:
path: ${{ github.workspace }}/npm-artifacts
pattern: npm-*
merge-multiple: true
- name: Collect .node binaries
run: find ${{ github.workspace }}/npm-artifacts -name '*.node' -exec cp {} . \;
- name: Copy README
run: cp ${{ github.workspace }}/README.md .
- run: npm install @napi-rs/cli
- name: Publish
run: npm publish --provenance --access public --ignore-scripts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-nuget:
name: NuGet
needs: [test, build-native]
runs-on: windows-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: bindings/dotnet
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: actions/download-artifact@v4
with:
path: ${{ github.workspace }}/artifacts
pattern: native-*
- name: Organize runtimes
shell: pwsh
run: |
$artifactsRoot = "${{ github.workspace }}/artifacts"
$mapping = @{
"native-x86_64-pc-windows-msvc" = "win-x64"
"native-aarch64-pc-windows-msvc" = "win-arm64"
"native-x86_64-apple-darwin" = "osx-x64"
"native-aarch64-apple-darwin" = "osx-arm64"
"native-x86_64-unknown-linux-gnu" = "linux-x64"
"native-aarch64-unknown-linux-gnu" = "linux-arm64"
}
foreach ($entry in $mapping.GetEnumerator()) {
$src = "$artifactsRoot/$($entry.Key)"
$dst = "runtimes/$($entry.Value)/native"
New-Item -ItemType Directory -Force -Path $dst
Copy-Item "$src/*" $dst
}
- name: Pack
run: dotnet pack SpeechMarkdown.nuspec -o output
- name: NuGet login
uses: NuGet/login@v1
id: nuget-login
with:
user: ${{ secrets.NUGET_USER }}
- name: Publish
shell: pwsh
run: |
$pkg = Get-ChildItem output/*.nupkg | Select-Object -First 1
dotnet nuget push $pkg.FullName --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
build-swift:
name: Swift Package
needs: test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: version-sync
path: .
- uses: dtolnay/rust-toolchain@stable
with:
targets: "aarch64-apple-darwin,x86_64-apple-darwin,aarch64-apple-ios,aarch64-apple-ios-sim"
- name: Build Rust (macOS Apple Silicon)
run: cargo build --release --target aarch64-apple-darwin
- name: Build Rust (macOS Intel)
run: cargo build --release --target x86_64-apple-darwin
- name: Build Rust (iOS device)
run: cargo build --release --target aarch64-apple-ios
- name: Build Rust (iOS simulator)
run: cargo build --release --target aarch64-apple-ios-sim
- name: Assemble XCFramework and Swift package
run: |
PREP=$(mktemp -d)
# macOS universal (arm64 + x86_64)
mkdir -p "$PREP/macos-arm64_x86_64"
lipo -create \
target/aarch64-apple-darwin/release/libspeechmarkdown_rust.a \
target/x86_64-apple-darwin/release/libspeechmarkdown_rust.a \
-output "$PREP/macos-arm64_x86_64/libspeechmarkdown_rust.a"
strip -x "$PREP/macos-arm64_x86_64/libspeechmarkdown_rust.a"
cp bindings/speechmarkdown.h bindings/swift/Sources/CSpeechMarkdown/shim.h bindings/swift/Sources/CSpeechMarkdown/module.modulemap "$PREP/macos-arm64_x86_64/"
# iOS device (arm64)
mkdir -p "$PREP/ios-arm64"
cp target/aarch64-apple-ios/release/libspeechmarkdown_rust.a "$PREP/ios-arm64/"
strip -x "$PREP/ios-arm64/libspeechmarkdown_rust.a"
cp bindings/speechmarkdown.h bindings/swift/Sources/CSpeechMarkdown/shim.h bindings/swift/Sources/CSpeechMarkdown/module.modulemap "$PREP/ios-arm64/"
# iOS simulator (arm64)
mkdir -p "$PREP/ios-arm64-sim"
cp target/aarch64-apple-ios-sim/release/libspeechmarkdown_rust.a "$PREP/ios-arm64-sim/"
strip -x "$PREP/ios-arm64-sim/libspeechmarkdown_rust.a"
cp bindings/speechmarkdown.h bindings/swift/Sources/CSpeechMarkdown/shim.h bindings/swift/Sources/CSpeechMarkdown/module.modulemap "$PREP/ios-arm64-sim/"
# Create multi-platform XCFramework
xcodebuild -create-xcframework \
-library "$PREP/macos-arm64_x86_64/libspeechmarkdown_rust.a" -headers "$PREP/macos-arm64_x86_64/" \
-library "$PREP/ios-arm64/libspeechmarkdown_rust.a" -headers "$PREP/ios-arm64/" \
-library "$PREP/ios-arm64-sim/libspeechmarkdown_rust.a" -headers "$PREP/ios-arm64-sim/" \
-output "$PREP/SpeechMarkdownRust.xcframework"
# Assemble Swift package
DIST="$GITHUB_WORKSPACE/swift-package-dist"
rm -rf "$DIST"
mkdir -p "$DIST/Sources/SpeechMarkdown"
mkdir -p "$DIST/Tests/SpeechMarkdownTests"
cp -R "$PREP/SpeechMarkdownRust.xcframework" "$DIST/"
cp bindings/swift/SpeechMarkdown.swift "$DIST/Sources/SpeechMarkdown/"
shell: bash
- name: Write Package.swift
run: |
cat > swift-package-dist/Package.swift << 'SWIFTPKG'
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "SpeechMarkdown",
platforms: [.macOS(.v12), .iOS(.v16)],
products: [
.library(name: "SpeechMarkdown", targets: ["SpeechMarkdown"]),
],
targets: [
.binaryTarget(
name: "SpeechMarkdownRust",
path: "SpeechMarkdownRust.xcframework"
),
.target(
name: "SpeechMarkdown",
dependencies: ["SpeechMarkdownRust"],
path: "Sources/SpeechMarkdown"
),
.testTarget(
name: "SpeechMarkdownTests",
dependencies: ["SpeechMarkdown"],
path: "Tests/SpeechMarkdownTests"
),
]
)
SWIFTPKG
- name: Write tests
run: |
cat > swift-package-dist/Tests/SpeechMarkdownTests/SpeechMarkdownTests.swift << 'SWIFTTEST'
import XCTest
@testable import SpeechMarkdown
final class SpeechMarkdownTests: XCTestCase {
let parser = SpeechMarkdownParser()
func testIsSpeechMarkdown() {
XCTAssertTrue(parser.isSpeechMarkdown(input: "Hello (world)[emphasis:\"strong\"]"))
XCTAssertFalse(parser.isSpeechMarkdown(input: "Hello world"))
}
func testToSsml() throws {
let ssml = try parser.toSsml(input: "Hello (world)[emphasis:\"strong\"]", platform: "amazon-alexa")
XCTAssertTrue(ssml.contains("<emphasis"))
}
func testToText() throws {
let text = try parser.toText(input: "Hello (world)[emphasis:\"strong\"]")
XCTAssertEqual(text, "Hello world")
}
func testToSmd() throws {
let smd = try parser.toSmd(ssml: "<speak><emphasis level=\"strong\">word</emphasis></speak>")
XCTAssertEqual(smd, "++word++")
}
func testValidate() throws {
try parser.validate(input: "Hello world")
}
}
SWIFTTEST
- name: Test Swift package
run: cd swift-package-dist && swift test
- name: Package artifact
run: cd swift-package-dist && zip -r "$GITHUB_WORKSPACE/speechmarkdown-swift-package.zip" .
- uses: actions/upload-artifact@v4
with:
name: swift-package
path: speechmarkdown-swift-package.zip
publish-github-release:
name: GitHub Release
needs: [build-swift]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: swift-package
path: .
- name: Upload release asset
uses: softprops/action-gh-release@v2
with:
files: speechmarkdown-swift-package.zip
fail_on_unmatched_files: true