fix: harden npm distribution release and installer

- release.yml: build static musl Linux binaries (no host glibc
  requirement), add darwin-x64, publish SHA256SUMS, allow
  workflow_dispatch for an existing tag (v0.2.3 has no binaries yet),
  and run build-only on PRs that touch the workflow or installer
- install.js: verify downloads against the release SHA256SUMS
  (IDX_BINARY_SHA256 for overrides), add a 60s request timeout, use
  stream.pipeline so truncated downloads fail, and skip the download
  inside the source checkout
- idx.js: forward SIGTERM/SIGHUP to the binary and keep the signal
  exit status
- package.json: drop the prepare script (no Rust toolchain needed to
  pack/publish)
- npm-smoke.sh: read the tarball name from npm pack, use grep over rg
- .gitignore: ignore /bin/ and *.tgz
- docs: musl targets, checksums, pnpm/bun opt-in, manual release flow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a5Cv5Z1NzdYKgKnTiAF4E
This commit is contained in:
hermes 2026-09-24 18:57:53 +00:00
commit 4f8c90a108
7 changed files with 191 additions and 41 deletions

View file

@ -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

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
node_modules/
/bin/
*.tgz
target/
.direnv/
.claude/

View file

@ -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`.

View file

@ -27,7 +27,6 @@
],
"scripts": {
"postinstall": "node scripts/install.js",
"prepare": "cargo build --release --locked",
"smoke": "bash scripts/npm-smoke.sh"
}
}

View file

@ -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;
}

View file

@ -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}`);
}

View file

@ -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