diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73d54668..95e8e9b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,32 +1,48 @@ name: publish -# Release pipeline (plan M5 tail). Auth is PyPI Trusted Publishing (OIDC) — -# no tokens anywhere; the publishers are registered against THIS filename -# (publish.yml) and the `testpypi` / `pypi` environments. +# Release pipeline. Two phases with a HARD BARRIER between them, plus a single +# preflight gate in front of everything. # -# REHEARSAL (workflow_dispatch): builds + validates the FULL matrix by -# calling python-wheels.yml and cuda-windows.yml, publishes the -# PyPI-bound set (api wheel, native wheels, sdist — the dist-* -# artifacts) to TestPyPI, then proves a clean-machine install FROM -# TestPyPI with a real transcription. cu12 wheels are NOT rehearsed on -# TestPyPI (197 MB > its 100 MB file cap); their home is GitHub release -# assets + the /whl/cu12 index, exercised on real tags. -# NOTE: (Test)PyPI filenames are write-once — a failed rehearsal -# iteration means bumping the patch version in include/transcribe.h -# (the single version source) and re-dispatching. +# release-preflight (GATE): the hard tier (scripts/release/prepare.py --check +# — tag/dispatch version == source tree, version sync across every §1b spot, +# Python/TS + Rust FFI + abihash drift, Swift ABI pin, and lockfile +# freshness) gates the WHOLE run. A soft infra doctor only warns. Nothing +# builds or publishes until this passes, so a mistag/drift/stale-lock stops +# the run before any expensive matrix work or immutable upload. # -# RELEASE (push tag v0.0.X): same full build, then with the `pypi` -# environment (add a required-reviewer rule once the repo is public): -# dist-* → PyPI, cu12 wheels → draft GitHub release assets for the tag, -# Rust crates → crates.io, Swift → the draft release, then the draft is -# published and wheel-index.yml is dispatched so /whl/cu12 picks it up. -# cu12 → PyPI additionally when the repo variable CU12_ON_PYPI is "true" -# (set it once the PyPI file-size request is granted). +# VALIDATE (Actions artifacts only — no registry touched): wheels +# (python-wheels.yml), cuda-windows (cuda-windows.yml), swift-build +# (xcframework -> artifact, NOT the Release), rust-verify (cargo publish +# --dry-run + packed-crate smoke). These run on BOTH a release tag and a +# workflow_dispatch rehearsal; Windows CUDA is release-required, not special. +# +# PUBLISH (tags only — immutable uploads): create-release, publish-pypi, +# rust-release, ts-release, release-assets each `needs:` the FULL validation +# set, so no immutable publish (crates.io / PyPI / npm / GitHub Release) can +# start while any release-required lane is still unproven — retiring the +# 0.0.1/0.0.2 crates burns. finalize-release flips the draft Release public +# only after all four publishers land. +# +# REHEARSAL (workflow_dispatch): the VALIDATE jobs plus publish-testpypi / +# smoke-testpypi / rust-rehearsal / swift-rehearsal / ts-rehearsal — full +# build + TestPyPI clean-install, NO prod registry touched. Requires a +# `version` input that release-preflight asserts equals the source tree. +# +# Auth is PyPI Trusted Publishing (OIDC) — no PyPI tokens; crates = +# CARGO_REGISTRY_TOKEN; npm = NPM_TOKEN; Release assets / draft = GITHUB_TOKEN. +# +# NOTE: (Test)PyPI filenames are write-once — a failed rehearsal iteration +# means bumping the version (scripts/release/prepare.py X.Y.Z) and re-running. on: push: tags: ["v[0-9]*"] workflow_dispatch: + inputs: + version: + description: "X.Y.Z being rehearsed — MUST equal the source tree version (release-preflight asserts it)." + required: true + type: string # Never two publishes at once; never cancel one mid-upload. concurrency: @@ -34,44 +50,153 @@ concurrency: cancel-in-progress: false jobs: - # Full build + validation matrix; the artifacts published below are the - # exact files these workflows tested post-repair. + # =========================================================================== + # GATE — runs first on every event; everything else `needs:` it. + # =========================================================================== + release-preflight: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install preflight deps (libclang for cargo xtask bindgen) + run: sudo apt-get update && sudo apt-get install -y clang libclang-dev + - name: Assert the tag / dispatch version equals the source tree + id: srcver + run: | + set -euo pipefail + src="$(grep -E '^#define TRANSCRIBE_VERSION_(MAJOR|MINOR|PATCH) ' include/transcribe.h \ + | grep -oE '[0-9]+$' | paste -sd. -)" + echo "source tree version: $src" + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + if [ "${GITHUB_REF_NAME}" != "v${src}" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match the source version v${src} — retag the prepared commit." + exit 1 + fi + else + want='${{ inputs.version }}' + if [ "${want}" != "${src}" ]; then + echo "::error::dispatch version ${want} does not match the source version ${src}." + exit 1 + fi + fi + echo "version=$src" >> "$GITHUB_OUTPUT" + echo "version assertion ok" + - name: Hard tier — public-registry availability (no reused/burned version) + run: | + set -euo pipefail + ver='${{ steps.srcver.outputs.version }}' + if [ "${GITHUB_EVENT_NAME}" = "push" ]; then + uv run --no-project scripts/release/check_registries.py "$ver" + else + uv run --no-project scripts/release/check_registries.py "$ver" --rehearsal + fi + - name: Hard tier — release consistency (prepare.py --check) + run: uv run --no-project scripts/release/prepare.py --check + - name: Soft tier — infra doctor (warns only, never gates) + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + HF_TOKEN: ${{ secrets.HF_TOKEN }} + MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} + MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} + run: uv run --no-project scripts/release/doctor.py + + # =========================================================================== + # VALIDATE — build + verify only; produce Actions artifacts, touch no + # registry. Runs on a tag AND a rehearsal. The publish phase below `needs:` + # the full set [release-preflight, wheels, cuda-windows, swift-build, + # rust-verify]. + # =========================================================================== wheels: + # Full build + validation matrix; the artifacts published below are the + # exact files these workflows tested post-repair. + needs: [release-preflight] uses: ./.github/workflows/python-wheels.yml secrets: inherit + cuda-windows: - # Tags only: the heaviest build in the project (nvcc over ggml-cuda x 5 - # arches under MSVC, ~3 h on 16vcpu), and the TestPyPI rehearsal neither - # publishes nor smokes its output (cu12 wheels exceed TestPyPI's file - # cap). Validate it on demand with its own workflow_dispatch. - if: startsWith(github.ref, 'refs/tags/v') + # The heaviest build in the project (nvcc over ggml-cuda x 5 arches under + # MSVC, ~3 h). Now release-required on BOTH events: a rehearsal proves it + # too, and no immutable publish starts until it is green. + needs: [release-preflight] uses: ./.github/workflows/cuda-windows.yml secrets: inherit - create-release: - # Tags only. Owns creation of the GitHub Release object. Keep it draft - # until the mandatory publishers have succeeded and assets are verified. + swift-build: + # Tags only — the tag-path validation twin of swift-rehearsal. Build the + # xcframework + checksum and stash the zip as an ACTIONS ARTIFACT; the upload + # to the GitHub Release happens later in release-assets (publish phase), so + # no release asset exists until the whole validation phase is green. if: startsWith(github.ref, 'refs/tags/v') - runs-on: blacksmith-2vcpu-ubuntu-2404 - permissions: - contents: write + needs: [release-preflight] + runs-on: blacksmith-6vcpu-macos-15 steps: - - name: Create the draft release for the tag - env: - GH_TOKEN: ${{ github.token }} + - uses: actions/checkout@v4 + - run: brew install ninja + - name: Build the full xcframework + run: scripts/ci/build_xcframework.sh + - name: Package + checksum + id: pkg run: | - set -e - tag="${GITHUB_REF_NAME}" - gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ - gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ - --title "$tag" --notes "transcribe.cpp $tag" --verify-tag --draft + scripts/ci/package_xcframework.sh | tee pkg.txt + echo "checksum=$(awk '/checksum:/ {print $2}' pkg.txt)" >> "$GITHUB_OUTPUT" + - name: Checksum for the mirror repo's Package.swift (informational) + run: | + echo "binaryTarget(url: .../TranscribeCpp.xcframework.zip," + echo " checksum: \"${{ steps.pkg.outputs.checksum }}\")" + - name: Stash the xcframework as an artifact (attached to the Release in release-assets) + uses: actions/upload-artifact@v7 + with: + name: swift-xcframework + path: bindings/swift/build-apple/TranscribeCpp.xcframework.zip + if-no-files-found: error + + rust-verify: + # Tags only — the tag-path validation twin of rust-rehearsal. Dry-run the + # sys crate (full verify build, as cargo would publish it) and run the + # packed-crate smoke; NO upload. The safe crate cannot be dry-run-verified + # pre-publish (sys-first ordering), so the packed smoke exercises it instead. + if: startsWith(github.ref, 'refs/tags/v') + needs: [release-preflight] + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 40 + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: astral-sh/setup-uv@v8.2.0 + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev + - name: Dry-run transcribe-cpp-sys (full verify build, as cargo would publish it) + run: cargo publish --dry-run --allow-dirty -p transcribe-cpp-sys + - uses: ./.github/actions/fetch-canary + with: + hf-token: ${{ secrets.HF_TOKEN }} + - name: Packed-crate smoke (build the native lib from the shipped tarball, transcribe) + run: uv run --no-project python scripts/ci/rust_packed_smoke.py + # =========================================================================== + # REHEARSAL — workflow_dispatch only. Proves the shipped artifacts against + # TestPyPI / packed tarballs without touching prod registries. + # =========================================================================== publish-testpypi: # Rehearsal target (dispatch only — real tags go to PyPI). # Blacksmith, not the Hetzner box: gh-action-pypi-publish is a Docker # container action and needs docker on the runner. + # + # TestPyPI filenames are WRITE-ONCE, so this upload waits on the full + # rehearsal validation matrix (Windows CUDA + the Rust/Swift/TS rehearsal + # lanes), not just the wheels — the same "no immutable publish while any + # release-required lane is unproven" rule the tag path enforces. A later + # CUDA/Swift/TS failure must not force a version bump just to re-rehearse. if: github.event_name == 'workflow_dispatch' - needs: [wheels] + needs: [release-preflight, wheels, cuda-windows, rust-rehearsal, swift-rehearsal, ts-rehearsal] runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 30 environment: testpypi @@ -150,93 +275,10 @@ jobs: print("ok: clean machine installed from TestPyPI and transcribed") EOF - publish-pypi: - # The real thing. Gate: only version tags reach this, and the `pypi` - # environment carries the approval rule once the repo is public. - if: startsWith(github.ref, 'refs/tags/v') - needs: [wheels, cuda-windows] - # Blacksmith for the same docker-container-action reason as testpypi. - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 30 - environment: pypi - permissions: - id-token: write - steps: - - uses: actions/download-artifact@v8 - with: - pattern: dist-* - merge-multiple: true - path: dist - - name: Add cu12 wheels (only once the PyPI size request is granted) - if: vars.CU12_ON_PYPI == 'true' - uses: actions/download-artifact@v8 - with: - pattern: cuda-dist-* - merge-multiple: true - path: dist - - run: ls -la dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: dist - - release-assets: - # cu12's primary distribution home: wheels as GitHub release assets, - # served to pip through the PEP 503 index on Pages (wheel-index.yml). - if: startsWith(github.ref, 'refs/tags/v') - needs: [create-release, wheels, cuda-windows] - runs-on: [self-hosted, Linux, X64, hetzner] - timeout-minutes: 30 - permissions: - contents: write # upload release assets - steps: - - uses: actions/download-artifact@v8 - with: - pattern: cuda-dist-* - merge-multiple: true - path: cu12 - # The canonical native bundles (extracted from the repaired wheels by - # python-wheels.yml). On the release they become the distribution home - # for non-PyPI ecosystems: npm platform packages and prebuilt-Rust - # fetch these exact bytes by tag instead of rebuilding. - - uses: actions/download-artifact@v8 - with: - pattern: native-* - merge-multiple: true - path: native-bundles - - name: Attach the cu12 wheels - env: - GH_TOKEN: ${{ github.token }} - run: | - set -e - tag="${GITHUB_REF#refs/tags/}" - gh release upload "$tag" cu12/*.whl --repo "$GITHUB_REPOSITORY" --clobber - - name: Attach the native bundles (versioned names) - env: - GH_TOKEN: ${{ github.token }} - run: | - set -e - tag="${GITHUB_REF#refs/tags/}" - ver="${tag#v}" - mkdir -p upload - for f in native-bundles/transcribe-native-*.tar.gz; do - base="$(basename "$f" .tar.gz)" - tuple="${base#transcribe-native-}" - cp "$f" "upload/transcribe-native-${ver}-${tuple}.tar.gz" - done - ls -la upload/ - gh release upload "$tag" upload/*.tar.gz --repo "$GITHUB_REPOSITORY" --clobber - - # ---- Rust crates (crates.io) ------------------------------------------------- - # The Rust release path mirrors the Python one: a dispatch REHEARSAL that - # validates without uploading, and a tag-gated RELEASE cut from CI. crates.io - # has no TestPyPI analog, so the rehearsal is `cargo publish --dry-run` plus - # the packed-crate smoke (build libtranscribe from the shipped tarball and - # transcribe) — the Rust "test the shipped artifact" gate (requirements §4). - rust-rehearsal: - # Dispatch only. Independent of the wheel jobs (no `needs`): the Rust and - # Python release artifacts don't share build steps. + # Dispatch only — the rehearsal twin of rust-verify. if: github.event_name == 'workflow_dispatch' + needs: [release-preflight] runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 40 env: @@ -250,52 +292,22 @@ jobs: - name: Dry-run transcribe-cpp-sys (full verify build, as cargo would publish it) run: cargo publish --dry-run --allow-dirty -p transcribe-cpp-sys # The safe crate cannot be dry-run-verified pre-publish: it depends on - # transcribe-cpp-sys = "0.0.1", and the registry only has the 0.0.0 - # name-reservation placeholder until sys actually publishes (sys-first - # ordering). Its packaging is verified at release time below; here the - # packed-crate smoke exercises it against the packed sys instead. + # transcribe-cpp-sys = "0.0.x", and the registry only has the placeholder + # until sys actually publishes (sys-first ordering). Its packaging is + # verified at release time below; here the packed-crate smoke exercises it + # against the packed sys instead. - uses: ./.github/actions/fetch-canary with: hf-token: ${{ secrets.HF_TOKEN }} - name: Packed-crate smoke (build the native lib from the shipped tarball, transcribe) run: uv run --no-project python scripts/ci/rust_packed_smoke.py - rust-release: - # Tags only. Releases are cut from CI, never a laptop (requirements §5). - # sys publishes FIRST (the safe crate resolves it by version), then the safe - # crate. The `crates-io` environment carries the approval gate (CJ approves) - # and holds CARGO_REGISTRY_TOKEN. cargo waits for the index to carry sys - # before returning, so the safe publish that follows resolves it. - if: startsWith(github.ref, 'refs/tags/v') - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 40 - environment: crates-io - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev - - name: Publish transcribe-cpp-sys (the native-carrying crate; verifies the build) - run: cargo publish -p transcribe-cpp-sys - - name: Publish transcribe-cpp (the safe wrapper; resolves the just-published sys) - run: cargo publish -p transcribe-cpp - - # --------------------------------------------------------------------------- - # Swift binding (TranscribeCpp) — xcframework release. - # - # The Swift package is consumed as a prebuilt static `.xcframework` - # binaryTarget (notes/swift-bindings-plan.md; requirements §5). Releasing it - # means: build the four Apple slices, zip + checksum the xcframework, attach - # the zip as a release asset, then point the mirror repo's Package.swift - # `binaryTarget(url:checksum:)` at it. "Releases are cut from CI, never a - # laptop." macOS runner: the xcframework needs Xcode (libtool/xcodebuild). - # --------------------------------------------------------------------------- swift-rehearsal: - # The shipped-artifact gate (§4): build the real xcframework and run the - # suite against it (transcribes the canary through the published shape). + # Dispatch only — the rehearsal twin of swift-build: build the real + # xcframework and run the suite against it (transcribes the canary through + # the published shape). if: github.event_name == 'workflow_dispatch' + needs: [release-preflight] runs-on: [self-hosted, macOS, ARM64] env: HF_TOKEN: ${{ secrets.HF_TOKEN }} @@ -314,55 +326,12 @@ jobs: working-directory: bindings/swift run: swift test - swift-release: - # Tags only. Builds the artifact, attaches the zip to the tag's release, and - # emits the checksum. The mirror-repo Package.swift bump is the final step - # (CJ-gated — needs the dedicated SwiftPM repo + a deploy key; see the plan). - if: startsWith(github.ref, 'refs/tags/v') - needs: [create-release] - runs-on: blacksmith-6vcpu-macos-15 - permissions: - contents: write # attach the release asset - steps: - - uses: actions/checkout@v4 - - run: brew install ninja - - name: Build the full xcframework - run: scripts/ci/build_xcframework.sh - - name: Package + checksum - id: pkg - run: | - scripts/ci/package_xcframework.sh | tee pkg.txt - echo "checksum=$(awk '/checksum:/ {print $2}' pkg.txt)" >> "$GITHUB_OUTPUT" - - name: Attach the xcframework zip to the release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -e - tag="${GITHUB_REF_NAME}" - gh release upload "$tag" \ - bindings/swift/build-apple/TranscribeCpp.xcframework.zip \ - --repo "$GITHUB_REPOSITORY" --clobber - - name: Checksum for the mirror repo's Package.swift - run: | - echo "binaryTarget(url: .../TranscribeCpp.xcframework.zip," - echo " checksum: \"${{ steps.pkg.outputs.checksum }}\")" - # TODO(CJ): push the thin Swift sources + the url/checksum-bearing - # Package.swift to the dedicated mirror repo (transcribe-cpp-swift) and tag - # it, so `swift package add` resolves the release. Needs the mirror repo + - # a deploy key secret. Until then, the asset + checksum above are produced - # but not wired into a resolvable SwiftPM tag. - - # ---- npm (TypeScript) -------------------------------------------------------- - # Mirrors the Python/Rust path: a dispatch REHEARSAL that builds + installs the - # shipped tarballs and transcribes the canary (the §4 shipped-artifact gate), - # and a tag-gated RELEASE that turns the native bundles into per-platform npm - # packages and publishes them with the pure-TS API package. - ts-rehearsal: - # Pack the API + a platform package, install from the tarballs into a clean - # dir, and transcribe the canary — exactly the clean-machine path, no - # TRANSCRIBE_LIBRARY (the install stands on the platform package alone). + # Dispatch only. Pack the API + a platform package, install from the + # tarballs into a clean dir, and transcribe the canary — exactly the + # clean-machine path, no TRANSCRIBE_LIBRARY. if: github.event_name == 'workflow_dispatch' + needs: [release-preflight] runs-on: [self-hosted, macOS, ARM64] env: HF_TOKEN: ${{ secrets.HF_TOKEN }} @@ -402,26 +371,108 @@ jobs: TRANSCRIBE_SMOKE_AUDIO="$GITHUB_WORKSPACE/samples/jfk.wav" \ node scripts/ci/ts_packed_smoke.mjs - ts-release: - # Tags only. Build each @transcribe-cpp/ package from its native - # bundle (the exact bytes the wheels shipped) and publish them + the pure-TS - # API package to npm. The `npm` environment carries the approval gate. + # =========================================================================== + # PUBLISH — tags only. Each immutable upload `needs:` the FULL validation set, + # so nothing is published while any release-required lane is still unproven. + # =========================================================================== + create-release: + # Owns creation of the GitHub Release object — created AFTER validation, not + # at workflow start. Keep it draft until the mandatory publishers have + # succeeded and assets are verified (finalize-release). if: startsWith(github.ref, 'refs/tags/v') - needs: [create-release, wheels] + needs: [release-preflight, wheels, cuda-windows, swift-build, rust-verify, ts-pack] + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: write + steps: + - name: Create the draft release for the tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF_NAME}" + gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ + gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ + --title "$tag" --notes "transcribe.cpp $tag" --verify-tag --draft + + publish-pypi: + # The real thing. Gate: only version tags reach this, and the `pypi` + # environment carries the approval rule once the repo is public. Waits for + # the full validation set — including cuda-windows even when CU12_ON_PYPI is + # false: PyPI should not go public while a release-required lane is unproven. + # Also waits on create-release: no immutable registry upload starts unless the + # GitHub Release object itself was created (else PyPI could land with no Release). + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release, release-preflight, wheels, cuda-windows, swift-build, rust-verify, ts-pack] + # Blacksmith for the same docker-container-action reason as testpypi. + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 30 + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: dist-* + merge-multiple: true + path: dist + - name: Add cu12 wheels (only once the PyPI size request is granted) + if: vars.CU12_ON_PYPI == 'true' + uses: actions/download-artifact@v8 + with: + pattern: cuda-dist-* + merge-multiple: true + path: dist + - run: ls -la dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + + rust-release: + # Tags only. Releases are cut from CI, never a laptop (requirements §5). + # sys publishes FIRST (the safe crate resolves it by version), then the safe + # crate. `crates-io` carries the approval gate + CARGO_REGISTRY_TOKEN. NOW + # gated behind the full validation set AND create-release (it previously had + # NO needs: and could burn the immutable crates.io version before anything + # else built, or before the Release object even existed). + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release, release-preflight, wheels, cuda-windows, swift-build, rust-verify, ts-pack] + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 40 + environment: crates-io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build zlib1g-dev + - name: Publish transcribe-cpp-sys (the native-carrying crate; verifies the build) + run: cargo publish -p transcribe-cpp-sys + - name: Publish transcribe-cpp (the safe wrapper; resolves the just-published sys) + run: cargo publish -p transcribe-cpp + + ts-pack: + # Tags only — VALIDATION-phase TS packaging (NO npm publish). Build each + # @transcribe-cpp/ package from its native bundle (the exact bytes + # the wheels shipped) + the pure-TS API package, sync the platform pins, and + # `npm pack` them all to .tgz uploaded as the `ts-tarballs` artifact. The + # publish job consumes those exact bytes, so a packaging failure can never + # land AFTER PyPI/crates have already started. + if: startsWith(github.ref, 'refs/tags/v') + needs: [release-preflight, wheels] runs-on: blacksmith-2vcpu-ubuntu-2404 - environment: npm steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - registry-url: "https://registry.npmjs.org" - uses: actions/download-artifact@v8 with: pattern: native-* merge-multiple: true path: native-bundles - - name: Build platform packages from the native bundles + - name: Build + pack platform packages and the API package working-directory: bindings/typescript run: | set -e @@ -429,10 +480,10 @@ jobs: npm run build ver="$(node -p "require('./package.json').version")" # Sync the API package's @transcribe-cpp/* platform pins to the version - # we publish, so the tarball pins the exact bytes we ship (the static - # gate in check_version_sync.py enforces the base match; this nails the - # exact pin). Without this the pins go stale on the first version bump - # and the install resolves a mismatched native -> load-time AbiError. + # we publish, so the packed API tarball pins the exact bytes we ship + # (the static gate in check_version_sync.py enforces the base match; + # this nails the exact pin). Without this the pins go stale on the first + # version bump and the install resolves a mismatched native -> AbiError. node -e ' const fs = require("node:fs"); const v = process.argv[1]; @@ -457,25 +508,117 @@ jobs: libdir="$(dirname "$(find "$ex" \( -name 'libtranscribe.*' -o -name 'transcribe.dll' \) | head -1)")" node scripts/pack-platform.mjs --lib-dir "$libdir" --tuple "$npm_tuple" --version "$ver" --out "$out" done - echo "TS_VER=$ver" >> "$GITHUB_ENV" + # Pack to .tgz: platform packages and the API package into separate dirs + # so the publish job can order them (platforms first, API last). + rm -rf /tmp/ts-tarballs && mkdir -p /tmp/ts-tarballs/platform /tmp/ts-tarballs/api + for d in "$out"/*/; do + (cd "$d" && npm pack --pack-destination /tmp/ts-tarballs/platform) + done + npm pack --pack-destination /tmp/ts-tarballs/api + - uses: actions/upload-artifact@v7 + with: + name: ts-tarballs + path: /tmp/ts-tarballs + if-no-files-found: error + + ts-release: + # Tags only — PUBLISH phase. Download the tarballs ts-pack validated and npm + # publish those exact bytes (platform packages first so the API package's + # optionalDependencies resolve at install time). Publishing a pre-packed + # tarball does NOT re-run prepublishOnly, so no build happens here — only the + # upload. The `npm` environment carries the approval gate. + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release, release-preflight, wheels, cuda-windows, swift-build, rust-verify, ts-pack] + runs-on: blacksmith-2vcpu-ubuntu-2404 + environment: npm + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + - uses: actions/download-artifact@v8 + with: + name: ts-tarballs + path: ts-tarballs - name: Publish platform packages, then the API package - working-directory: bindings/typescript env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -e - for d in /tmp/ts-platform/*/; do - echo "publishing $(node -p "require('${d}package.json').name")" - npm publish "$d" --access public + for t in ts-tarballs/platform/*.tgz; do + echo "publishing $t" + npm publish "$t" --access public done - npm publish --access public # the API package (transcribe-cpp) + npm publish ts-tarballs/api/*.tgz --access public # the API package (transcribe-cpp) + + release-assets: + # cu12's primary distribution home (wheels as GitHub release assets, served + # to pip through the PEP 503 index on Pages), the canonical native bundles, + # AND the Swift xcframework (built+validated in swift-build, uploaded here). + if: startsWith(github.ref, 'refs/tags/v') + needs: [create-release, release-preflight, wheels, cuda-windows, swift-build, rust-verify, ts-pack] + runs-on: [self-hosted, Linux, X64, hetzner] + timeout-minutes: 30 + permissions: + contents: write # upload release assets + steps: + - uses: actions/download-artifact@v8 + with: + pattern: cuda-dist-* + merge-multiple: true + path: cu12 + # The canonical native bundles (extracted from the repaired wheels by + # python-wheels.yml). On the release they become the distribution home + # for non-PyPI ecosystems: npm platform packages and prebuilt-Rust + # fetch these exact bytes by tag instead of rebuilding. + - uses: actions/download-artifact@v8 + with: + pattern: native-* + merge-multiple: true + path: native-bundles + # The Swift xcframework zip produced by swift-build in the validation phase. + - uses: actions/download-artifact@v8 + with: + name: swift-xcframework + path: swift + - name: Attach the cu12 wheels + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF#refs/tags/}" + gh release upload "$tag" cu12/*.whl --repo "$GITHUB_REPOSITORY" --clobber + - name: Attach the native bundles (versioned names) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF#refs/tags/}" + ver="${tag#v}" + mkdir -p upload + for f in native-bundles/transcribe-native-*.tar.gz; do + base="$(basename "$f" .tar.gz)" + tuple="${base#transcribe-native-}" + cp "$f" "upload/transcribe-native-${ver}-${tuple}.tar.gz" + done + ls -la upload/ + gh release upload "$tag" upload/*.tar.gz --repo "$GITHUB_REPOSITORY" --clobber + - name: Attach the Swift xcframework + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + tag="${GITHUB_REF#refs/tags/}" + gh release upload "$tag" swift/TranscribeCpp.xcframework.zip \ + --repo "$GITHUB_REPOSITORY" --clobber finalize-release: # Tags only. Publish the draft GitHub Release only after its hosted assets - # and the core PyPI publish have completed. crates.io has its own approval - # gate and does not host artifacts on this release. + # AND all four immutable publishers (PyPI, crates.io, npm, Release assets) + # have completed — so the public Release can't finalize before crates/npm + # have landed (closes sharp-edge #5). if: startsWith(github.ref, 'refs/tags/v') - needs: [publish-pypi, release-assets, swift-release] + needs: [publish-pypi, rust-release, ts-release, release-assets] runs-on: blacksmith-2vcpu-ubuntu-2404 permissions: contents: write # publish the draft release diff --git a/bindings/python/_generate/check_version_sync.py b/bindings/python/_generate/check_version_sync.py index 519d0d94..8bd8d9dc 100644 --- a/bindings/python/_generate/check_version_sync.py +++ b/bindings/python/_generate/check_version_sync.py @@ -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 @@ -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 @@ -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 = { @@ -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). diff --git a/bindings/python/_generate/generate.py b/bindings/python/_generate/generate.py index a262110d..15861679 100644 --- a/bindings/python/_generate/generate.py +++ b/bindings/python/_generate/generate.py @@ -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: @@ -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: diff --git a/bindings/python/src/transcribe_cpp/_generated.py b/bindings/python/src/transcribe_cpp/_generated.py index d5424be9..59cb7ec3 100644 --- a/bindings/python/src/transcribe_cpp/_generated.py +++ b/bindings/python/src/transcribe_cpp/_generated.py @@ -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 @@ -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): diff --git a/bindings/rust/sys/src/transcribe_sys.rs b/bindings/rust/sys/src/transcribe_sys.rs index d57c77a2..2cb22204 100644 --- a/bindings/rust/sys/src/transcribe_sys.rs +++ b/bindings/rust/sys/src/transcribe_sys.rs @@ -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; diff --git a/bindings/rust/transcribe-cpp/src/version.rs b/bindings/rust/transcribe-cpp/src/version.rs index bdb19983..a6b0c3ed 100644 --- a/bindings/rust/transcribe-cpp/src/version.rs +++ b/bindings/rust/transcribe-cpp/src/version.rs @@ -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. diff --git a/bindings/rust/xtask/src/main.rs b/bindings/rust/xtask/src/main.rs index 16285b2c..c03ffa98 100644 --- a/bindings/rust/xtask/src/main.rs +++ b/bindings/rust/xtask/src/main.rs @@ -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) diff --git a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift index c694e807..9353d417 100644 --- a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift +++ b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift @@ -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 } diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json index a38663eb..3f063882 100644 --- a/bindings/typescript/package-lock.json +++ b/bindings/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "transcribe-cpp", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "transcribe-cpp", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { "koffi": "^3.0.2" @@ -19,11 +19,11 @@ "node": ">=22" }, "optionalDependencies": { - "@transcribe-cpp/darwin-arm64-metal": "0.0.1", - "@transcribe-cpp/darwin-x64-cpu": "0.0.1", - "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.0.1", - "@transcribe-cpp/linux-x64-cpu-vulkan": "0.0.1", - "@transcribe-cpp/win32-x64-cpu-vulkan": "0.0.1" + "@transcribe-cpp/darwin-arm64-metal": "0.0.2", + "@transcribe-cpp/darwin-x64-cpu": "0.0.2", + "@transcribe-cpp/linux-arm64-cpu-vulkan": "0.0.2", + "@transcribe-cpp/linux-x64-cpu-vulkan": "0.0.2", + "@transcribe-cpp/win32-x64-cpu-vulkan": "0.0.2" } }, "node_modules/@koromix/koffi-darwin-arm64": { diff --git a/bindings/typescript/src/_generated.ts b/bindings/typescript/src/_generated.ts index 2e9352e1..947a9a0f 100644 --- a/bindings/typescript/src/_generated.ts +++ b/bindings/typescript/src/_generated.ts @@ -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; @@ -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; } export const STRUCT_LAYOUT: Record = { diff --git a/bindings/typescript/src/loader.ts b/bindings/typescript/src/loader.ts index abd5b8e8..4c8014ce 100644 --- a/bindings/typescript/src/loader.ts +++ b/bindings/typescript/src/loader.ts @@ -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 { @@ -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. diff --git a/bindings/typescript/src/native.ts b/bindings/typescript/src/native.ts index c61c7f55..51e218f3 100644 --- a/bindings/typescript/src/native.ts +++ b/bindings/typescript/src/native.ts @@ -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 { @@ -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; diff --git a/bindings/typescript/src/version.ts b/bindings/typescript/src/version.ts new file mode 100644 index 00000000..483a9008 --- /dev/null +++ b/bindings/typescript/src/version.ts @@ -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()); diff --git a/include/transcribe.abihash b/include/transcribe.abihash index 812802c2..5778a476 100644 --- a/include/transcribe.abihash +++ b/include/transcribe.abihash @@ -1 +1 @@ -fe9ed398c408e5d9 +2273744299e5aa65 diff --git a/scripts/release/check_registries.py b/scripts/release/check_registries.py new file mode 100644 index 00000000..fc0eda65 --- /dev/null +++ b/scripts/release/check_registries.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Fail a tag release / rehearsal if the target version is ALREADY published. + +`cargo publish --dry-run` does NOT fail when the version already exists — it +warns and exits 0 — and a real `cargo publish` then BURNS the attempt against an +immutable registry (crates.io versions are permanent). PyPI/npm filenames are +likewise write-once. This is the burn-prevention the dry-run can't give: a hard +check, run in release-preflight, that the version being released does not already +exist on ANY package publish.yml would upload it under. + + TAG (push): + crates.io transcribe-cpp-sys, transcribe-cpp + PyPI transcribe-cpp, transcribe-cpp-native, transcribe-cpp-native-cu12 + (cu12 only uploads when CU12_ON_PYPI, but a 200 there is always a + real burn, so it is always checked) + npm transcribe-cpp AND the five @transcribe-cpp/ packages + (the platform packages publish FIRST, so a reused platform version + burns before the API package is even attempted) + + REHEARSAL (dispatch): + TestPyPI transcribe-cpp, transcribe-cpp-native (the dist-* set; cu12 is + not rehearsed on TestPyPI, npm is packed locally — no registry) + +Semantics: a definitive "already exists" is a HARD failure. "Available" passes. +A registry we cannot reach (network / 5xx) is a WARNING, not a block — we only +hard-fail when we are CERTAIN the version is taken, so a transient outage cannot +hold a release hostage. + + uv run --no-project scripts/release/check_registries.py [--rehearsal] + +Exit 0 if no registry definitively has the version; 1 if any does. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request + +UA = "transcribe-cpp-release-preflight (+https://github.com/handy-computer/transcribe.cpp)" + +# The five @transcribe-cpp/ npm platform packages ts-release publishes +# before the API package (publish.yml ts-release / package.json optionalDeps). +NPM_PLATFORM_TUPLES = [ + "darwin-arm64-metal", + "darwin-x64-cpu", + "linux-x64-cpu-vulkan", + "linux-arm64-cpu-vulkan", + "win32-x64-cpu-vulkan", +] + +TAKEN, AVAILABLE, UNKNOWN = "taken", "available", "unknown" + + +def _get(url: str) -> "tuple[int | None, bytes | None]": + req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: # noqa: S310 — fixed https hosts + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, None + except (urllib.error.URLError, TimeoutError, OSError): + return None, None + + +def _simple(url: str) -> str: + """crates.io / PyPI per-version endpoints: 200 = taken, 404 = available.""" + st, _ = _get(url) + return TAKEN if st == 200 else AVAILABLE if st == 404 else UNKNOWN + + +def _npm(name: str, version: str) -> str: + """npm: fetch the packument and inspect its `versions` map. + + Per-version status codes are unreliable for SCOPED names, so we read the + whole packument (the scope `/` is percent-encoded) and check membership. A + 404 means the package was never published at all — available. + """ + enc = name.replace("/", "%2f") + st, body = _get(f"https://registry.npmjs.org/{enc}") + if st == 404: + return AVAILABLE + if st == 200 and body is not None: + try: + data = json.loads(body) + except ValueError: + return UNKNOWN + return TAKEN if version in (data.get("versions") or {}) else AVAILABLE + return UNKNOWN + + +def targets(version: str, rehearsal: bool) -> "list[tuple[str, str, object]]": + """(registry label, package name, checker thunk) for each immutable upload.""" + if rehearsal: + host = "test.pypi.org" + return [ + ("TestPyPI", name, lambda n=name: _simple(f"https://{host}/pypi/{n}/{version}/json")) + for name in ("transcribe-cpp", "transcribe-cpp-native") + ] + out: list[tuple[str, str, object]] = [] + for name in ("transcribe-cpp-sys", "transcribe-cpp"): + out.append(("crates.io", name, + lambda n=name: _simple(f"https://crates.io/api/v1/crates/{n}/{version}"))) + for name in ("transcribe-cpp", "transcribe-cpp-native", "transcribe-cpp-native-cu12"): + out.append(("PyPI", name, + lambda n=name: _simple(f"https://pypi.org/pypi/{n}/{version}/json"))) + npm_names = ["transcribe-cpp"] + [f"@transcribe-cpp/{t}" for t in NPM_PLATFORM_TUPLES] + for name in npm_names: + out.append(("npm", name, lambda n=name: _npm(n, version))) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("version", help="the X.Y.Z version about to be published") + ap.add_argument("--rehearsal", action="store_true", + help="check TestPyPI instead of the prod registries") + args = ap.parse_args() + + taken: list[tuple[str, str]] = [] + unknown: list[tuple[str, str]] = [] + for reg, name, check in targets(args.version, args.rehearsal): + result = check() + if result == TAKEN: + taken.append((reg, name)) + print(f" TAKEN {reg}: {name}=={args.version} already published") + elif result == AVAILABLE: + print(f" ok {reg}: {name}=={args.version} available") + else: + unknown.append((reg, name)) + print(f" unknown {reg}: {name} — could not determine, not blocking") + + if taken: + joined = ", ".join(f"{r}/{n}" for r, n in taken) + print( + f"\n::error::version {args.version} is already published on: {joined}. " + f"Bump the version (uv run --no-project scripts/release/prepare.py X.Y.Z) — " + f"reusing it BURNS the immutable registry.", + file=sys.stderr, + ) + return 1 + if unknown: + print(f"\n::warning::could not reach {len(unknown)} registry target(s); " + f"availability there is unconfirmed.") + print(f"\nversion {args.version} is available on all reachable target registries.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/doctor.py b/scripts/release/doctor.py new file mode 100644 index 00000000..6cbea5d7 --- /dev/null +++ b/scripts/release/doctor.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Soft-tier release infra doctor — surfaces §2 prerequisites as WARNINGS. + +This is the *soft* tier of ``release-preflight`` (notes/releasing.md §8 P1 #6): +it reports infra gaps that would otherwise surface mid-release as a billing / +quota / auth error *after* a tag has already been pushed. It is best-effort by +design and **can never fail a run** — it always exits 0. The hard tier +(``prepare.py --check``) is what gates a release. + +It checks only what is cheap and non-fragile: + + * Repo-level secrets visible to the gate job (``HF_TOKEN``, ``MODAL_TOKEN_*``). + * Environment-scoped secrets (``CARGO_REGISTRY_TOKEN``, ``NPM_TOKEN``) are + NOT visible from a non-environment job, so they are reported as + "verify manually" rather than warned on — their real gate is the + environment that owns them at publish time. + * GitHub Pages enabled (best-effort, only if ``gh`` + a token are present). + +Billing / spending-limit / artifact-storage-quota stay a MANUAL §2 checklist +item: they cannot be proven cheaply or non-fragilely from CI. The durable +mitigation for that failure class is the runner migration + publisher atomicity +(§8 P0 #3), not this script. + + uv run --no-project scripts/release/doctor.py # human run, locally or in CI + +Always exits 0. Emits ``::warning::`` lines on GitHub Actions so gaps are +visible in the run summary without failing it. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +# Repo-level secrets the gate job CAN see when the workflow maps them into env. +REPO_SECRETS = ["HF_TOKEN", "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"] +# Environment-scoped secrets — only present inside their protected environment +# (crates-io / npm) at publish time, never in the preflight gate job. +ENV_SCOPED_SECRETS = ["CARGO_REGISTRY_TOKEN", "NPM_TOKEN"] + +ON_GHA = os.environ.get("GITHUB_ACTIONS") == "true" + + +def warn(msg: str) -> None: + print(f"::warning::{msg}" if ON_GHA else f"WARN: {msg}") + + +def info(msg: str) -> None: + print(msg) + + +def check_repo_secrets() -> int: + warnings = 0 + for name in REPO_SECRETS: + if os.environ.get(name): + info(f" ok {name} is present") + else: + warn(f"{name} is not set in this job's env — canary downloads / the " + f"cu12 Modal build will fail if it is genuinely absent (§2).") + warnings += 1 + return warnings + + +def note_env_scoped_secrets() -> None: + for name in ENV_SCOPED_SECRETS: + present = "present" if os.environ.get(name) else "not visible here" + info(f" -- {name}: {present} (environment-scoped; verify in its " + f"protected environment — §2)") + + +def check_pages_enabled() -> int: + """Best-effort: is GitHub Pages enabled? Never fails; warns at most once.""" + repo = os.environ.get("GITHUB_REPOSITORY") + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not repo or not token: + info(" -- GitHub Pages: skipped (no GITHUB_REPOSITORY / token in env)") + return 0 + try: + proc = subprocess.run( + ["gh", "api", f"repos/{repo}/pages"], + capture_output=True, text=True, timeout=20, + env={**os.environ, "GH_TOKEN": token}, + ) + except (FileNotFoundError, subprocess.SubprocessError): + info(" -- GitHub Pages: skipped (gh CLI unavailable)") + return 0 + if proc.returncode == 0: + info(" ok GitHub Pages is enabled") + return 0 + warn("GitHub Pages does not appear to be enabled — the cu12 PEP 503 index " + "(wheel-index.yml) needs it (§2). This is best-effort; verify manually.") + return 1 + + +def main() -> int: + info("release infra doctor (soft tier — warnings only, never gates)\n") + info("repo-level secrets:") + warnings = check_repo_secrets() + info("\nenvironment-scoped secrets (informational):") + note_env_scoped_secrets() + info("\ninfra reachability:") + warnings += check_pages_enabled() + + info("\nMANUAL (§2, not provable from CI): GitHub billing current, spending " + "limit adequate, Actions artifact-storage quota has headroom, PyPI prod " + "trusted publishers registered for both projects.") + + info(f"\ndoctor: {warnings} warning(s). (Soft tier — this never fails the run.)") + return 0 # by contract: the doctor cannot, by itself, fail a release. + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/prepare.py b/scripts/release/prepare.py new file mode 100644 index 00000000..09842b44 --- /dev/null +++ b/scripts/release/prepare.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Author or verify a transcribe.cpp version bump — one command, every spot. + +The version is authored once in ``include/transcribe.h`` +(``TRANSCRIBE_VERSION_{MAJOR,MINOR,PATCH}``) and physically duplicated across +~14 files (see ``notes/releasing.md`` §1). This script is the executable form of +that bookkeeping and of the pre-tag checklist: + + uv run --no-project scripts/release/prepare.py X.Y.Z # write the bump into + # every §1b spot, regenerate the FFI, sync the lockfiles + uv run --no-project scripts/release/prepare.py --check # umbrella gate + +``--check`` is the single source of truth for "is this tree release-consistent?" +— it runs the hard-tier drift/version assertions (version-sync across every +spot, the Python/TS + Rust FFI drift gates, the Swift ABI pin, and lockfile +freshness). ``release-preflight`` in ``publish.yml`` calls ``--check`` rather +than re-listing the individual gates. + +Post-decoupling (releasing.md §8 P0 #1) a version-only bump produces NO change +to any generated file or the abihash; this script regenerates them anyway and +loudly surfaces a *real* ABI change (a moved ``include/transcribe.abihash``), +which additionally requires a conscious bump of the Swift ``pinnedHeaderHash``. + +Exit 0 on success; non-zero if a write target could not be located or any +``--check`` gate failed. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +# --- §1b spots (hand-edited; kept in lockstep with the header) --------------- +HEADER = REPO / "include" / "transcribe.h" +ROOT_CARGO = REPO / "Cargo.toml" +SAFE_CARGO = REPO / "bindings" / "rust" / "transcribe-cpp" / "Cargo.toml" +CARGO_LOCK = REPO / "Cargo.lock" +PYPROJECT = REPO / "bindings" / "python" / "pyproject.toml" +INIT = REPO / "bindings" / "python" / "src" / "transcribe_cpp" / "__init__.py" +PACKAGE_JSON = REPO / "bindings" / "typescript" / "package.json" +TS_DIR = REPO / "bindings" / "typescript" +SWIFT_SOURCE = REPO / "bindings" / "swift" / "Sources" / "TranscribeCpp" / "TranscribeCpp.swift" +ABIHASH = REPO / "include" / "transcribe.abihash" + +# --- tooling invoked for regeneration + the --check gate --------------------- +GENERATE_PY = REPO / "bindings" / "python" / "_generate" / "generate.py" +CHECK_SYNC = REPO / "bindings" / "python" / "_generate" / "check_version_sync.py" +SWIFT_ABIHASH = REPO / "scripts" / "ci" / "swift_abihash_check.py" +LIBCLANG = "libclang==18.1.1" + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +# --- small helpers ----------------------------------------------------------- + +def _edit(path: Path, pattern: str, repl: str, *, expected: int, flags: int = 0) -> None: + """Apply a regex substitution in-place, asserting ``expected`` replacements. + + A silent no-match (a spot that moved or was reformatted) is a hard error: it + is exactly the "forgot to bump X" failure this tool exists to prevent. + """ + text = path.read_text() + new, n = re.subn(pattern, repl, text, flags=flags) + if n != expected: + raise SystemExit( + f"error: {path.relative_to(REPO)}: pattern {pattern!r} matched {n} " + f"time(s), expected {expected} — the file shape changed; update " + f"prepare.py." + ) + if new != text: + path.write_text(new) + + +def _run(cmd: list[str], *, cwd: Path | None = None, quiet: bool = False) -> bool: + """Run a command; return True on exit 0. Streams output unless ``quiet``.""" + kw = {"cwd": str(cwd)} if cwd else {} + if quiet: + kw["stdout"] = subprocess.DEVNULL + proc = subprocess.run(cmd, **kw) # noqa: S603 — fixed argv, no shell + return proc.returncode == 0 + + +# --- write mode -------------------------------------------------------------- + +def write_version(version: str) -> int: + if not VERSION_RE.match(version): + raise SystemExit( + f"error: version {version!r} must be a clean MAJOR.MINOR.PATCH triple " + f"(the header macros cannot express a packaging suffix)." + ) + major, minor, patch = version.split(".") + + # include/transcribe.h — the source of truth. + _edit(HEADER, r"(#define TRANSCRIBE_VERSION_MAJOR )\d+", rf"\g<1>{major}", expected=1) + _edit(HEADER, r"(#define TRANSCRIBE_VERSION_MINOR )\d+", rf"\g<1>{minor}", expected=1) + _edit(HEADER, r"(#define TRANSCRIBE_VERSION_PATCH )\d+", rf"\g<1>{patch}", expected=1) + + # Rust manifests: sys [package].version, safe [package].version + sys pin. + _edit(ROOT_CARGO, r'(?m)^version = "[^"]+"', f'version = "{version}"', expected=1) + _edit(SAFE_CARGO, r'(?m)^version = "[^"]+"', f'version = "{version}"', expected=1) + _edit(SAFE_CARGO, r'(transcribe-cpp-sys = \{ version = ")[^"]+(")', + rf"\g<1>{version}\g<2>", expected=1) + + # Cargo.lock: both workspace-crate entries (edited directly so this stays + # offline + deterministic; `cargo metadata --locked` in --check proves it). + for name in ("transcribe-cpp", "transcribe-cpp-sys"): + _edit(CARGO_LOCK, rf'(name = "{re.escape(name)}"\nversion = ")[^"]+(")', + rf"\g<1>{version}\g<2>", expected=1) + + # Python: [project].version, __version__, and both native-provider pins. + _edit(PYPROJECT, r'(?m)^version = "[^"]+"', f'version = "{version}"', expected=1) + _edit(PYPROJECT, r"(transcribe-cpp-native(?:-[a-z0-9]+)*==)[0-9.]+(\.\*)", + rf"\g<1>{version}\g<2>", expected=2) + _edit(INIT, r'(?m)^(__version__ = ")[^"]+(")', rf"\g<1>{version}\g<2>", expected=1) + + # npm: top-level version + the 5 @transcribe-cpp/* optionalDependencies pins + # (targeted edits preserve package.json's hand formatting; the lock is + # regenerated below). The top-level "version" is the first such key. + _edit(PACKAGE_JSON, r'("version"\s*:\s*")[^"]+(")', rf"\g<1>{version}\g<2>", expected=1) + _edit(PACKAGE_JSON, r'("@transcribe-cpp/[^"]+"\s*:\s*")[^"]+(")', + rf"\g<1>{version}\g<2>", expected=5) + + # Swift: the hand-maintained compiledVersion literal (the ABI pin is not + # version-coupled post-§8-P0-#1 and is left to the regeneration check below). + _edit(SWIFT_SOURCE, r'(compiledVersion = ")[^"]+(")', rf"\g<1>{version}\g<2>", expected=1) + + print(f"wrote version {version} into every §1b spot") + + # package-lock.json: regenerate metadata only (no install, no scripts), as + # notes/releasing.md §3 prescribes. Updates root .version + .packages[""]. + print("syncing bindings/typescript/package-lock.json ...") + if not _run(["npm", "install", "--package-lock-only", "--ignore-scripts"], cwd=TS_DIR): + raise SystemExit( + "error: `npm install --package-lock-only --ignore-scripts` failed — " + "package-lock.json was not synced. Is npm on PATH?" + ) + + # Regenerate the FFI. Post-§8-P0-#1 a version-only bump yields no diff; a + # moved abihash means a real ABI change slipped in and needs a Swift pin bump. + before = ABIHASH.read_text() if ABIHASH.exists() else "" + print("regenerating the FFI (generate.py, cargo xtask bindgen) ...") + if not _run(["uv", "run", "--no-project", "--with", LIBCLANG, str(GENERATE_PY)]): + raise SystemExit("error: generate.py failed") + if not _run(["cargo", "xtask", "bindgen"], cwd=REPO): + raise SystemExit("error: cargo xtask bindgen failed") + after = ABIHASH.read_text() if ABIHASH.exists() else "" + if before != after: + print( + "\n*** NOTE: include/transcribe.abihash MOVED — this is a REAL ABI " + "change, not a version-only bump. Review what changed, audit the " + "bindings, and update the Swift pinnedHeaderHash in\n" + " bindings/swift/Sources/TranscribeCpp/ABIHash.swift\n" + f"to the new value ({after.strip()}) after a conscious review.\n", + file=sys.stderr, + ) + + print(f"\nprepared {version}. Now run: uv run --no-project scripts/release/prepare.py --check") + return 0 + + +# --- check mode -------------------------------------------------------------- + +def check() -> int: + """The umbrella hard-tier release-consistency gate (releasing.md §1c).""" + gates = [ + ("version sync (every §1b spot)", + ["uv", "run", "--no-project", str(CHECK_SYNC)], None, False), + ("python/ts FFI + abihash drift", + ["uv", "run", "--no-project", "--with", LIBCLANG, str(GENERATE_PY), "--check"], None, False), + ("rust FFI drift", + ["cargo", "xtask", "bindgen", "--check"], REPO, False), + ("swift ABI pin", + ["uv", "run", "--no-project", str(SWIFT_ABIHASH)], None, False), + ("Cargo.lock freshness (cargo metadata --locked)", + ["cargo", "metadata", "--locked", "--format-version", "1"], REPO, True), + ("package-lock.json freshness (npm ci)", + ["npm", "ci", "--ignore-scripts"], TS_DIR, True), + ] + results: list[tuple[str, bool]] = [] + for label, cmd, cwd, quiet in gates: + print(f"\n=== {label} ===") + ok = _run(cmd, cwd=cwd, quiet=quiet) + results.append((label, ok)) + + print("\n--- prepare.py --check summary ---") + failed = 0 + for label, ok in results: + print(f" {'PASS' if ok else 'FAIL'} {label}") + failed += not ok + if failed: + print(f"\n{failed} gate(s) failed — the tree is NOT release-consistent.", file=sys.stderr) + return 1 + print("\nall gates passed — the tree is release-consistent.") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("version", nargs="?", help="the X.Y.Z version to write everywhere") + g.add_argument("--check", action="store_true", + help="verify the tree is release-consistent (no writes)") + args = ap.parse_args() + return check() if args.check else write_version(args.version) + + +if __name__ == "__main__": + raise SystemExit(main())