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
609 changes: 376 additions & 233 deletions .github/workflows/publish.yml

Large diffs are not rendered by default.

92 changes: 82 additions & 10 deletions bindings/python/_generate/check_version_sync.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
#!/usr/bin/env python3
"""Fail if the library version drifts across its three sources of truth.
"""Fail if the library version drifts across every place it is duplicated.

The native library version is defined once, in
``include/transcribe.h`` (``TRANSCRIBE_VERSION_{MAJOR,MINOR,PATCH}``); CMake
parses it from there. The Python package repeats it in two more places —
``bindings/python/pyproject.toml`` (``project.version``) and the
``__version__`` in ``bindings/python/src/transcribe_cpp/__init__.py``. The
import-time gate enforces base-version match against the *loaded* library at
runtime; this script is the static, build-time counterpart so a forgotten bump
fails CI before anything is published.
parses it from there. Every binding repeats it — in package manifests, lockfiles,
the Python ``__version__``, the cross-package dependency pins, and the Swift
``compiledVersion`` literal. The import-time gate enforces base-version match
against the *loaded* library at runtime; this script is the static, build-time
counterpart so a forgotten bump fails CI before anything is published.

This covers every §1b spot in ``notes/releasing.md`` — including the ones that
used to be §1c blind spots: the ``transcribe-cpp-sys`` dependency *pin*, both
``Cargo.lock`` entries, both ``package-lock.json`` spots, and Swift
``compiledVersion``. (Lockfile *internal* consistency — a stale lock silently
rewritten by an unlocked command — is still the job of the locked-command
checks, ``cargo metadata --locked`` / ``npm ci``, run in release-preflight.)

Comparison is on the PEP 440 *release segment* (``MAJOR.MINOR.PATCH``): the
header is always a clean triple, while the Python side may legitimately carry a
header is always a clean triple, while a package side may legitimately carry a
``.postN`` packaging suffix that must still be accepted.

uv run --no-project bindings/python/_generate/check_version_sync.py

Exit 0 when all three agree on the base version; 1 on drift; 2 if a version
could not be located (treated as a hard error, not a pass).
Exit 0 when all agree on the base version; 1 on drift; 2 if a version could not
be located (treated as a hard error, not a pass).
"""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path
Expand All @@ -31,6 +38,10 @@
PYPROJECT = REPO / "bindings" / "python" / "pyproject.toml"
INIT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "__init__.py"
TS_PACKAGE_JSON = REPO / "bindings" / "typescript" / "package.json"
RUST_SAFE_CARGO = REPO / "bindings" / "rust" / "transcribe-cpp" / "Cargo.toml"
CARGO_LOCK = REPO / "Cargo.lock"
PACKAGE_LOCK = REPO / "bindings" / "typescript" / "package-lock.json"
SWIFT_SOURCE = REPO / "bindings" / "swift" / "Sources" / "TranscribeCpp" / "TranscribeCpp.swift"

# Binding package manifests (requirements doc §2: every manifest is derived
# from or gated against the header). Gated by the `active` flag: a 0.0.0
Expand Down Expand Up @@ -122,6 +133,50 @@ def npm_optional_pins(text: str) -> "dict[str, str | None]":
return {f"package.json ({name} pin)": version for name, version in pins}


def cargo_sys_pin(text: str) -> str | None:
# The safe crate's dependency *pin* on the sys crate (a different field from
# its own [package].version, which cargo_version() returns):
# transcribe-cpp-sys = { version = "X.Y.Z", path = "../../..", ... }
m = re.search(
r'transcribe-cpp-sys\s*=\s*\{[^}]*?\bversion\s*=\s*"([^"]+)"', text
)
return m.group(1) if m else None


def cargo_lock_versions(text: str) -> "dict[str, str | None]":
# The two workspace crates pinned in Cargo.lock. cargo writes name then
# version on consecutive lines within each [[package]] block; the closing
# quote in the name match keeps "transcribe-cpp" from also matching
# "transcribe-cpp-sys".
out: dict[str, str | None] = {}
for name in ("transcribe-cpp", "transcribe-cpp-sys"):
m = re.search(rf'name = "{re.escape(name)}"\nversion = "([^"]+)"', text)
out[f"Cargo.lock ({name})"] = m.group(1) if m else None
return out


def package_lock_versions(text: str) -> "dict[str, str | None]":
# The two spots npm keeps a root version in the lockfile: top-level
# `.version` and `.packages[""].version` (the root package's own node).
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return {"package-lock.json (root)": None, 'package-lock.json (packages[""])': None}
return {
"package-lock.json (root)": data.get("version"),
'package-lock.json (packages[""])': (data.get("packages") or {}).get("", {}).get("version"),
}


def swift_compiled_version(text: str) -> str | None:
# The hand-maintained Swift literal `compiledVersion = "X.Y.Z"` that the
# SwiftPM load gate (Transcribe.ensureCompatible) compares against the
# linked library. (The Swift ABI pin is checked separately by
# swift_abihash_check.py against include/transcribe.abihash.)
m = re.search(r'compiledVersion\s*=\s*"([^"]+)"', text)
return m.group(1) if m else None


def main() -> int:
pyproject_text = PYPROJECT.read_text()
sources = {
Expand All @@ -133,6 +188,23 @@ def main() -> int:
if TS_PACKAGE_JSON.exists():
sources.update(npm_optional_pins(TS_PACKAGE_JSON.read_text()))

# Formerly §1c blind spots — now part of the equality set (releasing.md §8
# P0 #2 slice B). Each file must exist; a missing one is a hard error below.
sources["Cargo.toml (sys dep pin)"] = (
cargo_sys_pin(RUST_SAFE_CARGO.read_text()) if RUST_SAFE_CARGO.exists() else None
)
if CARGO_LOCK.exists():
sources.update(cargo_lock_versions(CARGO_LOCK.read_text()))
else:
sources["Cargo.lock"] = None
if PACKAGE_LOCK.exists():
sources.update(package_lock_versions(PACKAGE_LOCK.read_text()))
else:
sources["package-lock.json"] = None
sources["TranscribeCpp.swift (compiledVersion)"] = (
swift_compiled_version(SWIFT_SOURCE.read_text()) if SWIFT_SOURCE.exists() else None
)

# Binding manifests: active ones join the equality set; inactive ones
# must merely exist and parse (placeholder versions are reported, not
# compared).
Expand Down
17 changes: 14 additions & 3 deletions bindings/python/_generate/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,22 @@ def in_headers(cursor) -> bool:
return f is not None and str(INCLUDE) in str(f.name)


# Version macros are deliberately excluded from BOTH the emitted bindings and the
# hashed ABI digest: a version-only bump (editing TRANSCRIBE_VERSION_* in
# include/transcribe.h) must not churn _generated.py / _generated.ts or the
# abihash (notes/releasing.md §8 P0 #1). Each binding reads its own version from
# its package metadata at runtime, not from these macros. Every OTHER integer
# object-like macro (e.g. the EXT FourCCs) is still captured.
_VERSION_MACROS = frozenset(
f"TRANSCRIBE_VERSION_{c}" for c in ("MAJOR", "MINOR", "PATCH", "NUMBER")
)


def int_macro_value(value_tokens):
"""Parse an object-like macro's value as an int, or None if it isn't one.

Captures integer constants (EXT kind FourCCs, version components); skips
string, attribute, and float/expression macros, which don't parse as int.
Captures integer constants (EXT kind FourCCs); skips string, attribute, and
float/expression macros, which don't parse as int.
"""
s = "".join(value_tokens).rstrip("uUlL")
try:
Expand Down Expand Up @@ -144,7 +155,7 @@ def collect(tu) -> Surface:
seen_enum.add(e.spelling)
s.enum_constants.append((e.spelling, e.enum_value))
elif c.kind == CursorKind.MACRO_DEFINITION and c.spelling.startswith("TRANSCRIBE_"):
if c.spelling in seen_macro:
if c.spelling in seen_macro or c.spelling in _VERSION_MACROS:
continue
tokens = [t.spelling for t in c.get_tokens()]
if len(tokens) < 2:
Expand Down
5 changes: 1 addition & 4 deletions bindings/python/src/transcribe_cpp/_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Stable digest of the ABI surface below (structs, enums, macros, layout,
# prototypes). A native provider package echoes this back so the API
# package can reject an ABI-mismatched provider before dlopen.
PUBLIC_HEADER_HASH = "fe9ed398c408e5d9"
PUBLIC_HEADER_HASH = "2273744299e5aa65"

# === enum constants ===
TRANSCRIBE_OK = 0
Expand Down Expand Up @@ -101,9 +101,6 @@
TRANSCRIBE_EXT_KIND_PARAKEET_STREAM = 1414744912
TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM = 1414746710
TRANSCRIBE_EXT_KIND_WHISPER_RUN = 1314015319
TRANSCRIBE_VERSION_MAJOR = 0
TRANSCRIBE_VERSION_MINOR = 0
TRANSCRIBE_VERSION_PATCH = 1

# === structs ===
class transcribe_ext(_c.Structure):
Expand Down
8 changes: 2 additions & 6 deletions bindings/rust/sys/src/transcribe_sys.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
// @generated by `cargo xtask bindgen` from include/transcribe/extensions.h
// DO NOT EDIT BY HAND. Regenerate: `cargo xtask bindgen`.
// Pinned to include/transcribe.abihash = fe9ed398c408e5d9
// Pinned to include/transcribe.abihash = 2273744299e5aa65

/// The public-ABI digest these bindings were generated against
/// (sha256/16 over the normalized FFI surface). The load-time version
/// gate and the CI drift check both anchor on this value.
pub const PUBLIC_HEADER_HASH: &str = "fe9ed398c408e5d9";
pub const PUBLIC_HEADER_HASH: &str = "2273744299e5aa65";

/* automatically generated by rust-bindgen 0.72.1 */

pub const TRANSCRIBE_VERSION_MAJOR: u32 = 0;
pub const TRANSCRIBE_VERSION_MINOR: u32 = 0;
pub const TRANSCRIBE_VERSION_PATCH: u32 = 1;
pub const TRANSCRIBE_VERSION_NUMBER: u32 = 1;
pub const TRANSCRIBE_EXT_KIND_MOONSHINE_STREAMING_STREAM: u32 = 1414746957;
pub const TRANSCRIBE_EXT_KIND_PARAKEET_STREAM: u32 = 1414744912;
pub const TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM: u32 = 1396853584;
Expand Down
14 changes: 7 additions & 7 deletions bindings/rust/transcribe-cpp/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ use crate::error::{Error, Result};
use crate::result::owned_str;
use crate::types::AbiStruct;

/// The base version string this crate's generated FFI was built against.
/// The base version string this crate's bindings were built against.
///
/// Taken from this crate's own `Cargo.toml` (`CARGO_PKG_VERSION`), not the
/// generated FFI macros: a version-only bump must not churn the committed
/// bindings or the abihash (notes/releasing.md §8 P0 #1). The generators no
/// longer emit `TRANSCRIBE_VERSION_*`, so this is also the only source left.
pub fn compiled_version() -> String {
format!(
"{}.{}.{}",
sys::TRANSCRIBE_VERSION_MAJOR,
sys::TRANSCRIBE_VERSION_MINOR,
sys::TRANSCRIBE_VERSION_PATCH
)
base(env!("CARGO_PKG_VERSION")).to_string()
}

/// The `MAJOR.MINOR.PATCH` version string of the linked native library.
Expand Down
6 changes: 6 additions & 0 deletions bindings/rust/xtask/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ fn generate(root: &Path) -> String {
// Only emit declarations from our own headers (skip stdint/stddef).
.allowlist_file(r".*/include/transcribe\.h")
.allowlist_file(r".*/include/transcribe/.*\.h")
// Version macros are deliberately NOT emitted: a version-only bump must
// not churn the committed bindings or the abihash (notes/releasing.md
// §8 P0 #1). The runtime version comes from CARGO_PKG_VERSION instead
// (transcribe-cpp/src/version.rs), matching the Python/TS generator,
// which drops these from both its output and the hashed digest.
.blocklist_item(r"TRANSCRIBE_VERSION_(MAJOR|MINOR|PATCH|NUMBER)")
// Compile-time layout assertions (free belt-and-suspenders; the
// per-field check is otherwise waived for bindgen).
.layout_tests(true)
Expand Down
2 changes: 1 addition & 1 deletion bindings/swift/Sources/TranscribeCpp/ABIHash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import CTranscribe
extension Transcribe {
/// sha256/16 of the normalized public FFI surface, pinned to the value in
/// include/transcribe.abihash at the time this binding was last reviewed.
public static let pinnedHeaderHash = "fe9ed398c408e5d9"
public static let pinnedHeaderHash = "2273744299e5aa65"

/// The public-ABI digest this binding was reviewed against (16 hex chars).
public static func headerHash() -> String { pinnedHeaderHash }
Expand Down
14 changes: 7 additions & 7 deletions bindings/typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions bindings/typescript/src/_generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// Stable digest of the ABI surface (structs, enums, macros, layout,
// prototypes), computed by the Python oracle and pinned here so a header
// ABI change turns this binding's drift check red for conscious review.
export const PUBLIC_HEADER_HASH = "fe9ed398c408e5d9";
export const PUBLIC_HEADER_HASH = "2273744299e5aa65";

// === enum constants ===
export const TRANSCRIBE_OK = 0;
Expand Down Expand Up @@ -99,9 +99,6 @@ export const TRANSCRIBE_EXT_KIND_PARAKEET_BUFFERED_STREAM = 1396853584;
export const TRANSCRIBE_EXT_KIND_PARAKEET_STREAM = 1414744912;
export const TRANSCRIBE_EXT_KIND_VOXTRAL_REALTIME_STREAM = 1414746710;
export const TRANSCRIBE_EXT_KIND_WHISPER_RUN = 1314015319;
export const TRANSCRIBE_VERSION_MAJOR = 0;
export const TRANSCRIBE_VERSION_MINOR = 0;
export const TRANSCRIBE_VERSION_PATCH = 1;

export interface StructLayout { size: number; align: number; offsets: Record<string, number>; }
export const STRUCT_LAYOUT: Record<string, StructLayout> = {
Expand Down
8 changes: 1 addition & 7 deletions bindings/typescript/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import * as g from "./_generated.js";
import { OUR_VERSION, baseVersion } from "./version.js";
import { AbiError, BackendError, TranscribeError, VersionMismatch } from "./errors.js";

export interface Resolved {
Expand All @@ -33,13 +34,6 @@ const LIB_NAME =
? "transcribe.dll"
: "libtranscribe.so";

const OUR_VERSION = `${g.TRANSCRIBE_VERSION_MAJOR}.${g.TRANSCRIBE_VERSION_MINOR}.${g.TRANSCRIBE_VERSION_PATCH}`;

function baseVersion(v: string): string {
const m = /^\d+(?:\.\d+)*/.exec(v.trim());
return m ? m[0] : v.trim();
}

/**
* The platform-package tuple for this host, or null if unsupported. Uses the
* Node platform-arch convention (matches npm os/cpu fields), e.g.
Expand Down
4 changes: 1 addition & 3 deletions bindings/typescript/src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { resolveLibrary } from "./loader.js";
import { abortProto, bindLibrary, type Bound, logProto } from "./ffi.js";
import { verifyLayouts } from "./abi.js";
import { BackendError, VersionMismatch } from "./errors.js";
import { OUR_VERSION, baseVersion } from "./version.js";
import * as g from "./_generated.js";

export interface Native extends Bound {
Expand All @@ -19,9 +20,6 @@ export interface Native extends Bound {

let cached: Native | null = null;

const OUR_VERSION = `${g.TRANSCRIBE_VERSION_MAJOR}.${g.TRANSCRIBE_VERSION_MINOR}.${g.TRANSCRIBE_VERSION_PATCH}`;
const baseVersion = (v: string): string => (/^\d+(?:\.\d+)*/.exec(v.trim())?.[0] ?? v.trim());

// ---- log routing -----------------------------------------------------------

export type LogHandler = (level: number, message: string) => void;
Expand Down
34 changes: 34 additions & 0 deletions bindings/typescript/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* This binding's own version, and the base-version helper the load gates use.
*
* `OUR_VERSION` is read from the API package's `package.json` at runtime — NOT
* from the generated FFI macros. The generators stopped emitting
* `TRANSCRIBE_VERSION_*` so a version-only bump no longer churns generated
* files or the abihash (notes/releasing.md §8 P0 #1). `package.json` is always
* present in the published tarball and sits one directory above the compiled
* `dist/` output, so a runtime `require("../package.json")` resolves it.
*
* A runtime `createRequire(...)` is used rather than `import "../package.json"`:
* tsconfig sets `rootDir: "src"` with no `resolveJsonModule`, so a static JSON
* import would break the emit layout.
*/

import { createRequire } from "node:module";

/** Leading dotted-numeric release segment, suffix stripped: "0.0.1.post3" -> "0.0.1". */
export function baseVersion(v: string): string {
const m = /^\d+(?:\.\d+)*/.exec(v.trim());
return m ? m[0] : v.trim();
}

function readPackageVersion(): string {
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version?: string };
if (!pkg.version) {
throw new Error("transcribe-cpp: package.json is missing a version field");
}
return pkg.version;
}

/** The base `MAJOR.MINOR.PATCH` this binding was built as. */
export const OUR_VERSION = baseVersion(readPackageVersion());
2 changes: 1 addition & 1 deletion include/transcribe.abihash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
fe9ed398c408e5d9
2273744299e5aa65
Loading
Loading