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
70 changes: 59 additions & 11 deletions .github/workflows/release_napi.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Release workflow for the N-API npm packages — the @fuzdev/tsv loader +
# the five @fuzdev/tsv-<triple> platform packages. This is the per-platform
# the six @fuzdev/tsv-<triple> platform packages. This is the per-platform
# matrix the single-machine scripts/publish.ts deliberately does NOT own:
# publish.ts releases the WASM set and pushes the annotated v* tag, and that tag
# triggers this workflow. The tag is also the assertion that the local
Expand All @@ -11,7 +11,7 @@
# a process that owns its own crash, the same artifact the hyperfine benches
# measure), stage the platform package (addon + CLI binary), gate it (glibc
# floor / musl purity over BOTH binaries, size bounds, the npm-shape test over
# the real artifacts), upload. The publish job downloads all five, stages the
# the real artifacts), upload. The publish job downloads all six, stages the
# loader, re-arms the CLI binaries' executable bit (artifact transport drops
# file modes), and publishes platforms-then-loader idempotently
# (scripts/publish_napi.ts) — a partial matrix never publishes anything.
Expand All @@ -31,7 +31,7 @@
#
# Auth: NPM_TOKEN secret (granular, publish-only) for the bootstrap releases —
# npm trusted publishing is only configurable on packages that already exist.
# Once the six packages are live, configure Trusted Publishers and switch this
# Once the seven packages are live, configure Trusted Publishers and switch this
# to OIDC, dropping the token. `id-token: write` is already scoped on the
# publish job for `npm --provenance`.
#
Expand Down Expand Up @@ -90,8 +90,8 @@ defaults:
jobs:
build:
strategy:
# Publish requires ALL five artifacts (a partial set never publishes),
# but let every target report — a one-target failure with four green
# Publish requires ALL six artifacts (a partial set never publishes),
# but let every target report — a one-target failure with five green
# uploads means a re-run only rebuilds what changed.
fail-fast: false
matrix:
Expand All @@ -108,6 +108,18 @@ jobs:
- triple: darwin-arm64
runner: macos-15
build: native
# The one row whose staged triple is not its host's: Intel macOS
# runners are being retired, so darwin-x64 CROSS-COMPILES on the
# arm64 mac runner (`target` names the rust target; cargo then writes
# under target/<target>/) and runs its npm-shape test under x64 Node
# through Rosetta 2 (`node_arch`), where `process.arch` reads `x64` and
# the loader resolves the staged package. Rosetta training for a PGO
# leg is the same mechanism, unverified until that leg exists.
- triple: darwin-x64
runner: macos-15
build: native
target: x86_64-apple-darwin
node_arch: x64
- triple: win32-x64
runner: windows-2025
build: native
Expand Down Expand Up @@ -137,9 +149,23 @@ jobs:
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master (no semver tags; bump manually)
with:
toolchain: 1.94.1
# The cross row's std; empty (no `target`) on the native-host rows.
targets: ${{ matrix.target }}

- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
if: matrix.build == 'native'
with:
# Two rows share the macos-15 runner (darwin-arm64 and the darwin-x64
# cross row) and must not share a target-dir cache.
key: ${{ matrix.triple }}

# The cross row's test half runs x64 Node under Rosetta 2. The hosted arm64
# mac images ship it; this asks before the build rather than at the test
# step an hour in (`arch -x86_64` exits non-zero when no x64 slice can
# run), and installs it if an image ever stops shipping it.
- name: Rosetta 2 (cross rows)
if: matrix.node_arch
run: arch -x86_64 /usr/bin/true || sudo softwareupdate --install-rosetta --agree-to-license

# The container rows' cache: cargo/rustup homes + target live under the
# workspace (mounted into the container) so actions/cache can carry them.
Expand All @@ -161,19 +187,28 @@ jobs:
deno-version: 2.9.6

# For the npm-shape test (`node --test` executes the .ts directly via
# native type stripping, >= 22.18). Pinned to match check.yml.
# native type stripping, >= 22.18). Pinned to match check.yml. The cross
# row asks for the x64 build of Node (run under Rosetta); the empty
# default everywhere else is the runner's own architecture.
- name: Install Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24.14.1'
architecture: ${{ matrix.node_arch }}

# Each leg builds BOTH shipped binaries in one environment: the addon
# (napi profile) and the tsv_cli binary (release profile — abort + LTO).
# A row with a `target` cross-compiles; cargo then writes under
# target/<target>/, which the stage step names.
- name: Build (native)
if: matrix.build == 'native'
env:
TARGET: ${{ matrix.target }}
run: |
cargo build --locked -p tsv_napi --profile napi
cargo build --locked -p tsv_cli --release
flags=()
if [ -n "$TARGET" ]; then flags+=(--target "$TARGET"); fi
cargo build --locked -p tsv_napi --profile napi "${flags[@]}"
cargo build --locked -p tsv_cli --release "${flags[@]}"

- name: Build (almalinux:8 — glibc 2.28 floor)
if: matrix.build == 'gnu-container'
Expand Down Expand Up @@ -244,15 +279,28 @@ jobs:
fi
done

# The cross row names its binaries: the staging script's defaults follow the
# HOST (target/napi/, target/release/), and a cross build lands under
# target/<target>/ instead. The `.dylib` spelling is the mac's, and the one
# cross row is a mac row.
- name: Stage platform package
run: deno run --allow-read --allow-write=crates/tsv_napi/pkg scripts/build_napi_packages.ts --triple ${{ matrix.triple }}
env:
TARGET: ${{ matrix.target }}
run: |
flags=(--triple ${{ matrix.triple }})
if [ -n "$TARGET" ]; then
flags+=(--artifact "target/$TARGET/napi/libtsv_napi.dylib" --cli-artifact "target/$TARGET/release/tsv")
fi
deno run --allow-read --allow-write=crates/tsv_napi/pkg scripts/build_napi_packages.ts "${flags[@]}"

- name: Size bounds
run: deno run --allow-read scripts/validate_napi_artifact.ts --triple ${{ matrix.triple }}

# The npm-shape test over the REAL artifacts: loader platform-resolution,
# the wasm-parity options surface, the packaged .node, and the `tsv` bin
# dispatching to the packaged CLI binary. (The panic-contract
# dispatching to the packaged CLI binary. On the cross row this is x64
# Node under Rosetta loading the x64 addon and exec'ing the x64 CLI, and
# its `platform_triple()` must equal the staged triple. (The panic-contract
# probe test runs per OS in check.yml's platforms job on every PR; it
# needs a separate probe build, and the contract is profile+attribute
# determined — identical across targets — so it is not re-proven here.)
Expand Down Expand Up @@ -317,7 +365,7 @@ jobs:
- name: Stage loader package
run: deno run --allow-read --allow-write=crates/tsv_napi/pkg scripts/build_napi_packages.ts --loader-only

# Completeness (all five platforms, versions in lockstep), tag↔version
# Completeness (all six platforms, versions in lockstep), tag↔version
# agreement, platforms-then-loader order, and skip-if-already-published
# idempotency all live in the script. A missing NPM_TOKEN secret yields
# an EMPTY token, which `npm view` (public) and `npm publish --dry-run`
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ Each `## Unreleased` section must be non-empty and carry a
npm surface spells one way; the snake_case names stop at 0.3.x and are deprecated on npm with a
pointer to their replacement. Exports, options and the `tsv` bin are unchanged; the Rust crates
keep their `tsv_*` names
- feat: a `darwin-x64` platform package joins the native set, so `@fuzdev/tsv` installs on Intel
Macs and under x64 Node on Apple Silicon instead of throwing at import; its `tsv` CLI binary is
attached to the GitHub Release like the others
- fix: a relational chain such as `x < y > z` no longer prints in a form that re-parses as a
type-argument instantiation when a line break lands after the `>` — tsv keeps a paren pair around
the `<` operand, so the output reads back as the comparison the author wrote
([#1039](https://github.com/fuzdev/tsv/pull/1039))
- fix: restore the CSS parse speed lost in 0.3.0 — a declaration value is ruled out for operator
splitting before its run is tokenized ([#1037](https://github.com/fuzdev/tsv/pull/1037))

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ npm is the package surface (the GitHub Release per tag carries only notes + the

**Naming.** Every npm name tsv publishes is kebab-case — `@fuzdev/tsv`, `@fuzdev/tsv-wasm`, `@fuzdev/tsv-format-wasm`, `@fuzdev/tsv-parse-wasm`, the `@fuzdev/tsv-<triple>` platform packages — one spelling with the `tsv-<triple>` release assets and the `fuzdev.tsv-format` VS Code extension (vsce forbids `_`). The Rust crates stay snake_case (`tsv_wasm`, `tsv_napi`), as does the rest of the `@fuzdev` npm scope; tsv is the one project shipping beside Rust-tooling peers whose WASM editions all spell `-wasm`, so its shipped names follow that convention rather than the ecosystem's. The crate-derived file names inside a package (`tsv_wasm.js`, `tsv_wasm_bg.wasm`, `tsv_napi.node`) are internal and follow the crate.

The **N-API set** — the bare `@fuzdev/tsv` loader + five `@fuzdev/tsv-<triple>` platform packages (see ./crates/tsv_napi/CLAUDE.md §The npm packages). Each platform package ships **two binaries**: the addon (`tsv_napi.node`, `napi` profile) and the real `tsv_cli` binary (`tsv`/`tsv.exe`, plain `release` profile), and the loader's `tsv` bin is a dispatcher (`bin.js`) that execs that binary — `npx tsv` on the native set IS the native CLI (real `--jobs`, parallel discovery), the esbuild/biome shape — falling back to the shared `cli.js` JS mirror (one source with `@fuzdev/tsv-wasm`, bound to the native engine via its own `./index.js` import). The set is staged by `deno task build:napi:packages` and publishes through the tag-triggered `.github/workflows/release_napi.yml`, **never** through the single-machine `scripts/publish.ts`. The v\* tag `publish.ts` pushes triggers the 5-target matrix: container-pinned builds of both binaries (glibc **2.28 floor** on the gnu rows via almalinux:8 — Node's own binary floor; musl in rust:alpine with `-crt-static` off), each gated by the measured glibc-floor / musl-purity checks over both artifacts, per-artifact size bounds (`deno task validate:napi`, tight like `validate:artifacts`), and the npm-shape test over the real artifacts (in node:alpine for musl); then an idempotent platforms-then-loader publish (`scripts/publish_napi.ts` — refuses partial sets, re-arms the CLI binaries' executable bit that artifact transport drops; `deno task publish:napi --dry-run` runs it locally — no `--` separator, which deno task forwards literally and the script's `parseArgs` rejects — where a local run proves staging + the refusal logic and then **stops at the partial-set refusal by design**, since local staging holds only the host platform; the full-set rehearsal is the `workflow_dispatch` dry run). `workflow_dispatch` runs the whole matrix as a dry-run rehearsal by default (`dry_run=false` is the recovery path for a failed tag run — dispatched **on the tag**, since the tag↔version assertion keys on the ref and the Release job keys on it too; a `dry_run=false` dispatch off a branch is **refused** before any target builds, so a branch dispatch can only rehearse), and a weekly cron force-dry-runs it as a rot watch. Auth is a granular `NPM_TOKEN` secret from the bootstrap releases (trusted publishing is only configurable on packages that already exist on the registry); switching the workflow to npm trusted publishing (OIDC) is the pending follow-up.
The **N-API set** — the bare `@fuzdev/tsv` loader + six `@fuzdev/tsv-<triple>` platform packages (see ./crates/tsv_napi/CLAUDE.md §The npm packages). Each platform package ships **two binaries**: the addon (`tsv_napi.node`, `napi` profile) and the real `tsv_cli` binary (`tsv`/`tsv.exe`, plain `release` profile), and the loader's `tsv` bin is a dispatcher (`bin.js`) that execs that binary — `npx tsv` on the native set IS the native CLI (real `--jobs`, parallel discovery), the esbuild/biome shape — falling back to the shared `cli.js` JS mirror (one source with `@fuzdev/tsv-wasm`, bound to the native engine via its own `./index.js` import). The set is staged by `deno task build:napi:packages` and publishes through the tag-triggered `.github/workflows/release_napi.yml`, **never** through the single-machine `scripts/publish.ts`. The v\* tag `publish.ts` pushes triggers the 6-target matrix: container-pinned builds of both binaries (glibc **2.28 floor** on the gnu rows via almalinux:8 — Node's own binary floor; musl in rust:alpine with `-crt-static` off; `darwin-x64` cross-compiled on the arm64 mac runner and tested under x64 Node via Rosetta 2), each gated by the measured glibc-floor / musl-purity checks over both artifacts, per-artifact size bounds (`deno task validate:napi`, tight like `validate:artifacts`), and the npm-shape test over the real artifacts (in node:alpine for musl); then an idempotent platforms-then-loader publish (`scripts/publish_napi.ts` — refuses partial sets, re-arms the CLI binaries' executable bit that artifact transport drops; `deno task publish:napi --dry-run` runs it locally — no `--` separator, which deno task forwards literally and the script's `parseArgs` rejects — where a local run proves staging + the refusal logic and then **stops at the partial-set refusal by design**, since local staging holds only the host platform; the full-set rehearsal is the `workflow_dispatch` dry run). `workflow_dispatch` runs the whole matrix as a dry-run rehearsal by default (`dry_run=false` is the recovery path for a failed tag run — dispatched **on the tag**, since the tag↔version assertion keys on the ref and the Release job keys on it too; a `dry_run=false` dispatch off a branch is **refused** before any target builds, so a branch dispatch can only rehearse), and a weekly cron force-dry-runs it as a rot watch. Auth is a granular `NPM_TOKEN` secret from the bootstrap releases (trusted publishing is only configurable on packages that already exist on the registry); switching the workflow to npm trusted publishing (OIDC) is the pending follow-up.

After a real publish (the tag push, or a `dry_run=false` dispatch on the tag — never the cron or a dry run), the workflow's `release` job creates the **GitHub Release** for the tag. The body is `CHANGELOG.md`'s stamped `## <version>` section (`scripts/release_notes.ts`; `deno task release:notes v<version>` previews it). The assets are every platform package's native `tsv` CLI binary (`tsv-<triple>`, `tsv-win32-x64.exe`) plus a `SHA256SUMS`, pulled back **from the registry** (`scripts/release_assets.ts`; `deno task release:assets v<version> --out <dir>`), so an asset is byte-identical to what npm serves even on a recovery re-run where the matrix rebuilds while the publish step skips versions already live; its registry reads retry for a bounded window, since the job runs seconds after the publish and a just-published version can lag the registry's read path. Every asset, the checksum file included, gets a Sigstore build provenance attestation (`actions/attest-build-provenance`, ahead of the Release so a failed attestation fails before anything is public; `gh attestation verify <file> -R fuzdev/tsv`). The first create attaches the assets in the same call, so a Release is never public without them; on a re-run an existing Release keeps its notes, the assets re-upload with `--clobber`, and the attestation is made again. The job holds the workflow's only `contents: write` and `attestations: write`, and no npm token.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const ast: Root = parse_svelte('<script>const x = 1;</script>');

`@fuzdev/tsv` and `@fuzdev/tsv-wasm` are drop-in swaps.
The native package is prebuilt
for Linux (x64 gnu and musl, arm64 gnu), macOS arm64, and Windows x64.
for Linux (x64 gnu and musl, arm64 gnu), macOS (arm64 and x64), and Windows x64.
As with other wasm packages, browsers must call `await init()`.

The same native `tsv` CLI binaries are attached to each
Expand Down
18 changes: 10 additions & 8 deletions crates/tsv_napi/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,15 @@ into `crates/tsv_napi/pkg/` (gitignored):
execs), + a generated package.json whose `os`/`cpu`/`libc` fields drive
install-time selection. Naming is the ecosystem-universal
`<loader>-<dash triple>` shape (swc's). The set: `linux-x64-gnu`,
`linux-arm64-gnu`, `linux-x64-musl`, `darwin-arm64`, `win32-x64`. One per
invocation by design — a machine can only have built its own triple; the
release workflow runs the script once per matrix target with `--triple` alone —
every row builds on its own architecture (the gnu/musl rows in pinned containers, the
arm row on an arm runner, mac and Windows on native runners), so the
`--artifact`/`--cli-artifact` flags that would name cross-built binaries exist but go
unused today.
`linux-arm64-gnu`, `linux-x64-musl`, `darwin-arm64`, `darwin-x64`, `win32-x64`.
One per invocation by design — a machine can only have built its own triple; the
release workflow runs the script once per matrix target with `--triple` —
every row but one builds on its own architecture (the gnu/musl rows in pinned
containers, the arm row on an arm runner, mac and Windows on native runners).
The exception is `darwin-x64`: Intel macOS runners are being retired, so it
cross-compiles on the arm64 mac runner (`cargo build --target x86_64-apple-darwin`),
names its binaries via `--artifact`/`--cli-artifact`, and runs the npm-shape test under
x64 Node through Rosetta 2, where `platform_triple()` reads the staged triple.

**The loader is ESM**, the same module system as the wasm packages — one
dialect across tsv's whole npm surface, which is what lets shared sources
Expand Down Expand Up @@ -252,7 +254,7 @@ both artifacts; musl in rust:alpine with `-crt-static` off, both gated
GLIBC-free. Then per-artifact size bounds
(`scripts/validate_napi_artifact.ts`, one anchored band per binary) and the
npm-shape test over the real artifacts (node:alpine for musl). The publish
job gathers all five, stages the loader (`--loader-only`), and runs
job gathers all six, stages the loader (`--loader-only`), and runs
`scripts/publish_napi.ts` — completeness (addon + CLI binary per platform)
and version-lockstep checks, re-arming the CLI binaries' executable bit
(artifact transport drops file modes — without it every posix `npx tsv`
Expand Down
2 changes: 1 addition & 1 deletion crates/tsv_napi/npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Requires Node.js 22+ or Bun.
The right platform binary installs automatically (per-platform `optionalDependencies`). Prebuilt platforms:

- `linux-x64-gnu`, `linux-arm64-gnu`, `linux-x64-musl` (Alpine)
- `darwin-arm64`
- `darwin-arm64`, `darwin-x64`
- `win32-x64`

On any other platform the import throws with a pointer at `@fuzdev/tsv-wasm`.
Expand Down
1 change: 1 addition & 0 deletions crates/tsv_napi/npm/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const SUPPORTED = [
'linux-arm64-gnu',
'linux-x64-musl',
'darwin-arm64',
'darwin-x64',
'win32-x64'
];

Expand Down
Loading
Loading