diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abc9f43..6185dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,25 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build and attach binaries to (for example v0.2.3)" + required: true + pull_request: + paths: + - ".github/workflows/release.yml" + - "Cargo.toml" + - "Cargo.lock" + - "package.json" + - "scripts/install.js" permissions: contents: write +env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + jobs: build: name: Build ${{ matrix.asset }} @@ -17,38 +32,49 @@ jobs: matrix: include: - os: ubuntu-latest - target: x86_64-unknown-linux-gnu + target: x86_64-unknown-linux-musl asset: linux-x64 - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl asset: linux-arm64 - os: macos-14 target: aarch64-apple-darwin asset: darwin-arm64 + - os: macos-14 + target: x86_64-apple-darwin + asset: darwin-x64 steps: - name: Check out source uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - name: Install Rust toolchain and target uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Install Linux ARM64 linker - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: sudo apt-get update && sudo apt-get install --yes gcc-aarch64-linux-gnu + - name: Install musl toolchain + if: endsWith(matrix.target, '-linux-musl') + run: sudo apt-get update && sudo apt-get install --yes musl-tools - name: Build release binary shell: bash run: | - if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then - export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + target="${{ matrix.target }}" + if [[ "$target" == *-linux-musl ]]; then + # Bundled SQLite and ring compile C code; build it against musl too. + export "CC_${target//-/_}=musl-gcc" fi - cargo build --release --locked --target "${{ matrix.target }}" + cargo build --release --locked --target "$target" cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}" chmod 0755 "idx-${{ matrix.asset }}" + - name: Check binary runs + if: matrix.target != 'x86_64-apple-darwin' + run: ./idx-${{ matrix.asset }} --help >/dev/null + - name: Upload target artifact uses: actions/upload-artifact@v4 with: @@ -59,6 +85,7 @@ jobs: publish: name: Attach binaries to release needs: build + if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - name: Download target artifacts @@ -67,11 +94,16 @@ jobs: pattern: idx-* merge-multiple: true + - name: Write checksums + run: sha256sum idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 idx-darwin-x64 > SHA256SUMS + - name: Attach binaries to the version tag uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} + tag_name: ${{ env.RELEASE_TAG }} files: | idx-linux-x64 idx-linux-arm64 idx-darwin-arm64 + idx-darwin-x64 + SHA256SUMS diff --git a/.gitignore b/.gitignore index e5145bc..716cccb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +/bin/ +*.tgz target/ .direnv/ .claude/ diff --git a/docs/NPM_DISTRIBUTION.md b/docs/NPM_DISTRIBUTION.md index 38c00d5..dafcd12 100644 --- a/docs/NPM_DISTRIBUTION.md +++ b/docs/NPM_DISTRIBUTION.md @@ -9,9 +9,13 @@ Supported release assets: | npm platform | Rust target | Release asset | | --- | --- | --- | -| Linux x64 | `x86_64-unknown-linux-gnu` | `idx-linux-x64` | -| Linux arm64 | `aarch64-unknown-linux-gnu` | `idx-linux-arm64` | +| Linux x64 | `x86_64-unknown-linux-musl` | `idx-linux-x64` | +| Linux arm64 | `aarch64-unknown-linux-musl` | `idx-linux-arm64` | | macOS arm64 | `aarch64-apple-darwin` | `idx-darwin-arm64` | +| macOS x64 | `x86_64-apple-darwin` | `idx-darwin-x64` | + +The Linux binaries are statically linked against musl, so they do not depend +on the host glibc version. Windows and other platform combinations are out of scope. The postinstall script reports the supported targets when it rejects a platform. @@ -29,6 +33,25 @@ npx idx-cli --help The package version and the release tag must match. For example, package version `0.2.3` downloads assets from the `v0.2.3` GitHub Release. +The postinstall script downloads `SHA256SUMS` from the same release and +refuses to install a binary whose checksum does not match. + +### pnpm and bun + +pnpm 10+ and bun do not run dependency lifecycle scripts by default, so the +binary is never downloaded and `idx` reports that it is missing. Allow the +postinstall script explicitly: + +```bash +# pnpm +pnpm add --global --allow-build=idx-cli idx-cli + +# bun +bun add --global --trust idx-cli +``` + +Plain `npm install` and `npx` run the postinstall script normally. + ## Local smoke test The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, @@ -41,15 +64,31 @@ nix develop -c bash -c 'scripts/npm-smoke.sh' nix develop -c bash -c 'npm pack --dry-run' ``` -The `prepare` npm script builds the Rust release binary when npm packs the -wrapper from this checkout. The `postinstall` script performs the release -download for normal package installation. `IDX_BINARY_URL` accepts an HTTP(S) -URL, a `file:` URL, or an existing local path for local testing. +The `postinstall` script performs the release download for normal package +installation. It skips the download when it runs inside this source checkout +(where `Cargo.toml` exists) unless `IDX_BINARY_URL` is set. `IDX_BINARY_URL` +accepts an HTTP(S) URL, a `file:` URL, or an existing local path for local +testing. Set `IDX_BINARY_SHA256` to verify an override binary as well. ## GitHub Release workflow Pushing a tag that matches `v*` starts -`.github/workflows/release.yml`. The workflow builds all three supported Rust -targets, names the binaries with the asset names above, and attaches them to -the matching GitHub Release. The asset names and URLs must stay aligned with -`scripts/install.js`. +`.github/workflows/release.yml`. The workflow builds all supported Rust +targets, names the binaries with the asset names above, writes `SHA256SUMS`, +and attaches everything to the matching GitHub Release. The asset names and +URLs must stay aligned with `scripts/install.js`. + +To attach binaries to a tag that already exists, run the workflow manually: + +```bash +gh workflow run release.yml -f tag=v0.2.3 +``` + +Pull requests that touch the workflow or the installer run the build jobs +without publishing, so target breakage shows up before a release. + +Release order: + +1. Push the tag, or run the workflow for an existing tag. +2. Confirm the release has all binaries plus `SHA256SUMS`. +3. Run `npm publish`. diff --git a/package.json b/package.json index 1ee9b6f..ceff396 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ ], "scripts": { "postinstall": "node scripts/install.js", - "prepare": "cargo build --release --locked", "smoke": "bash scripts/npm-smoke.sh" } } diff --git a/scripts/idx.js b/scripts/idx.js index a28db2d..42cbc8d 100755 --- a/scripts/idx.js +++ b/scripts/idx.js @@ -16,6 +16,13 @@ if (!fs.existsSync(binaryPath)) { const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" }); +// Pass termination signals from a supervisor (kill, docker stop) to the binary. +for (const signal of ["SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} +// Ctrl+C already reaches the binary through the terminal's process group. +process.on("SIGINT", () => {}); + child.once("error", (error) => { console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`); process.exit(1); @@ -23,6 +30,7 @@ child.once("error", (error) => { child.once("exit", (code, signal) => { if (signal) { + process.removeAllListeners(signal); process.kill(process.pid, signal); return; } diff --git a/scripts/install.js b/scripts/install.js index b0adf03..ececbaf 100755 --- a/scripts/install.js +++ b/scripts/install.js @@ -1,10 +1,12 @@ #!/usr/bin/env node "use strict"; +const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); +const { pipeline } = require("node:stream"); const { URL, fileURLToPath } = require("node:url"); const packageRoot = path.resolve(__dirname, ".."); @@ -16,6 +18,7 @@ const binaryDirectory = path.join(packageRoot, "bin"); const binaryPath = path.join(binaryDirectory, binaryName); const githubRepository = "0xrsydn/idx-cli"; const maxRedirects = 5; +const requestTimeoutMs = 60_000; const targets = { "linux/x64": { @@ -33,6 +36,11 @@ const targets = { triple: "aarch64-apple-darwin", asset: "idx-darwin-arm64", }, + "darwin/x64": { + label: "darwin-x64", + triple: "x86_64-apple-darwin", + asset: "idx-darwin-x64", + }, }; function currentTarget() { @@ -49,8 +57,8 @@ function currentTarget() { return target; } -function releaseAssetUrl(target) { - return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${target.asset}`; +function releaseAssetUrl(assetName) { + return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`; } function ensureBinaryDirectory() { @@ -80,7 +88,7 @@ function replaceWithLocalBinary(sourcePath) { } } -function download(url, destination, redirects = 0) { +function request(url, onResponse, redirects = 0) { if (redirects > maxRedirects) { return Promise.reject(new Error(`too many redirects while downloading ${url}`)); } @@ -89,7 +97,7 @@ function download(url, destination, redirects = 0) { try { parsedUrl = new URL(url); } catch (error) { - return Promise.reject(new Error(`invalid IDX_BINARY_URL: ${error.message}`)); + return Promise.reject(new Error(`invalid download URL: ${error.message}`)); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { @@ -98,7 +106,7 @@ function download(url, destination, redirects = 0) { const client = parsedUrl.protocol === "https:" ? https : http; return new Promise((resolve, reject) => { - const request = client.get( + const req = client.get( parsedUrl, { headers: { @@ -111,7 +119,7 @@ function download(url, destination, redirects = 0) { if (status >= 300 && status < 400 && response.headers.location) { const redirectedUrl = new URL(response.headers.location, parsedUrl).toString(); response.resume(); - download(redirectedUrl, destination, redirects + 1).then(resolve, reject); + request(redirectedUrl, onResponse, redirects + 1).then(resolve, reject); return; } @@ -121,21 +129,69 @@ function download(url, destination, redirects = 0) { return; } - const output = fs.createWriteStream(destination, { mode: 0o755 }); - output.once("finish", resolve); - output.once("error", reject); - response.once("error", reject); - response.pipe(output); + onResponse(response).then(resolve, reject); }, ); - request.once("error", reject); + req.setTimeout(requestTimeoutMs, () => { + req.destroy(new Error(`timed out after ${requestTimeoutMs / 1000}s downloading ${url}`)); + }); + req.once("error", reject); }); } -async function replaceWithDownloadedBinary(url) { +function download(url, destination) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const output = fs.createWriteStream(destination, { mode: 0o755 }); + pipeline(response, output, (error) => (error ? reject(error) : resolve())); + }), + ); +} + +function downloadText(url) { + return request( + url, + (response) => + new Promise((resolve, reject) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.once("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + response.once("error", reject); + }), + ); +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +async function expectedReleaseChecksum(assetName) { + const sums = await downloadText(releaseAssetUrl("SHA256SUMS")); + for (const line of sums.split(/\r?\n/)) { + const [hash, name] = line.trim().split(/\s+\*?/); + if (name === assetName && /^[0-9a-f]{64}$/i.test(hash)) { + return hash.toLowerCase(); + } + } + throw new Error(`SHA256SUMS for v${packageManifest.version} has no entry for ${assetName}`); +} + +function verifyChecksum(filePath, expected, label) { + const actual = sha256File(filePath); + if (actual !== expected.toLowerCase()) { + throw new Error(`checksum mismatch for ${label}: expected ${expected}, got ${actual}`); + } +} + +async function replaceWithDownloadedBinary(url, expectedSha256) { const temporaryPath = temporaryBinaryPath(); try { await download(url, temporaryPath); + if (expectedSha256) { + verifyChecksum(temporaryPath, expectedSha256, url); + } fs.chmodSync(temporaryPath, 0o755); fs.renameSync(temporaryPath, binaryPath); } finally { @@ -165,14 +221,25 @@ function localPathFromOverride(value) { } async function main() { - const target = currentTarget(); const override = process.env.IDX_BINARY_URL; - const source = override || releaseAssetUrl(target); + const overrideSha256 = process.env.IDX_BINARY_SHA256; + + // `npm install` inside the Rust checkout should not fetch a release binary. + if (!override && fs.existsSync(path.join(packageRoot, "Cargo.toml"))) { + console.log("idx-cli: source checkout detected; skipping binary download (set IDX_BINARY_URL to override)"); + return; + } + + const target = currentTarget(); + const source = override || releaseAssetUrl(target.asset); ensureBinaryDirectory(); if (override) { const localPath = localPathFromOverride(override); if (localPath) { + if (overrideSha256) { + verifyChecksum(localPath, overrideSha256, localPath); + } replaceWithLocalBinary(localPath); console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`); return; @@ -182,7 +249,10 @@ async function main() { console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`); } - await replaceWithDownloadedBinary(source); + const expectedSha256 = override + ? overrideSha256 + : await expectedReleaseChecksum(target.asset); + await replaceWithDownloadedBinary(source, expectedSha256); console.log(`idx-cli: installed binary at ${binaryPath}`); } diff --git a/scripts/npm-smoke.sh b/scripts/npm-smoke.sh index 32f1d0c..e6b58ac 100755 --- a/scripts/npm-smoke.sh +++ b/scripts/npm-smoke.sh @@ -3,7 +3,6 @@ set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/idx-cli-npm-smoke.XXXXXX")" -tarball="${smoke_dir}/idx-cli-0.2.3.tgz" install_dir="${smoke_dir}/install" npm_cache="${smoke_dir}/npm-cache" @@ -37,8 +36,9 @@ fi echo "npm smoke: packing wrapper" mkdir -p "$npm_cache" -npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" >/dev/null -if [[ ! -f "$tarball" ]]; then +tarball_name="$(npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" | tail -n 1)" +tarball="${smoke_dir}/${tarball_name}" +if [[ -z "$tarball_name" || ! -f "$tarball" ]]; then echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 exit 1 fi @@ -64,7 +64,7 @@ fi help_output="${smoke_dir}/help.txt" echo "npm smoke: running installed idx --help" "$resolved_bin" --help | tee "$help_output" -if ! rg -q "Usage: idx" "$help_output"; then +if ! grep -q "Usage: idx" "$help_output"; then echo "npm smoke: --help output did not contain the expected usage line" >&2 exit 1 fi