The JSR package ships TypeScript only (~70KB; publish include/exclude keeps the 51MB native prebuilts out). JSR consumers get the native layer from GitHub-release assets: src/native/artifacts.ts (generated by deno task release:artifacts, freshness-gated in CI) pins release asset names + sha256 digests; the loader gains a download-verify-cache step when repo-relative resolution misses (typed MeshUnavailableError on offline/checksum-mismatch; Windows keeps its documented degradation). loadIrohAddon is async now. publish.yml (tag v*.*.*): tag↔version check, artifacts freshness, full gate + suite with cargo-built addon, publish dry-run, deno publish via OIDC, three cross-compiled binaries (darwin-arm64, linux-x64/arm64 — per-triple embeds), gh release with binaries + raw addons + SHA256SUMS, then a fresh-publish registry smoke (CLI run, library import, deno x subpath probe). Docs: README quick start is JSR-first (dx / deno run / install -g / release binaries incl. unsigned-macOS note); CONTRIBUTING gains the release procedure (tags on main, fix-forward); hosting walkthrough install step updated. New artifacts-manifest integrity test; 35 green.
- commit
- 2855974faa29a11c5ce3eec8faaac72cc94843bb
- parent
- 82ae44fac7f99f33a1355c1d4b7bcbe2075901a0
- author
- Dami <dami.etoile@gmail.com>
| A | .github/ | 99 | ++++++++++++++++++++++++++++++ |
| M | CONTRIBUTING.md | 21 | +++++-- |
| M | README.md | 29 | +++++++-- |
| M | deno.json | 25 | +++++++- |
| M | docs/ | 3 | + |
| M | src/ | 88 | ++++++++++++++++++++++----- |
| A | src/ | 22 | +++++++ |
| M | src/ | 2 | +- |
| M | tests/ | 29 | +++++---- |
| A | tools/ | 36 | +++++++++++ |
@@ -0,0 +1,99 @@
1+# Tag a release (e.g. `git tag v0.1.0 && git push origin v0.1.0`) to publish2+# @nzip/lofi-node to JSR and attach compiled binaries + native addon artifacts3+# to the GitHub release. The tag version must match "version" in deno.json.4+name: publish5+6+on:7+ push:8+ tags: ["v*.*.*"]9+10+permissions:11+ contents: write # gh release create + asset upload12+ id-token: write # JSR OIDC publish13+14+jobs:15+ publish:16+ runs-on: ubuntu-latest17+ steps:18+ - uses: actions/checkout@v419+20+ - uses: denoland/setup-deno@v221+ with:22+ deno-version: v2.x23+24+ - name: Tag must match deno.json version25+ run: |26+ tag="${GITHUB_REF_NAME#v}"27+ manifest="$(deno eval 'console.log(JSON.parse(Deno.readTextFileSync("deno.json")).version)')"28+ if [ "$tag" != "$manifest" ]; then29+ echo "::error::tag v$tag does not match deno.json version $manifest"30+ exit 131+ fi32+33+ - name: Release artifacts manifest must be fresh34+ run: |35+ deno task release:artifacts36+ git diff --exit-code src/native/artifacts.ts37+38+ - name: Format, lint, typecheck39+ run: deno task check40+41+ - uses: dtolnay/rust-toolchain@stable42+43+ - uses: Swatinem/rust-cache@v244+ with:45+ workspaces: native/iroh-js46+47+ - name: Build native addon48+ run: deno task native49+50+ - name: Full test suite51+ run: deno task test52+53+ - name: Publish dry run54+ run: deno publish --dry-run --allow-dirty55+56+ - name: Publish to JSR57+ run: deno publish58+59+ - name: Compile release binaries (3 targets)60+ run: |61+ deno task compile:darwin-arm6462+ deno task compile:linux-x6463+ deno task compile:linux-arm6464+65+ - name: Stage release assets66+ run: |67+ mkdir -p dist/assets68+ cp dist/lofi-node-aarch64-apple-darwin dist/assets/69+ cp dist/lofi-node-x86_64-unknown-linux-gnu dist/assets/70+ cp dist/lofi-node-aarch64-unknown-linux-gnu dist/assets/71+ # Raw addon artifacts under the names artifacts.ts pins.72+ cp native/iroh-js/prebuilt/aarch64-apple-darwin/libnumber0_iroh.dylib \73+ dist/assets/libnumber0_iroh-aarch64-apple-darwin.dylib74+ cp native/iroh-js/prebuilt/x86_64-unknown-linux-gnu/libnumber0_iroh.so \75+ dist/assets/libnumber0_iroh-x86_64-unknown-linux-gnu.so76+ cp native/iroh-js/prebuilt/aarch64-unknown-linux-gnu/libnumber0_iroh.so \77+ dist/assets/libnumber0_iroh-aarch64-unknown-linux-gnu.so78+ (cd dist/assets && sha256sum * > SHA256SUMS)79+ cat dist/assets/SHA256SUMS80+81+ - name: Create GitHub release with assets82+ env:83+ GH_TOKEN: ${{ github.token }}84+ run: |85+ gh release create "$GITHUB_REF_NAME" dist/assets/* \86+ --title "$GITHUB_REF_NAME" --generate-notes87+88+ - name: Registry smoke — CLI, library, and dx from a clean dir89+ run: |90+ version="${GITHUB_REF_NAME#v}"91+ smoke="$(mktemp -d)"92+ cd "$smoke"93+ for i in $(seq 1 20); do94+ deno run -A "jsr:@nzip/lofi-node@$version/cli" >/dev/null 2>&1 && break95+ echo "waiting for registry propagation ($i)"; sleep 1596+ done97+ deno run -A "jsr:@nzip/lofi-node@$version/cli" | head -398+ deno eval "import { looksLikeAppTicket } from 'jsr:@nzip/lofi-node@$version'; if (!looksLikeAppTicket('lofisync1.abcdef')) throw new Error('lib smoke failed'); console.log('library import ok')"99+ deno x -A "jsr:@nzip/lofi-node@$version/cli" && echo "deno x subpath form works" || echo "::warning::deno x subpath form failed — README documents deno run/install fallbacks"@@ -34,8 +34,19 @@ the evidence for behavior claims (test name or command output) in the descriptio
3434 - **Never call the upstream watch APIs** from the TS layer — at iroh-ffi v1.1.0 they abort the3535 process under Deno (see UPSTREAM.md known issues).3636 37-## Publishing38-39-Not yet published: the JSR packaging story for a repo that ships native prebuilts is an open40-decision (JSR payload size vs. artifact downloads). Until that lands, consumers use the repo41-directly or `deno task compile`.37+## Releasing38+39+Published to JSR as `@nzip/lofi-node`. The package ships TypeScript only; native addons are40+GitHub-release assets that the loader downloads and sha256-verifies against digests committed in41+`src/native/artifacts.ts` (regenerate with `deno task release:artifacts` — CI fails if stale).42+43+Procedure (tags on `main`, no dev branch):44+45+1. Bump `version` in `deno.json`; run `deno task release:artifacts`.46+2. `deno task check && deno task test`, then inspect the artifact: `deno task publish:dry`.47+3. Commit, push, wait for ci green.48+4. `git tag v<version> && git push origin v<version>` — publish.yml verifies the tag matches49+ `deno.json`, publishes to JSR via OIDC, compiles the three target binaries, and creates the50+ GitHub release with binaries + raw addon assets + `SHA256SUMS`, then smokes the fresh publish51+ from the registry.52+5. JSR versions are immutable — mistakes fix forward as the next patch version.@@ -13,12 +13,31 @@ dependency.
1313 1414 ## Quick start1515 16+Run straight from JSR with `dx` (Deno's npx — `deno x`):17+18+```sh19+dx -A jsr:@nzip/lofi-node/cli init --port 4802 # ticket-gated by default; --open opts out20+dx -A jsr:@nzip/lofi-node/cli start # gate URL + node-pairing ticket21+dx -A jsr:@nzip/lofi-node/cli ticket issue --label phone # app-connect ticket (shown once)22+```23+24+Equivalent forms:25+26+```sh27+deno run -A jsr:@nzip/lofi-node/cli start # plain deno28+deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli # persistent `lofi-node` command29+```30+31+On first start the native iroh layer is downloaded from this repo's GitHub release for the matching32+version and sha256-verified against digests pinned inside the package. No Deno at all? Grab a33+compiled binary from the [releases](https://github.com/FelineStateMachine/lofi-node/releases) (macOS34+arm64, Linux x86_64/aarch64; the macOS binary is unsigned — clear quarantine with35+`xattr -d com.apple.quarantine lofi-node-*`).36+37+As a library:38+1639 ```sh17-deno task compile # one self-contained binary (dist/lofi-node)18-dist/lofi-node init --port 4802 # ticket-gated by default; --open opts out19-dist/lofi-node start # gate URL + node-pairing ticket20-dist/lofi-node ticket issue --label phone # app-connect ticket (shown once)21-dist/lofi-node pair endpoint… # elect another node as upstream40+deno add jsr:@nzip/lofi-node2241 ```2342 2443 The issued `lofisync1.…` ticket carries **location + secret**; the lofi app stores it@@ -23,7 +23,30 @@
2323 "lint": "deno lint",2424 "start": "deno run -A cli.ts start",2525 "compile": "deno compile -A --include native/iroh-js/prebuilt --output dist/lofi-node cli.ts",26- "native": "cargo build --release --manifest-path native/iroh-js/Cargo.toml"26+ "compile:darwin-arm64": "deno compile -A --target aarch64-apple-darwin --include native/iroh-js/prebuilt/aarch64-apple-darwin --output dist/lofi-node-aarch64-apple-darwin cli.ts",27+ "compile:linux-x64": "deno compile -A --target x86_64-unknown-linux-gnu --include native/iroh-js/prebuilt/x86_64-unknown-linux-gnu --output dist/lofi-node-x86_64-unknown-linux-gnu cli.ts",28+ "compile:linux-arm64": "deno compile -A --target aarch64-unknown-linux-gnu --include native/iroh-js/prebuilt/aarch64-unknown-linux-gnu --output dist/lofi-node-aarch64-unknown-linux-gnu cli.ts",29+ "native": "cargo build --release --manifest-path native/iroh-js/Cargo.toml",30+ "release:artifacts": "deno run --allow-read --allow-write=src/native/artifacts.ts tools/release_artifacts.ts",31+ "publish:dry": "deno publish --dry-run"32+ },33+ "publish": {34+ "include": [35+ "LICENSE",36+ "README.md",37+ "mod.ts",38+ "cli.ts",39+ "src",40+ "testing",41+ "docs/app-ticket.md",42+ "docs/hosting-lofi-apps.md",43+ "docs/fixtures"44+ ],45+ "exclude": [46+ "native",47+ "tests",48+ "dist"49+ ]2750 },2851 "fmt": {2952 "lineWidth": 100,@@ -7,6 +7,9 @@ end against lofi's reference app (`apps/reference`) with real Chromium clients.
77 88 ## 1. Host a node99 10+Install once (`deno install -g -A -n lofi-node jsr:@nzip/lofi-node/cli`), run ad hoc with11+`dx -A jsr:@nzip/lofi-node/cli …`, or download a compiled binary from the GitHub releases. Then:12+1013 ```sh1114 lofi-node init --dir ./data --port 4802 --public-url http://192.168.1.10:48021215 lofi-node start --dir ./data@@ -8,6 +8,7 @@
88 import { createRequire } from "node:module";99 import { MeshUnavailableError } from "../errors.ts";1010 import { resolveIrohLib } from "./loader.ts";11+import { RELEASE_ARTIFACTS } from "./artifacts.ts";1112 1213 export interface SecretKey {1314 toBytes(): number[];@@ -156,29 +157,82 @@ function stageNodeArtifact(srcPath: string): string {
156157 return target;157158 }158159 159-/** Load + probe the addon. Throws MeshUnavailableError with a precise reason160- * (no silent degradation — lofi's boot-gate ethos). */161-export function loadIrohAddon(explicitPath?: string): IrohAddon {162- const resolved = resolveIrohLib(explicitPath);163- if (resolved.status === "unsupported-platform") {164- throw new MeshUnavailableError(`no iroh-js build for ${resolved.platform}`);160+/** Fetch the platform's addon from the GitHub release pinned by the161+ * committed artifacts.ts, verify its sha256 against the in-package digest162+ * (the JSR artifact pins the binaries it will accept), and stage it into the163+ * version-keyed OS cache. This is how JSR consumers get the native layer —164+ * the package itself ships no binaries. */165+async function downloadReleaseArtifact(platform: string): Promise<string> {166+ const asset = (RELEASE_ARTIFACTS.assets as Record<string, { file: string; sha256: string }>)[167+ platform168+ ];169+ if (!asset) {170+ throw new MeshUnavailableError(`no release artifact for ${platform}`);165171 }166- if (resolved.status === "not-found") {172+ const dir = `${osCacheDir()}/lofi-node/iroh-js-release-${RELEASE_ARTIFACTS.version}-${platform}`;173+ const target = `${dir}/iroh.node`;174+ try {175+ Deno.statSync(target);176+ return target; // version-keyed: verified at download time177+ } catch {178+ // download below179+ }180+ const url =181+ `https://github.com/FelineStateMachine/lofi-node/releases/download/v${RELEASE_ARTIFACTS.version}/${asset.file}`;182+ let bytes: Uint8Array;183+ try {184+ const res = await fetch(url);185+ if (!res.ok) {186+ await res.body?.cancel();187+ throw new Error(`HTTP ${res.status}`);188+ }189+ bytes = new Uint8Array(await res.arrayBuffer());190+ } catch (e) {167191 throw new MeshUnavailableError(168- `iroh-js addon not found; tried: ${resolved.tried.join(", ")} — build with ` +169- `\`cargo build --release\` in native/iroh-js, or set LOFI_NODE_IROH`,192+ `could not download native addon from ${url}: ${(e as Error).message}`,170193 );171194 }195+ const digest = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer);196+ const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");197+ if (sha256 !== asset.sha256) {198+ throw new MeshUnavailableError(199+ `native addon checksum mismatch for ${asset.file}: got ${sha256.slice(0, 16)}…, ` +200+ `expected ${asset.sha256.slice(0, 16)}… — refusing to load`,201+ );202+ }203+ Deno.mkdirSync(dir, { recursive: true });204+ const tmp = `${target}.${Deno.pid}.tmp`;205+ Deno.writeFileSync(tmp, bytes);206+ Deno.renameSync(tmp, target);207+ return target;208+}209+210+/** Load + probe the addon. Resolution: env/explicit → cargo build output →211+ * in-repo prebuilt → GitHub-release download (JSR consumers). Throws212+ * MeshUnavailableError with a precise reason (no silent degradation —213+ * lofi's boot-gate ethos). */214+export async function loadIrohAddon(explicitPath?: string): Promise<IrohAddon> {215+ const resolved = resolveIrohLib(explicitPath);216+ if (resolved.status === "unsupported-platform") {217+ throw new MeshUnavailableError(`no iroh-js build for ${resolved.platform}`);218+ }172219 // require() only loads native addons from `.node` files — cargo emits173220 // .dylib/.so/.dll, and a compiled binary's embedded artifacts aren't174221 // directly loadable at all; stage a real .node file either way.175222 let loadPath: string;176- try {177- loadPath = stageNodeArtifact(resolved.path);178- } catch (e) {179- throw new MeshUnavailableError(180- `could not stage .node artifact for ${resolved.path}: ${(e as Error).message}`,181- );223+ if (resolved.status === "not-found") {224+ // Running from the JSR cache (no repo checkout): fetch the pinned225+ // release artifact instead.226+ const platform = `${Deno.build.os}-${Deno.build.arch}`;227+ loadPath = await downloadReleaseArtifact(platform);228+ } else {229+ try {230+ loadPath = stageNodeArtifact(resolved.path);231+ } catch (e) {232+ throw new MeshUnavailableError(233+ `could not stage .node artifact for ${resolved.path}: ${(e as Error).message}`,234+ );235+ }182236 }183237 let addon: IrohAddon;184238 try {@@ -189,11 +243,11 @@ export function loadIrohAddon(explicitPath?: string): IrohAddon {
189243 // Probe: core surface + the lofi_ext marker prove this is OUR build, not a190244 // stock upstream artifact.191245 if (typeof addon.Endpoint?.builder !== "function") {192- throw new MeshUnavailableError(`addon at ${resolved.path} lacks the Endpoint surface`);246+ throw new MeshUnavailableError(`addon at ${loadPath} lacks the Endpoint surface`);193247 }194248 if (typeof addon.maxFrame !== "function" || typeof addon.writeFrame !== "function") {195249 throw new MeshUnavailableError(196- `addon at ${resolved.path} lacks lofi_ext framing — built from stock upstream?`,250+ `addon at ${loadPath} lacks lofi_ext framing — built from stock upstream?`,197251 );198252 }199253 return addon;@@ -0,0 +1,22 @@
1+// GENERATED by tools/release_artifacts.ts — do not edit by hand.2+// Release-asset names + sha256 digests for the native addon; the loader3+// downloads these from the GitHub release for version 0.1.0 and refuses4+// anything that does not match. Regenerate: deno task release:artifacts5+6+export const RELEASE_ARTIFACTS = {7+ "version": "0.1.0",8+ "assets": {9+ "darwin-aarch64": {10+ "file": "libnumber0_iroh-aarch64-apple-darwin.dylib",11+ "sha256": "5c63bde32c5319312285509736d99f0c57b00111bd5936812a552268fe2188eb",12+ },13+ "linux-x86_64": {14+ "file": "libnumber0_iroh-x86_64-unknown-linux-gnu.so",15+ "sha256": "1244634b48aba29ccce992be7d7ba69e3d4bb6f33d07368811fe604969c6a08d",16+ },17+ "linux-aarch64": {18+ "file": "libnumber0_iroh-aarch64-unknown-linux-gnu.so",19+ "sha256": "00ebb991e4310eef7a379bc9676da496f78b96d815832187ef043ccf5f62366e",20+ },21+ },22+} as const;@@ -155,7 +155,7 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
155155 let ticketString: string | null = null;156156 if (meshMode === "auto") {157157 try {158- const addon = loadIrohAddon(options.irohLibPath);158+ const addon = await loadIrohAddon(options.irohLibPath);159159 const key = options.dataDir160160 ? await loadOrCreateIrohKey(options.dataDir)161161 : crypto.getRandomValues(new Uint8Array(32));@@ -18,7 +18,7 @@ Deno.test({
1818 sanitizeOps: false,1919 sanitizeResources: false,2020 fn: async () => {21- const addon = loadIrohAddon();21+ const addon = await loadIrohAddon();2222 2323 // Stand-in for the acceptor's local JazzServer: a WS echo server.2424 const echo = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {@@ -75,15 +75,20 @@ Deno.test({
7575 },7676 });7777 78-Deno.test({79- name: "loader reports a typed unavailable error rather than degrading silently",80- ignore: available,81- fn: () => {82- try {83- loadIrohAddon();84- throw new Error("expected MeshUnavailableError");85- } catch (e) {86- if (!(e instanceof MeshUnavailableError)) throw e;87- }88- },78+Deno.test("release artifacts manifest matches deno.json version and prebuilt digests", async () => {79+ const { RELEASE_ARTIFACTS } = await import("../src/native/artifacts.ts");80+ const denoJson = JSON.parse(await Deno.readTextFile(new URL("../deno.json", import.meta.url)));81+ assertEquals(82+ RELEASE_ARTIFACTS.version,83+ denoJson.version,84+ "run `deno task release:artifacts` after a version bump",85+ );86+ for (const [platform, asset] of Object.entries(RELEASE_ARTIFACTS.assets)) {87+ assertEquals(asset.sha256.length, 64, `${platform} digest is sha256 hex`);88+ assertEquals(89+ /^libnumber0_iroh-[a-z0-9_]+-[a-z-]+\.(dylib|so)$/.test(asset.file),90+ true,91+ `${platform} asset name shape: ${asset.file}`,92+ );93+ }8994 });@@ -0,0 +1,36 @@
1+// Regenerates src/native/artifacts.ts from native/iroh-js/prebuilt/ and2+// deno.json: the release-asset names and sha256 digests the JSR-published3+// loader will accept when downloading the native addon from the GitHub4+// release. CI verifies the file is fresh (`git diff --exit-code`).5+//6+// Run: deno task release:artifacts7+8+const TRIPLES: Record<string, { dir: string; ext: string }> = {9+ "darwin-aarch64": { dir: "aarch64-apple-darwin", ext: "dylib" },10+ "linux-x86_64": { dir: "x86_64-unknown-linux-gnu", ext: "so" },11+ "linux-aarch64": { dir: "aarch64-unknown-linux-gnu", ext: "so" },12+};13+14+const version = JSON.parse(await Deno.readTextFile("deno.json")).version as string;15+16+const assets: Record<string, { file: string; sha256: string }> = {};17+for (const [platform, { dir, ext }] of Object.entries(TRIPLES)) {18+ const source = `native/iroh-js/prebuilt/${dir}/libnumber0_iroh.${ext}`;19+ const bytes = await Deno.readFile(source);20+ const digest = await crypto.subtle.digest("SHA-256", bytes);21+ const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");22+ assets[platform] = { file: `libnumber0_iroh-${dir}.${ext}`, sha256 };23+}24+25+const banner = `// GENERATED by tools/release_artifacts.ts — do not edit by hand.26+// Release-asset names + sha256 digests for the native addon; the loader27+// downloads these from the GitHub release for version ${version} and refuses28+// anything that does not match. Regenerate: deno task release:artifacts29+30+export const RELEASE_ARTIFACTS = ${JSON.stringify({ version, assets }, null, 2)} as const;31+`;32+await Deno.writeTextFile("src/native/artifacts.ts", banner);33+console.log(`src/native/artifacts.ts regenerated for v${version}`);34+for (const [platform, asset] of Object.entries(assets)) {35+ console.log(` ${platform}: ${asset.file} ${asset.sha256.slice(0, 16)}…`);36+}