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: push:
tags: tags:
- "v*" - "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: permissions:
contents: write contents: write
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
jobs: jobs:
build: build:
name: Build ${{ matrix.asset }} name: Build ${{ matrix.asset }}
@ -17,38 +32,49 @@ jobs:
matrix: matrix:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-musl
asset: linux-x64 asset: linux-x64
- os: ubuntu-latest - os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-musl
asset: linux-arm64 asset: linux-arm64
- os: macos-14 - os: macos-14
target: aarch64-apple-darwin target: aarch64-apple-darwin
asset: darwin-arm64 asset: darwin-arm64
- os: macos-14
target: x86_64-apple-darwin
asset: darwin-x64
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Install Rust toolchain and target - name: Install Rust toolchain and target
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Install Linux ARM64 linker - name: Install musl toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu' if: endsWith(matrix.target, '-linux-musl')
run: sudo apt-get update && sudo apt-get install --yes gcc-aarch64-linux-gnu run: sudo apt-get update && sudo apt-get install --yes musl-tools
- name: Build release binary - name: Build release binary
shell: bash shell: bash
run: | run: |
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then target="${{ matrix.target }}"
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc if [[ "$target" == *-linux-musl ]]; then
# Bundled SQLite and ring compile C code; build it against musl too.
export "CC_${target//-/_}=musl-gcc"
fi fi
cargo build --release --locked --target "${{ matrix.target }}" cargo build --release --locked --target "$target"
cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/idx" "idx-${{ matrix.asset }}"
chmod 0755 "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 - name: Upload target artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@ -59,6 +85,7 @@ jobs:
publish: publish:
name: Attach binaries to release name: Attach binaries to release
needs: build needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download target artifacts - name: Download target artifacts
@ -67,11 +94,16 @@ jobs:
pattern: idx-* pattern: idx-*
merge-multiple: true 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 - name: Attach binaries to the version tag
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ env.RELEASE_TAG }}
files: | files: |
idx-linux-x64 idx-linux-x64
idx-linux-arm64 idx-linux-arm64
idx-darwin-arm64 idx-darwin-arm64
idx-darwin-x64
SHA256SUMS

2
.gitignore vendored
View file

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

View file

@ -9,9 +9,13 @@ Supported release assets:
| npm platform | Rust target | Release asset | | npm platform | Rust target | Release asset |
| --- | --- | --- | | --- | --- | --- |
| Linux x64 | `x86_64-unknown-linux-gnu` | `idx-linux-x64` | | Linux x64 | `x86_64-unknown-linux-musl` | `idx-linux-x64` |
| Linux arm64 | `aarch64-unknown-linux-gnu` | `idx-linux-arm64` | | Linux arm64 | `aarch64-unknown-linux-musl` | `idx-linux-arm64` |
| macOS arm64 | `aarch64-apple-darwin` | `idx-darwin-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 Windows and other platform combinations are out of scope. The postinstall
script reports the supported targets when it rejects a platform. 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 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. 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 ## Local smoke test
The smoke script builds or reuses `target/release/idx`, packs the npm wrapper, 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' nix develop -c bash -c 'npm pack --dry-run'
``` ```
The `prepare` npm script builds the Rust release binary when npm packs the The `postinstall` script performs the release download for normal package
wrapper from this checkout. The `postinstall` script performs the release installation. It skips the download when it runs inside this source checkout
download for normal package installation. `IDX_BINARY_URL` accepts an HTTP(S) (where `Cargo.toml` exists) unless `IDX_BINARY_URL` is set. `IDX_BINARY_URL`
URL, a `file:` URL, or an existing local path for local testing. 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 ## GitHub Release workflow
Pushing a tag that matches `v*` starts Pushing a tag that matches `v*` starts
`.github/workflows/release.yml`. The workflow builds all three supported Rust `.github/workflows/release.yml`. The workflow builds all supported Rust
targets, names the binaries with the asset names above, and attaches them to targets, names the binaries with the asset names above, writes `SHA256SUMS`,
the matching GitHub Release. The asset names and URLs must stay aligned with and attaches everything to the matching GitHub Release. The asset names and
`scripts/install.js`. 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": { "scripts": {
"postinstall": "node scripts/install.js", "postinstall": "node scripts/install.js",
"prepare": "cargo build --release --locked",
"smoke": "bash scripts/npm-smoke.sh" "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" }); 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) => { child.once("error", (error) => {
console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`); console.error(`idx-cli: failed to start ${binaryPath}: ${error.message}`);
process.exit(1); process.exit(1);
@ -23,6 +30,7 @@ child.once("error", (error) => {
child.once("exit", (code, signal) => { child.once("exit", (code, signal) => {
if (signal) { if (signal) {
process.removeAllListeners(signal);
process.kill(process.pid, signal); process.kill(process.pid, signal);
return; return;
} }

View file

@ -1,10 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
"use strict"; "use strict";
const crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const http = require("node:http"); const http = require("node:http");
const https = require("node:https"); const https = require("node:https");
const path = require("node:path"); const path = require("node:path");
const { pipeline } = require("node:stream");
const { URL, fileURLToPath } = require("node:url"); const { URL, fileURLToPath } = require("node:url");
const packageRoot = path.resolve(__dirname, ".."); const packageRoot = path.resolve(__dirname, "..");
@ -16,6 +18,7 @@ const binaryDirectory = path.join(packageRoot, "bin");
const binaryPath = path.join(binaryDirectory, binaryName); const binaryPath = path.join(binaryDirectory, binaryName);
const githubRepository = "0xrsydn/idx-cli"; const githubRepository = "0xrsydn/idx-cli";
const maxRedirects = 5; const maxRedirects = 5;
const requestTimeoutMs = 60_000;
const targets = { const targets = {
"linux/x64": { "linux/x64": {
@ -33,6 +36,11 @@ const targets = {
triple: "aarch64-apple-darwin", triple: "aarch64-apple-darwin",
asset: "idx-darwin-arm64", asset: "idx-darwin-arm64",
}, },
"darwin/x64": {
label: "darwin-x64",
triple: "x86_64-apple-darwin",
asset: "idx-darwin-x64",
},
}; };
function currentTarget() { function currentTarget() {
@ -49,8 +57,8 @@ function currentTarget() {
return target; return target;
} }
function releaseAssetUrl(target) { function releaseAssetUrl(assetName) {
return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${target.asset}`; return `https://github.com/${githubRepository}/releases/download/v${packageManifest.version}/${assetName}`;
} }
function ensureBinaryDirectory() { function ensureBinaryDirectory() {
@ -80,7 +88,7 @@ function replaceWithLocalBinary(sourcePath) {
} }
} }
function download(url, destination, redirects = 0) { function request(url, onResponse, redirects = 0) {
if (redirects > maxRedirects) { if (redirects > maxRedirects) {
return Promise.reject(new Error(`too many redirects while downloading ${url}`)); return Promise.reject(new Error(`too many redirects while downloading ${url}`));
} }
@ -89,7 +97,7 @@ function download(url, destination, redirects = 0) {
try { try {
parsedUrl = new URL(url); parsedUrl = new URL(url);
} catch (error) { } 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)) { if (!["http:", "https:"].includes(parsedUrl.protocol)) {
@ -98,7 +106,7 @@ function download(url, destination, redirects = 0) {
const client = parsedUrl.protocol === "https:" ? https : http; const client = parsedUrl.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = client.get( const req = client.get(
parsedUrl, parsedUrl,
{ {
headers: { headers: {
@ -111,7 +119,7 @@ function download(url, destination, redirects = 0) {
if (status >= 300 && status < 400 && response.headers.location) { if (status >= 300 && status < 400 && response.headers.location) {
const redirectedUrl = new URL(response.headers.location, parsedUrl).toString(); const redirectedUrl = new URL(response.headers.location, parsedUrl).toString();
response.resume(); response.resume();
download(redirectedUrl, destination, redirects + 1).then(resolve, reject); request(redirectedUrl, onResponse, redirects + 1).then(resolve, reject);
return; return;
} }
@ -121,21 +129,69 @@ function download(url, destination, redirects = 0) {
return; return;
} }
const output = fs.createWriteStream(destination, { mode: 0o755 }); onResponse(response).then(resolve, reject);
output.once("finish", resolve);
output.once("error", reject);
response.once("error", reject);
response.pipe(output);
}, },
); );
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(); const temporaryPath = temporaryBinaryPath();
try { try {
await download(url, temporaryPath); await download(url, temporaryPath);
if (expectedSha256) {
verifyChecksum(temporaryPath, expectedSha256, url);
}
fs.chmodSync(temporaryPath, 0o755); fs.chmodSync(temporaryPath, 0o755);
fs.renameSync(temporaryPath, binaryPath); fs.renameSync(temporaryPath, binaryPath);
} finally { } finally {
@ -165,14 +221,25 @@ function localPathFromOverride(value) {
} }
async function main() { async function main() {
const target = currentTarget();
const override = process.env.IDX_BINARY_URL; 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(); ensureBinaryDirectory();
if (override) { if (override) {
const localPath = localPathFromOverride(override); const localPath = localPathFromOverride(override);
if (localPath) { if (localPath) {
if (overrideSha256) {
verifyChecksum(localPath, overrideSha256, localPath);
}
replaceWithLocalBinary(localPath); replaceWithLocalBinary(localPath);
console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`); console.log(`idx-cli: installed local binary from ${path.resolve(localPath)}`);
return; return;
@ -182,7 +249,10 @@ async function main() {
console.log(`idx-cli: downloading ${target.asset} for ${target.label} from ${source}`); 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}`); 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)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/idx-cli-npm-smoke.XXXXXX")" 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" install_dir="${smoke_dir}/install"
npm_cache="${smoke_dir}/npm-cache" npm_cache="${smoke_dir}/npm-cache"
@ -37,8 +36,9 @@ fi
echo "npm smoke: packing wrapper" echo "npm smoke: packing wrapper"
mkdir -p "$npm_cache" mkdir -p "$npm_cache"
npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" >/dev/null tarball_name="$(npm_config_cache="$npm_cache" npm pack --silent --pack-destination "$smoke_dir" | tail -n 1)"
if [[ ! -f "$tarball" ]]; then tarball="${smoke_dir}/${tarball_name}"
if [[ -z "$tarball_name" || ! -f "$tarball" ]]; then
echo "npm smoke: expected tarball was not produced at ${tarball}" >&2 echo "npm smoke: expected tarball was not produced at ${tarball}" >&2
exit 1 exit 1
fi fi
@ -64,7 +64,7 @@ fi
help_output="${smoke_dir}/help.txt" help_output="${smoke_dir}/help.txt"
echo "npm smoke: running installed idx --help" echo "npm smoke: running installed idx --help"
"$resolved_bin" --help | tee "$help_output" "$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 echo "npm smoke: --help output did not contain the expected usage line" >&2
exit 1 exit 1
fi fi