pax_global_header00006660000000000000000000000064152233341560014516gustar00rootroot0000000000000052 comment=443ee1fbaac376a2745de2df22fe699f67bafbe3 uhop-install-artifact-from-github-443ee1f/000077500000000000000000000000001522333415600205665ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/.editorconfig000066400000000000000000000003061522333415600232420ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 2 [*.{h,cc,cpp}] indent_style = tab indent_size = 4 uhop-install-artifact-from-github-443ee1f/.gitattributes000066400000000000000000000003321522333415600234570ustar00rootroot00000000000000# Normalize all text files to LF in the working tree on every platform. # Prevents Prettier (and other LF-strict tools) from failing on Windows CI # runners where Git would otherwise check out CRLF. * text=auto eol=lf uhop-install-artifact-from-github-443ee1f/.github/000077500000000000000000000000001522333415600221265ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/.github/FUNDING.yml000066400000000000000000000000431522333415600237400ustar00rootroot00000000000000github: uhop buy_me_a_coffee: uhop uhop-install-artifact-from-github-443ee1f/.github/copilot-instructions.md000066400000000000000000000002361522333415600266640ustar00rootroot00000000000000 See [AGENTS.md](../AGENTS.md) for all AI agent rules and project conventions. uhop-install-artifact-from-github-443ee1f/.github/dependabot.yml000066400000000000000000000014071522333415600247600ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: 'npm' # See documentation for possible values directory: '/' # Location of package manifests schedule: interval: 'weekly' versioning-strategy: 'increase-if-necessary' groups: npm-deps: patterns: - '*' - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'weekly' groups: gh-actions: patterns: - '*' uhop-install-artifact-from-github-443ee1f/.github/workflows/000077500000000000000000000000001522333415600241635ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/.github/workflows/build.yml000066400000000000000000000023621522333415600260100ustar00rootroot00000000000000name: Release dogfood (test tags) # Fires only on `*-test` tags so the maintainer can rehearse a release without # touching real semver tags. Creates a GitHub Release for the tag and exercises # `save-to-github-cache` by uploading `package.json` as a synthetic artifact — # end-to-end smoke test against the real GitHub Releases API. This is what the # package's >1M weekly users actually hit, so a real-API check matters. on: push: tags: - '*-test' - 'v*-test' permissions: contents: write # gh release create + asset upload jobs: release-dogfood: name: Create release and upload artifact runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 - name: Create release env: GH_TOKEN: ${{github.token}} run: gh release create -t "Release ${GITHUB_REF#refs/tags/}" -n "" "${{github.ref}}" - name: Install (skip artifact download) env: DEVELOPMENT_SKIP_GETTING_ASSET: true run: npm ci - name: Save artifact to GitHub env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} run: npm run save-to-github uhop-install-artifact-from-github-443ee1f/.github/workflows/test.yml000066400000000000000000000015771522333415600256770ustar00rootroot00000000000000name: Test on: push: branches: [master] pull_request: branches: [master] permissions: contents: read jobs: test: name: ${{matrix.os}} / Node ${{matrix.node}} runs-on: ${{matrix.os}} strategy: fail-fast: false matrix: include: - {os: ubuntu-latest, node: 22} - {os: ubuntu-latest, node: 24} - {os: ubuntu-latest, node: 26} - {os: macos-latest, node: 24} - {os: windows-latest, node: 24} steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: ${{matrix.node}} cache: npm - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: js-check run: npm run js-check - name: Tests run: npm test uhop-install-artifact-from-github-443ee1f/.gitignore000066400000000000000000000001201522333415600225470ustar00rootroot00000000000000node_modules/ .AppleDouble /scripts/save-local.sh .claude/settings.local.json uhop-install-artifact-from-github-443ee1f/.gitmodules000066400000000000000000000001451522333415600227430ustar00rootroot00000000000000[submodule "wiki"] path = wiki url = https://github.com/uhop/install-artifact-from-github.wiki.git uhop-install-artifact-from-github-443ee1f/.prettierignore000066400000000000000000000000711522333415600236270ustar00rootroot00000000000000node_modules/ package-lock.json wiki/ .github/ dev-docs/ uhop-install-artifact-from-github-443ee1f/.prettierrc000066400000000000000000000001771522333415600227570ustar00rootroot00000000000000{ "printWidth": 160, "singleQuote": true, "bracketSpacing": false, "arrowParens": "avoid", "trailingComma": "none" } uhop-install-artifact-from-github-443ee1f/AGENTS.md000066400000000000000000000135711522333415600221000ustar00rootroot00000000000000# AGENTS.md — install-artifact-from-github > A no-dependency micro helper for developers of binary addons for Node. Three single-file bin utilities integrated with GitHub Releases: `save-to-github-cache` uploads a built binary artifact to a GitHub release (from CI); `install-from-cache` downloads it on the user's machine at install time, optionally verifies its integrity against a hash bag in the addon's `package.json`, and falls back to building from sources on any failure; `hash-github-cache` generates that hash bag at release time. Zero dependencies, ESM, Node >= 18. For detailed usage docs see the [wiki](https://github.com/uhop/install-artifact-from-github/wiki). ## Setup ```bash git clone --recursive https://github.com/uhop/install-artifact-from-github.git cd install-artifact-from-github npm install ``` The wiki is a git submodule in `wiki/`. ## Commands - **Test:** `npm test` (runs `tape6 --flags FO` against the mock-server harness) - **Test (sequential):** `npm run test:seq` (`tape6-seq --flags FO`) - **JavaScript check:** `npm run js-check` (`tsc --project tsconfig.check.json` — checkJs on the bin sources) - **Lint:** `npm run lint` (Prettier check) - **Lint fix:** `npm run lint:fix` (Prettier write) ## Project structure ``` install-artifact-from-github/ ├── package.json # Package config; "type": "module"; exposes the three bins ├── tsconfig.check.json # Lint config -- checkJs on the bin sources ├── bin/ │ ├── install-from-cache.js # Consumer-side bin: download -> integrity check -> verify -> fallback rebuild │ ├── save-to-github-cache.js # CI-side bin: compress + upload artifact to a GitHub release │ └── hash-github-cache.js # Release-side bin: hash release assets into the package.json integrity bag ├── scripts/ │ ├── dump-env.js # Dev helper: dump npm-provided env vars │ └── example-save.sh # Manual upload example (uses a personal token) ├── tests/ # Automated tests (tape-six) │ └── helpers/ # mock-server.js (GitHub API + asset host impersonation), run-bin.js └── wiki/ # GitHub wiki documentation (git submodule) ``` ## How the bins work - `install-from-cache` runs as the consuming addon's `install` script. It computes `${platform}-${arch}-${abiSlot}`, builds the asset URL from the consumer's `package.json` (`github` or `repository.url`, plus `version`), then tries `.br` → `.gz` → uncompressed. If the addon pins an `artifactHashes` bag and the download is from the canonical source, the decompressed bytes' SHA-256 must match before writing (integrity check); on success it also verifies via the consumer's `verify-build` (or `test`) script; on any failure it runs `npm run rebuild` (typically `node-gyp rebuild`). All failures degrade gracefully to the source build. - `save-to-github-cache` runs in GitHub Actions on a tag build: reads `GITHUB_REPOSITORY` / `GITHUB_REF` / `GITHUB_TOKEN` (or `PERSONAL_TOKEN`), resolves the release upload URL, and uploads the artifact in the formats requested by `--format` (default `br`). - `hash-github-cache` runs at release time (e.g. `prepublishOnly`): hashes the release's assets (`--from-release`) or a local directory (`--from`) and `--write`s / `--check`s the `{slot -> sha256}` `artifactHashes` bag in the consumer's `package.json`. It shares slot naming + decompression with `install-from-cache`, so a bag it writes always verifies. - Configuration knobs follow the `--flag` / `--flag-var ENVVAR` / default-env-var triple convention: mirror host (`DOWNLOAD_HOST`), path/version skipping (`DOWNLOAD_SKIP_PATH`, `DOWNLOAD_SKIP_VER`), proxy agent (`DOWNLOAD_AGENT`), N-API level (`DOWNLOAD_NAPI`), forced source build (`DOWNLOAD_FORCE_BUILD` / `--force-build`). The canonical (verified) download host is `GITHUB_SERVER_URL` or `https://github.com`; a `DOWNLOAD_HOST` mirror is not integrity-checked. - musl Linux is detected (`linux-musl`) using the detect-libc algorithm. ## Code style - **ES modules** (`"type": "module"`), Node >= 18. - **Prettier** for formatting — run `npm run lint:fix` before committing. - **No narrating comments** — comments are short _why_-markers only (a non-trivial decision or constraint, an algorithm reference, or required JSDoc); never a restatement of _what_ the code does. - Each bin stays a self-contained single file — they must be trivially auditable (the security story depends on it). ## Key conventions - **No runtime dependencies, ever.** The bins use only the Node standard library. DevDeps for tooling are fine. - **Every change must be fail-safe**: any failure in the download path must fall back to `npm run rebuild`. Never make a download/verification failure fatal. - **Integrity verification is opt-in and source-scoped** (since 1.7.0): it runs only for the canonical GitHub source (`GITHUB_SERVER_URL` / `https://github.com`) and only when the addon ships an `artifactHashes` bag. A consumer mirror (`--host` / `DOWNLOAD_HOST`) is the deployer's own trust root and must never be integrity-checked. A failed check stays fail-safe (rebuild), never fatal. The bag is authored in the consumer's `package.json`, never in this package. - **No build step, no importable API** — this package ships three bins only; there is no `src/`, no `.d.ts` sidecars, no `exports` map. - Wiki documentation lives in the `wiki/` submodule — update it alongside behavior changes; commit in the submodule, then bump the pointer in the parent repo. - Tests impersonate the GitHub Releases API + asset host with a local HTTP server (`tests/helpers/mock-server.js`) — no network access in tests. - **npm 12 (July 2026)** disables dependency lifecycle scripts by default; consumers must allowlist the addon that uses this package (`npm approve-scripts `). Keep the consumer-side allowlist flow visible in the docs (README + wiki). uhop-install-artifact-from-github-443ee1f/ARCHITECTURE.md000066400000000000000000000172741522333415600230050ustar00rootroot00000000000000# Architecture `install-artifact-from-github` is a no-dependency micro helper for developers of binary addons for Node. It ships three single-file bin utilities integrated with GitHub Releases: one uploads pre-built binary artifacts from CI, one downloads them on the user's machine at install time — optionally verifying their integrity, and falling back to a source build on any failure — and one generates the integrity hash bag at release time. **Zero runtime dependencies** — devDeps only for formatting, type-checking, and the test runner. ## Project layout ``` install-artifact-from-github/ ├── package.json # "type": "module"; declares the three bins; no exports map (nothing importable) ├── tsconfig.check.json # checkJs config for the bin sources (npm run js-check) ├── bin/ │ ├── install-from-cache.js # Consumer-side bin: download -> integrity check -> verify -> fallback rebuild │ ├── save-to-github-cache.js # CI-side bin: compress + upload artifact to a GitHub release │ └── hash-github-cache.js # Release-side bin: hash release assets into the package.json integrity bag ├── scripts/ │ ├── dump-env.js # Dev helper: dump npm-provided env vars │ └── example-save.sh # Manual upload example for exotic configurations ├── tests/ # tape-six tests │ └── helpers/ │ ├── mock-server.js # Local HTTP server impersonating the GitHub Releases API + asset host │ └── run-bin.js # Spawns the bins with a controlled environment └── wiki/ # GitHub wiki documentation (git submodule) ``` There is deliberately no `src/`, no importable API, no build step: the package is consumed only through its three bins, invoked from a consuming addon's `package.json` scripts. ## The install flow (`install-from-cache`) Runs as the consuming addon's `install` script: 1. **Platform detection** — `${platform}-${arch}-${abiSlot}` from `process.platform` / `process.arch` / `process.versions.modules`; musl Linux becomes `linux-musl` (detect-libc algorithm); `--napi N` swaps the ABI slot to `napi-vN`. All three are overridable via `npm_config_platform*` for cross-builds. 2. **URL construction** — host (default `https://github.com`, overridable for mirrors) + `/${owner}/${repo}/releases/download` (skippable) + `/${version}` (skippable) + `/${prefix}${platform}-${arch}-${abiSlot}${suffix}`. Owner/repo/version come from the consumer's `package.json` (`github` or `repository.url`, `version`), provided by npm via environment variables (npm < 7) or `npm_package_json` (npm >= 7). 3. **Download chain** — try `.br`, then `.gz`, then uncompressed; each failure is silent and falls through. A non-HTTP host is treated as a local filesystem path. An optional `http.Agent` module (proxy support) is dynamically imported and applied to every request. 4. **Integrity check** _(since 1.7.0)_ — if the consumer's `package.json` pins an `artifactHashes` bag and the download came from the canonical source (default host, not a `--host`/`DOWNLOAD_HOST` mirror), the decompressed bytes' SHA-256 must match the bag's entry for this slot before the file is written; a mismatch or an uncovered slot rejects the artifact. `node:crypto` only — no new dependency. 5. **Verification** — run the consumer's `verify-build` script (or `test` as fallback). A binary that downloads but fails verification is discarded. 6. **Fallback** — anything that fails above ends in `npm run rebuild` (typically `node-gyp rebuild`). The download path is a lossless optimization: its only possible cost is a wasted download attempt. Short-circuits: `DEVELOPMENT_SKIP_GETTING_ASSET` env var, a `.development` file, or _(since 1.7.0)_ a forced build (`--force-build` / `DOWNLOAD_FORCE_BUILD`) go straight to the source build. ## The upload flow (`save-to-github-cache`) Runs in GitHub Actions on a tag build: 1. Reads `GITHUB_REPOSITORY` / `GITHUB_REF` / `GITHUB_TOKEN` (or `PERSONAL_TOKEN` for manual/local runs) and resolves the release's `upload_url` via the GitHub REST API (`GITHUB_API_URL` overridable). 2. Compresses the artifact per `--format` (comma-separated set of `br`, `gz`, `none`; default `br`; brotli and gzip both at maximum compression) and uploads each format in parallel as release assets. 3. Exports `CREATED_ASSET_NAME` into `GITHUB_ENV` for downstream workflow steps. ## The hash flow (`hash-github-cache`) _(since 1.7.0)_ Runs at release time, once all binaries exist for the version being published (typically from a `prepublishOnly` hook, so a plain `npm publish` stamps a fresh bag into the packed tarball): 1. Collects artifacts — `--from-release [tag]` fetches the GitHub release's assets (default tag: the `package.json` version; repo from `package.json` / `GITHUB_REPOSITORY`), or `--from dir` reads a local directory. It recovers each slot from the asset name, keeping one asset per slot. 2. Decompresses each artifact and records `sha256:` of the resulting `.node`. 3. `--write` stamps the sorted `{slot -> sha256}` map into the consumer's `package.json` as `artifactHashes`; `--check` compares it to the existing bag and exits non-zero (with a per-slot diff) on any drift — the publish guard and a post-publish tamper monitor. The generator and the verifier share the slot naming and decompression, so a bag written here always verifies at install time. ## Design properties - **Lossless shortcut.** The fallback is the textbook `node-gyp rebuild` flow; every failure mode degrades to it. No failure in this package can make an install worse than not using it. - **Auditable by inspection.** Three small standard-library-only files. No transitive trust, no separate binary CDN: artifacts live on the same GitHub release anyone reading the consumer's source would expect, writable only by repo maintainers. - **Integrity anchored in npm, not a key** _(since 1.7.0)_. The optional integrity check trusts one thing the addon already publishes immutably: the SHA-256 bag in its own `package.json`. An attacker who can swap a mutable GitHub release asset after publish cannot rewrite the immutable npm tarball, so the swap is caught — with no signing key, no transparency-log service, no extra network call, and no dependency (`node:crypto`). Opt-in and source-scoped: mirrors are the deployer's own trust root and are not checked. - **Convention-driven configuration.** Every knob follows the same triple: `--flag value` (hard-coded) / `--flag-var ENVVAR` (consumer-namespaced env var, recommended for libraries) / default env var (`DOWNLOAD_HOST`, `DOWNLOAD_SKIP_PATH`, `DOWNLOAD_SKIP_VER`, `DOWNLOAD_AGENT`, `DOWNLOAD_NAPI`, `DOWNLOAD_FORCE_BUILD`). - **Bring-your-own-agent proxy.** Proxy support never adds dependencies: the consumer points at a module whose default export is an `http.Agent`; a load failure warns and degrades to direct connections. ## Testing `tests/helpers/mock-server.js` impersonates both the GitHub Releases API and the asset host on a local HTTP server, so the full download / upload / fallback matrix runs without network access. CI runs the suite on Linux (multiple Node versions) plus macOS and Windows. A separate `build.yml` "release dogfood" workflow fires on `*-test` tags and exercises `save-to-github-cache` against the real GitHub API. ## External context: npm 12 and install scripts npm 12 (July 2026) stops running dependency lifecycle scripts by default. This package's delivery mechanism is the consumer's `install` script, so end users must allowlist the consuming addon (`npm approve-scripts `) — nothing in this package's code can change that. The consumer-facing story lives in the README and the wiki; keep it current. uhop-install-artifact-from-github-443ee1f/CLAUDE.md000066400000000000000000000002321522333415600220420ustar00rootroot00000000000000 See [AGENTS.md](./AGENTS.md) for all AI agent rules and project conventions. uhop-install-artifact-from-github-443ee1f/CONTRIBUTING.md000066400000000000000000000023131522333415600230160ustar00rootroot00000000000000# Contributing to install-artifact-from-github Thank you for your interest in contributing! ## Licensing This project is distributed under the BSD-3-Clause license. External contributions are accepted only under licenses compatible with it — by submitting a contribution you agree that it can be distributed under the project's license. ## Getting started This project uses a git submodule for the wiki. Clone and set up: ```bash git clone --recursive https://github.com/uhop/install-artifact-from-github.git cd install-artifact-from-github npm install ``` See the [wiki](https://github.com/uhop/install-artifact-from-github/wiki) for documentation. ## Development workflow 1. Make your changes. 2. Format: `npm run lint:fix` 3. Test: `npm test` 4. Type-check: `npm run js-check` ## Code style - ES modules (`import`/`export`), no CommonJS in source. - Formatted with Prettier — run `npm run lint:fix` before committing. - No dependencies — both utilities are intentionally zero-dependency, single-file bins. - Update wiki documentation alongside code changes. ## AI agents If you are an AI coding agent, see [AGENTS.md](./AGENTS.md) for detailed project conventions, commands, and architecture. uhop-install-artifact-from-github-443ee1f/LICENSE000066400000000000000000000026711522333415600216010ustar00rootroot00000000000000Copyright 2005-2026 Eugene Lazutkin Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. uhop-install-artifact-from-github-443ee1f/README.md000066400000000000000000000136231522333415600220520ustar00rootroot00000000000000# install-artifact-from-github [![NPM version][npm-img]][npm-url] [npm-img]: https://img.shields.io/npm/v/install-artifact-from-github.svg [npm-url]: https://npmjs.org/package/install-artifact-from-github This is a no-dependency micro helper for developers of binary addons for Node. It is literally three small one-file utilities integrated with [GitHub releases](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/about-releases): - [save-to-github-cache](https://github.com/uhop/install-artifact-from-github/wiki/save‐to‐github‐cache) saves a binary artifact to a GitHub release according to the platform, architecture, and Node ABI (or N-API level). - Designed to be used with [GitHub actions](https://github.com/features/actions). - [install-from-cache](https://github.com/uhop/install-artifact-from-github/wiki/install‐from‐cache) retrieves a previously saved artifact, optionally verifies its integrity, tests if it works properly, and rebuilds a project from sources in the case of failure. - _(since 1.7.0)_ [hash-github-cache](https://github.com/uhop/install-artifact-from-github/wiki/hash‐github‐cache) records the SHA-256 of each published artifact into the addon's `package.json` so `install-from-cache` can verify downloads against it. In general, it can save your users from a long recompilation and, in some cases, even save them from installing build tools. By using GitHub facilities ([Releases](https://docs.github.com/en/github/administering-a-repository/about-releases) and [Actions](https://github.com/features/actions)) the whole process of publishing and subsequent installations are secure, transparent, painless, inexpensive, or even free for public repositories. ## How to install Installation: ``` npm install --save install-artifact-from-github ``` ## How to use In your `package.json` (pseudo-code with comments): ```js { // your custom package.json stuff // ... "scripts": { // your scripts go here // ... // saves an artifact "save-to-github": "save-to-github-cache --artifact build/Release/ABC.node", // installs using pre-created artifacts "install": "install-from-cache --artifact build/Release/ABC.node", // used by "install" to test the artifact "verify-build": "node scripts/verify-build.js", // used by "install" to rebuild from sources "rebuild": "node-gyp rebuild" } } ``` Examples of GitHub actions can be found in the documentation. ## Verifying downloads (since 1.7.0) `install-from-cache` can check that a downloaded binary is byte-for-byte the one you published — closing the gap where a network-downloaded artifact is trusted with no integrity check. It is opt-in and adds no dependency. You pin a hash bag in your addon's `package.json` and let `hash-github-cache` maintain it, typically from a `prepublishOnly` hook: ```json { "scripts": { "prepublishOnly": "hash-github-cache --write" } } ``` On `npm publish`, `hash-github-cache` hashes the release's assets and stamps an `artifactHashes` map (`{"linux-x64-137": "sha256:...", ...}`) into the packed `package.json`. Because that map ships in your **immutable npm tarball**, someone who swaps a GitHub release asset after publish cannot also rewrite the expected hash — so `install-from-cache` rejects the swapped binary and rebuilds from source instead. Verification runs only for downloads from GitHub itself; a custom mirror (`--host` / `DOWNLOAD_HOST`) serves the deployer's own build and is intentionally not checked. To skip the prebuilt download entirely and always build from source (trusting only npm plus your own toolchain), set `--force-build` (or the `DOWNLOAD_FORCE_BUILD` environment variable). See [Verifying artifacts](https://github.com/uhop/install-artifact-from-github/wiki/Verifying-artifacts) for the full picture. ## npm 12: install scripts require approval Starting with npm 12 (July 2026), npm does not run dependency lifecycle scripts by default — and `install-from-cache` runs as your package's `install` script. Users of your addon have to approve it once (`npm approve-scripts `), or neither the prebuilt download nor the `node-gyp` fallback will run. Document that step in your install instructions. See [NPM 12 and install scripts](https://github.com/uhop/install-artifact-from-github/wiki/NPM-12-and-install-scripts) for the full story. ## Documentation The full documentation is available in the [wiki](https://github.com/uhop/install-artifact-from-github/wiki). ## Release history - 1.7.0 _added optional artifact integrity verification: a new `hash-github-cache` bin records each published binary's SHA-256 into the addon's `package.json` (`artifactHashes`), and `install-from-cache` verifies downloads against it before use. Added `--force-build` / `--force-build-var` / `DOWNLOAD_FORCE_BUILD` to skip the download and build from sources._ - 1.6.0 _added N-API support: `--napi` / `--napi-var` / `DOWNLOAD_NAPI` swap the URL slot from `${abi}` to `napi-v${level}`, collapsing the per-Node-major build matrix._ - 1.5.0 _added optional proxy support via `--agent` / `--agent-var` / `DOWNLOAD_AGENT`; converted to ESM; added an automated test suite; minimum Node bumped to 18._ - 1.4.0 _added support for uncompresed artifacts and selective compression format._ - 1.3.5 _propagated the previous timeout fix to the saving utility._ - 1.3.4 _minor fixes + a timeout fix: use a new default agent for GET. Thx, [Laura Hausmann](https://github.com/zotanmew)._ - 1.3.3 _minor refactor, added support for a personal token._ - 1.3.2 _added support for the 204 response and error logging._ - 1.3.1 _added a way to specify a custom build, thx [Grisha Pushkov](https://github.com/reepush) + a test._ - 1.3.0 _enhanced support for custom mirrors._ The full release history with dates is in the wiki: [Release notes](https://github.com/uhop/install-artifact-from-github/wiki/Release-notes). ## License BSD-3-Clause — see [LICENSE](./LICENSE). uhop-install-artifact-from-github-443ee1f/SECURITY.md000066400000000000000000000035451522333415600223660ustar00rootroot00000000000000# Security Policy ## Reporting a vulnerability **Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.** Report privately through GitHub's **[Private Vulnerability Reporting](https://github.com/uhop/install-artifact-from-github/security/advisories/new)** (the "Report a vulnerability" button under the repository's **Security** tab). This opens a confidential advisory visible only to the maintainers and you. If GitHub reporting is unavailable to you, email the maintainer at **eugene.lazutkin@gmail.com** with `SECURITY` in the subject line. Please do not disclose details publicly until a fix is released. When reporting, please include: - the affected version(s) and platform, - a description of the issue and its impact, - steps to reproduce or a proof of concept (a link to a private/secret gist is fine), - any suggested remediation. ## Scope This package downloads prebuilt native addon binaries from GitHub Releases on behalf of a consuming package (for example, [node-re2](https://github.com/uhop/node-re2)). Reports about the download, verification, or integrity of those artifacts are in scope for this repository even when they surface through a consuming package's install script. ## Supported versions Fixes are released against the latest published version. Please upgrade to the latest `install-artifact-from-github` release before reporting, and pin the fixed version once one is available. ## Disclosure process - We aim to acknowledge a report within a few business days. - We work to a coordinated-disclosure timeline (up to 90 days by default) and will keep you updated on progress toward a fix. - With your permission, we credit reporters in the release notes and advisory. We are happy to coordinate a CVE through GitHub's CNA once a fix is validated. Thank you for helping keep the ecosystem safe. uhop-install-artifact-from-github-443ee1f/bin/000077500000000000000000000000001522333415600213365ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/bin/hash-github-cache.js000077500000000000000000000164411522333415600251510ustar00rootroot00000000000000#!/usr/bin/env node import {promises as fsp} from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import http from 'node:http'; import https from 'node:https'; import {createHash} from 'node:crypto'; const isParamPresent = name => process.argv.indexOf('--' + name) > 0; const getParam = (name, defaultValue = '') => { const index = process.argv.indexOf('--' + name); if (index > 0) return process.argv[index + 1] || ''; return defaultValue; }; // A flag that takes an optional value: `--x` (present, empty) vs `--x v` (present, "v"). // A following token that itself starts with `--` is a separate flag, not this one's value. const getOptionalParam = name => { const index = process.argv.indexOf('--' + name); if (index < 0) return undefined; const next = process.argv[index + 1]; return next && !next.startsWith('--') ? next : ''; }; const prefix = getParam('prefix'), suffix = getParam('suffix'); const parseUrl = [ /^(?:https?|git|git\+ssh|git\+https?):\/\/github.com\/([^\/]+)\/([^\/\.]+)(?:\/|\.git\b|$)/i, /^github:([^\/]+)\/([^#]+)(?:#|$)/i, /^([^:\/]+)\/([^#]+)(?:#|$)/i ]; const getRepo = url => { if (!url) return null; for (const re of parseUrl) { const result = re.exec(url); if (result) return result; } return null; }; // Recognize an uploaded binary asset and recover its slot; returns null for anything else // (source archives, checksum files, ...). The slot is ` - prefix - suffix - compression`. const compressionRank = {br: 3, gz: 2, none: 1}; const parseAsset = name => { let base = name, compression = 'none'; if (base.endsWith('.br')) ((base = base.slice(0, -3)), (compression = 'br')); else if (base.endsWith('.gz')) ((base = base.slice(0, -3)), (compression = 'gz')); if (prefix && !base.startsWith(prefix)) return null; if (suffix && !base.endsWith(suffix)) return null; const slot = base.slice(prefix.length, suffix ? base.length - suffix.length : base.length); // platform-arch-abi is the minimal shape (musl / N-API add components); guards non-binary uploads. if (slot.split('-').length < 3) return null; return {slot, compression}; }; const decompress = (buffer, compression) => compression === 'br' ? promisify(zlib.brotliDecompress)(buffer) : compression === 'gz' ? promisify(zlib.gunzip)(buffer) : Promise.resolve(buffer); const hash = buffer => 'sha256:' + createHash('sha256').update(buffer).digest('hex'); const httpGet = (url, headers = {}) => new Promise((resolve, reject) => { const target = typeof url === 'string' ? url : url.href; const httpLib = /^http:\/\//i.test(target) ? http : https; httpLib .get(url, {headers: {'User-Agent': 'uhop/install-artifact-from-github', ...headers}}, res => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { // Drop auth on redirect: asset URLs bounce to a separate CDN host (avoids leaking a token). httpGet(res.headers.location).then(resolve, reject); return; } if (res.statusCode < 200 || res.statusCode >= 300) { reject(Error(`Status ${res.statusCode} for ${target}`)); return; } const chunks = []; res.on('data', c => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks))); }) .on('error', reject); }); // Keep one asset per slot, preferring the smallest download; all formats decode to the same bytes. const pickBestPerSlot = entries => { const bySlot = new Map(); for (const entry of entries) { const parsed = parseAsset(entry.name); if (!parsed) continue; const current = bySlot.get(parsed.slot); if (!current || compressionRank[parsed.compression] > compressionRank[current.compression]) { bySlot.set(parsed.slot, {...entry, compression: parsed.compression}); } } return bySlot; }; const collectFromDir = async dir => { const names = await fsp.readdir(dir); const bySlot = pickBestPerSlot(names.map(name => ({name}))); const bag = {}; for (const [slot, {name, compression}] of bySlot) { bag[slot] = hash(await decompress(await fsp.readFile(path.join(dir, name)), compression)); } return bag; }; const collectFromRelease = async (owner, repo, tag) => { const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com'; const releaseUrl = new URL(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`, apiBase); const token = process.env.GITHUB_TOKEN || process.env.PERSONAL_TOKEN; const headers = {Accept: 'application/vnd.github.v3+json'}; if (token) headers.Authorization = 'Bearer ' + token; const release = JSON.parse((await httpGet(releaseUrl, headers)).toString()); const bySlot = pickBestPerSlot((release.assets || []).map(a => ({name: a.name, url: a.browser_download_url}))); const bag = {}; for (const [slot, {url, compression}] of bySlot) { bag[slot] = hash(await decompress(await httpGet(url), compression)); } return bag; }; const sortByKey = bag => Object.fromEntries(Object.entries(bag).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); const diffBag = (current, computed) => { const out = []; for (const key of Object.keys(computed)) { if (!(key in current)) out.push(`missing: ${key}`); else if (current[key] !== computed[key]) out.push(`mismatch: ${key}`); } for (const key of Object.keys(current)) { if (!(key in computed)) out.push(`stale (not in release): ${key}`); } return out; }; const main = async () => { const write = isParamPresent('write'), check = isParamPresent('check'); if (write === check) { console.error('Specify exactly one of --write or --check.'); process.exit(2); } const pkgPath = path.resolve(getParam('package') || 'package.json'); const pkg = JSON.parse(await fsp.readFile(pkgPath, 'utf8')); const fromDir = getParam('from'); let bag; if (fromDir) { bag = await collectFromDir(fromDir); } else { const tag = getOptionalParam('from-release') || pkg.version; const repo = getRepo(pkg.github || (pkg.repository && pkg.repository.type === 'git' && pkg.repository.url)); let owner = repo && repo[1], name = repo && repo[2]; if ((!owner || !name) && process.env.GITHUB_REPOSITORY) [owner, name] = process.env.GITHUB_REPOSITORY.split('/'); if (!owner || !name) { console.error('Could not determine the GitHub repository (package.json "github" / "repository", or GITHUB_REPOSITORY).'); process.exit(2); } bag = await collectFromRelease(owner, name, tag); } bag = sortByKey(bag); const count = Object.keys(bag).length; if (!count) { console.error('No artifacts found to hash.'); process.exit(1); } if (write) { pkg.artifactHashes = bag; await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); console.log(`Wrote ${count} artifact ${count === 1 ? 'hash' : 'hashes'} to ${pkgPath}.`); return; } const diffs = diffBag(pkg.artifactHashes || {}, bag); if (diffs.length) { console.error(`Hash bag in ${pkgPath} does not match the artifacts:`); for (const line of diffs) console.error(' ' + line); process.exit(1); } console.log(`Hash bag matches the artifacts (${count}).`); }; main().catch(error => { console.error((error && error.message) || 'hash-github-cache has failed'); process.exit(1); }); uhop-install-artifact-from-github-443ee1f/bin/install-from-cache.js000077500000000000000000000255711522333415600253610ustar00rootroot00000000000000#!/usr/bin/env node import {promises as fsp} from 'node:fs'; import path from 'node:path'; import {pathToFileURL} from 'node:url'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import http from 'node:http'; import https from 'node:https'; import {exec, spawnSync} from 'node:child_process'; import {createHash} from 'node:crypto'; /** @type {import('child_process').SpawnSyncOptions} */ const spawnOptions = {encoding: 'utf8', env: process.env}; const getPlatform = () => { let platform = process.env.npm_config_platform; if (platform) return platform; platform = process.platform; if (platform !== 'linux') return platform; // detecting musl using algorithm from https://github.com/lovell/detect-libc under Apache License 2.0 let result = spawnSync('getconf', ['GNU_LIBC_VERSION'], spawnOptions); if (!result.status && !result.signal) return platform; result = spawnSync('ldd', ['--version'], spawnOptions); if (result.signal) return platform; if ((!result.status && result.stdout.toString().indexOf('musl') >= 0) || (result.status === 1 && result.stderr.toString().indexOf('musl') >= 0)) return platform + '-musl'; return platform; }; const platform = getPlatform(), platformArch = process.env.npm_config_platform_arch || process.arch, platformABI = process.env.npm_config_platform_abi || process.versions.modules; const isParamPresent = name => process.argv.indexOf('--' + name) > 0; const getParam = (name, defaultValue = '') => { const index = process.argv.indexOf('--' + name); if (index > 0) return process.argv[index + 1] || ''; return defaultValue; }; const artifactPath = getParam('artifact'), prefix = getParam('prefix'), suffix = getParam('suffix'), mirrorHost = getParam('host'), mirrorEnvVar = getParam('host-var') || 'DOWNLOAD_HOST', skipPath = isParamPresent('skip-path'), skipPathVar = getParam('skip-path-var') || 'DOWNLOAD_SKIP_PATH', skipVer = isParamPresent('skip-ver'), skipVerVar = getParam('skip-ver-var') || 'DOWNLOAD_SKIP_VER', agentDirect = getParam('agent'), agentEnvVar = getParam('agent-var') || 'DOWNLOAD_AGENT', napiDirect = getParam('napi'), napiEnvVar = getParam('napi-var') || 'DOWNLOAD_NAPI', forceBuild = isParamPresent('force-build'), forceBuildVar = getParam('force-build-var') || 'DOWNLOAD_FORCE_BUILD'; const napiLevel = napiDirect || process.env[napiEnvVar] || process.env.npm_config_platform_napi || ''; const abiSlot = napiLevel ? `napi-v${napiLevel}` : platformABI; // The slot names the artifact for this platform; it keys the integrity hash bag. const slot = `${platform}-${platformArch}-${abiSlot}`; // Verification applies only to the canonical source: a consumer-supplied mirror is the // deployer's own trust root (its bytes may legitimately differ), so we never check it. const isDefaultSource = !mirrorHost && !process.env[mirrorEnvVar]; let artifactHashes = null; const parseUrl = [ /^(?:https?|git|git\+ssh|git\+https?):\/\/github.com\/([^\/]+)\/([^\/\.]+)(?:\/|\.git\b|$)/i, /^github:([^\/]+)\/([^#]+)(?:#|$)/i, /^([^:\/]+)\/([^#]+)(?:#|$)/i ]; const isHttp = /^http:\/\//i, isHttps = /^https:\/\//i; const getRepo = url => { if (!url) return null; for (const re of parseUrl) { const result = re.exec(url); if (result) return result; } return null; }; const getAssetUrlPrefix = () => { const url = process.env.npm_package_github || (process.env.npm_package_repository_type === 'git' && process.env.npm_package_repository_url), result = getRepo(url); if (!result) return null; let assetUrl = mirrorHost || process.env[mirrorEnvVar] || process.env.GITHUB_SERVER_URL || 'https://github.com'; if (!skipPath && !process.env[skipPathVar]) { assetUrl += `/${result[1]}/${result[2]}/releases/download`; } if (!skipVer && !process.env[skipVerVar]) { assetUrl += '/' + process.env.npm_package_version; } assetUrl += `/${prefix}${platform}-${platformArch}-${abiSlot}${suffix}`; return assetUrl; }; const isDev = async () => { if (process.env.DEVELOPMENT_SKIP_GETTING_ASSET) return true; try { await fsp.access('.development'); return true; } catch (e) { // squelch } return false; }; const run = (cmd, suppressOutput) => new Promise((resolve, reject) => { const p = exec(cmd); let closed = false; p.on('exit', (code, signal) => { if (closed) return; closed = true; (signal || code) && reject(signal || code); resolve(0); }); p.on('error', error => !closed && ((closed = true), reject(error))); if (!suppressOutput || process.env.DEVELOPMENT_SHOW_VERIFICATION_RESULTS) { p.stdout.on('data', data => process.stdout.write(data)); p.stderr.on('data', data => process.stderr.write(data)); } }); const isVerified = async () => { if (process.env.npm_config_platform || process.env.npm_config_platform_arch || process.env.npm_config_platform_abi) { console.log(`Fetched for the custom platform "${platform}-${platformArch}-${platformABI}" -- skipping the verification.`); return true; } try { if (process.env.npm_package_scripts_verify_build) { await run('npm run verify-build', true); } else if (process.env.npm_package_scripts_test) { await run('npm test', true); } else { console.log('No verify-build nor test scripts were found -- no way to verify the build automatically.'); return false; } } catch (e) { console.log('The verification has failed: building from sources ...'); return false; } return true; }; const loadAgent = async () => { const agentPath = agentDirect || process.env[agentEnvVar]; if (!agentPath) return false; try { const mod = await import(pathToFileURL(path.resolve(agentPath)).href); return mod.default ?? false; } catch (e) { console.error(`Failed to load download agent "${agentPath}": ${e.message}`); return false; } }; const downloadAgent = await loadAgent(); const get = url => new Promise((resolve, reject) => { const httpLib = isHttps.test(url) ? https : isHttp.test(url) ? http : null; if (!httpLib) { // local file fsp.readFile(url).then(resolve, reject); return; } let buffer = null; httpLib .get(url, {agent: downloadAgent}, res => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers && res.headers.location) { get(res.headers.location).then(resolve, reject); return; } if (res.statusCode != 200) { reject(Error(`Status ${res.statusCode} for ${url}`)); return; } res.on('data', data => { if (buffer) { buffer = Buffer.concat([buffer, data]); } else { buffer = data; } }); res.on('end', () => resolve(buffer)); }) .on('error', e => reject(e)) .end(); }); const write = async (name, data) => { await fsp.mkdir(path.dirname(name), {recursive: true}); await fsp.writeFile(name, data); }; // Integrity gate for the canonical source: the decompressed bytes must match the hash the // author pinned in the immutable, npm-published package.json. A mismatch OR a downloaded slot // the bag doesn't cover both fail closed (rebuild). No bag / a mirror source skips the check. const verifyArtifact = data => { if (!isDefaultSource || !artifactHashes) return true; const expected = artifactHashes[slot], actual = 'sha256:' + createHash('sha256').update(data).digest('hex'); if (expected && expected === actual) return true; console.log(`Integrity check failed for ${slot}: building from sources ...`); return false; }; const main = async () => { checks: { if (process.env.npm_package_json && /\bpackage\.json$/i.test(process.env.npm_package_json)) { // for NPM >= 7 try { // read the package info const pkg = JSON.parse(await fsp.readFile(process.env.npm_package_json, 'utf8')); // populate necessary environment variables locally process.env.npm_package_github = pkg.github || ''; process.env.npm_package_repository_type = (pkg.repository && pkg.repository.type) || ''; process.env.npm_package_repository_url = (pkg.repository && pkg.repository.url) || ''; process.env.npm_package_version = pkg.version || ''; process.env.npm_package_scripts_verify_build = (pkg.scripts && pkg.scripts['verify-build']) || ''; process.env.npm_package_scripts_test = (pkg.scripts && pkg.scripts.test) || ''; artifactHashes = pkg.artifactHashes && typeof pkg.artifactHashes === 'object' ? pkg.artifactHashes : null; } catch (error) { console.log('Could not retrieve and parse package.json.'); break checks; } } if (!artifactPath) { console.log('No artifact path was specified with --artifact.'); break checks; } if (forceBuild || process.env[forceBuildVar]) { console.log('Forced build from sources was requested.'); break checks; } if (await isDev()) { console.log('Development flag was detected.'); break checks; } const prefix = getAssetUrlPrefix(); if (!prefix) { console.log('No github repository was identified.'); break checks; } let copied = false, rejected = false; // a failed integrity check rejects the artifact outright: the other formats decode to the // same bytes, so there is no point trying them -- fall through to a source build instead. // let's try brotli if (!rejected && zlib.brotliDecompress) { try { console.log(`Trying ${prefix}.br ...`); const artifact = await promisify(zlib.brotliDecompress)(await get(prefix + '.br')); if (verifyArtifact(artifact)) { console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, artifact); copied = true; } else { rejected = true; } } catch (e) { // squelch } } // let's try gzip if (!copied && !rejected && zlib.gunzip) { try { console.log(`Trying ${prefix}.gz ...`); const artifact = await promisify(zlib.gunzip)(await get(prefix + '.gz')); if (verifyArtifact(artifact)) { console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, artifact); copied = true; } else { rejected = true; } } catch (e) { // squelch } } // let's try uncompressed if (!copied && !rejected) { try { console.log(`Trying ${prefix} ...`); const artifact = await get(prefix); if (verifyArtifact(artifact)) { console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, artifact); copied = true; } else { rejected = true; } } catch (e) { // squelch } } // verify the install if (copied && (await isVerified())) return console.log('Done.'); } console.log('Building locally ...'); await run('npm run rebuild'); }; main(); uhop-install-artifact-from-github-443ee1f/bin/save-to-github-cache.js000077500000000000000000000157721522333415600256120ustar00rootroot00000000000000#!/usr/bin/env node import {EOL} from 'node:os'; import {promises as fsp} from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import http from 'node:http'; import https from 'node:https'; import {spawnSync} from 'node:child_process'; const isHttp = /^http:\/\//i; /** @type {import('child_process').SpawnSyncOptions} */ const spawnOptions = {encoding: 'utf8', env: process.env}; const getPlatform = () => { const platform = process.platform; if (platform !== 'linux') return platform; // detecting musl using algorithm from https://github.com/lovell/detect-libc under Apache License 2.0 let result = spawnSync('getconf', ['GNU_LIBC_VERSION'], spawnOptions); if (!result.status && !result.signal) return platform; result = spawnSync('ldd', ['--version'], spawnOptions); if (result.signal) return platform; if ((!result.status && result.stdout.toString().indexOf('musl') >= 0) || (result.status === 1 && result.stderr.toString().indexOf('musl') >= 0)) return platform + '-musl'; return platform; }; const platform = getPlatform(); const getParam = (name, defaultValue = '') => { const index = process.argv.indexOf('--' + name); if (index > 0) return process.argv[index + 1] || ''; return defaultValue; }; const cleanOptions = options => { const result = {}; for (const [key, value] of Object.entries(options)) { if (value === undefined || value === null) continue; if (key === 'headers') { result.headers = cleanOptions(value); continue; } result[key] = value; } return result; }; const io = (url, options = {}, data) => new Promise((resolve, reject) => { let buffer = null; options = cleanOptions(options); const httpLib = isHttp.test(typeof url === 'string' ? url : url.href) ? http : https; const req = httpLib .request(url, options, res => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers && res.headers.location) { io(res.headers.location, options, data).then(resolve, reject); return; } if (res.statusCode < 200 || res.statusCode >= 300) { reject(Error(`Status ${res.statusCode} for ${url}`)); return; } res.on('data', data => { if (buffer) { buffer = Buffer.concat([buffer, data]); } else { buffer = data; } }); res.on('end', () => resolve({data: buffer, res})); }) .on('error', error => reject(error)); data && req.write(data); req.end(); }); const get = (url, options) => io(url, {agent: false, ...options, method: 'GET'}); const post = (url, options, data) => io(url, {agent: false, ...options, method: 'POST'}, data); const withParams = (url, params) => { const result = new URL(url); for (const [key, value] of Object.entries(params)) { result.searchParams.append(key, value); } return result; }; const artifactPath = getParam('artifact'), prefix = getParam('prefix'), suffix = getParam('suffix'), format = getParam('format', 'br'), requestedFormats = new Set(format.toLowerCase().split(/\s*,\s*/)), skipBrotli = !zlib.brotliCompress || !requestedFormats.has('br'), skipGzip = !zlib.gzip || !requestedFormats.has('gz'), skipUncompressed = !requestedFormats.has('none'), napiDirect = getParam('napi'), napiEnvVar = getParam('napi-var') || 'DOWNLOAD_NAPI'; const napiLevel = napiDirect || process.env[napiEnvVar] || ''; const abiSlot = napiLevel ? `napi-v${napiLevel}` : process.versions.modules; const main = async () => { const [OWNER, REPO] = process.env.GITHUB_REPOSITORY.split('/'), TAG = /^refs\/tags\/(.*)$/.exec(process.env.GITHUB_REF)[1], TOKEN = process.env.GITHUB_TOKEN, PERSONAL_TOKEN = process.env.PERSONAL_TOKEN; const fileName = `${prefix}${platform}-${process.arch}-${abiSlot}${suffix}`; console.log('Preparing artifact', fileName, '...'); const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com'; const releaseUrl = new URL(`/repos/${encodeURIComponent(OWNER)}/${encodeURIComponent(REPO)}/releases/tags/${encodeURIComponent(TAG)}`, apiBase); const [data, uploadUrl] = await Promise.all([ fsp.readFile(path.normalize(artifactPath)), get(releaseUrl, { auth: TOKEN ? OWNER + ':' + TOKEN : null, headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'uhop/install-artifact-from-github', Authorization: !TOKEN && PERSONAL_TOKEN ? 'Bearer ' + PERSONAL_TOKEN : null } }).then(response => { const data = JSON.parse(response.data.toString()), p = data.upload_url.indexOf('{'); return p > 0 ? data.upload_url.substr(0, p) : data.upload_url; }) ]); const postArtifact = (name, label, data, contentType = 'application/octet-stream') => post( withParams(uploadUrl, {name, label}), { auth: TOKEN ? OWNER + ':' + TOKEN : null, headers: { Accept: 'application/vnd.github.v3+json', 'Content-Type': contentType, 'Content-Length': data.length, 'User-Agent': 'uhop/install-artifact-from-github', Authorization: !TOKEN && PERSONAL_TOKEN ? 'Bearer ' + PERSONAL_TOKEN : null } }, data ); console.log('Compressing and uploading ...'); await Promise.all([ (async () => { if (skipBrotli) return null; const compressed = await promisify(zlib.brotliCompress)(data, {params: {[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY}}), name = fileName + '.br', label = `Binary artifact: ${artifactPath} (${platform}, ${process.arch}, ${abiSlot}, brotli).`; return postArtifact(name, label, compressed, 'application/brotli') .then(({res}) => console.log('Uploaded BR:', res.statusCode)) .catch(error => console.error('BR has failed to upload:', error)); })(), (async () => { if (skipGzip) return null; const compressed = await promisify(zlib.gzip)(data, {level: zlib.constants.Z_BEST_COMPRESSION}), name = fileName + '.gz', label = `Binary artifact: ${artifactPath} (${platform}, ${process.arch}, ${abiSlot}, gzip).`; return postArtifact(name, label, compressed, 'application/gzip') .then(({res}) => console.log('Uploaded GZ:', res.statusCode)) .catch(error => console.error('GZ has failed to upload:', error)); })(), (async () => { if (skipUncompressed) return null; const label = `Binary artifact: ${artifactPath} (${platform}, ${process.arch}, ${abiSlot}, uncompressed).`; return postArtifact(fileName, label, data) .then(({res}) => console.log('Uploaded Uncompressed:', res.statusCode)) .catch(error => console.error('Uncompressed has failed to upload:', error)); })() ]); if (process.env.GITHUB_ENV) await fsp.appendFile(process.env.GITHUB_ENV, 'CREATED_ASSET_NAME=' + fileName + EOL); console.log('Done.'); }; main().catch(error => { console.log('::error::' + ((error && error.message) || 'save-to-github-cache has failed')); process.exit(1); }); uhop-install-artifact-from-github-443ee1f/dev-docs/000077500000000000000000000000001522333415600222725ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/dev-docs/artifact-distribution-field-survey.md000066400000000000000000000345721522333415600315550ustar00rootroot00000000000000# Field survey: how native-addon binaries are distributed and verified **Status:** Research / reference — informs the design decision, no code. **Date:** 2026-07-07. **Origin:** the CWE-494 disclosure (now filed as draft advisory GHSA-88q3-gch3-5396). Before finalizing the response in `artifact-integrity-verification.md`, we surveyed what comparable packages actually do about downloading and verifying prebuilt native addons. This note records the findings so the decision is grounded in prior art, not assumption. Companion to `artifact-integrity-verification.md` (the design note); that note assumes we keep the downloader and harden it — this survey tests that assumption against the field and measures the alternative. --- ## 1. The one lens that organizes everything: where the checksum's trust root lives Every integrity scheme falls into one of three classes. The class — not the algorithm — is what determines whether it survives the attack in our own threat model (a compromised maintainer account that swaps a release asset after publish). | Class | Mechanism | Root of trust | Survives account/release compromise? | | --- | --- | --- | --- | | **(a)** In-band checksum in the *same mutable channel* as the binary | `SHASUMS256.txt` inside the GitHub Release | The release itself (maintainer-controlled) | **No** — one compromise rewrites binary *and* checksum | | **(b)** Checksum pinned in an *immutable registry/lockfile* | npm tarball SRI (`integrity: sha512-…`), `Cargo.lock`, pip `--require-hashes` | The immutable published artifact | **Partially** — only if pinned before the compromise | | **(c)** *External transparency log + CI identity* | Sigstore/Rekor (npm provenance, GitHub attestations, PyPI PEP 740, Homebrew bottles); Go `sum.golang.org` | A log + OIDC identity *outside* the account | **Yes** — attacker lacks the CI identity; tampering is publicly detectable | Our gap today is class-(a)-with-nothing. **The reporter's suggested fix — publish `SHASUMS256.txt` — is also class (a):** it is defeated by the exact "compromised maintainer account" scenario in his own advisory. The design note's instinct (Sigstore over checksums) is class (c), which is where npm, PyPI, Homebrew, and Go have all independently converged. --- ## 2. What comparable packages actually do | Tool | Model | Downloads at install? | Integrity | Class | | --- | --- | --- | --- | --- | | `prebuild-install` (~18M/wk, **deprecated 2026-02**) | download from GitHub Releases | Yes | **none** | — | | `@mapbox/node-pre-gyp` (~12M/wk) | download from S3 / configurable host | Yes | **none** | — | | `node-gyp` (source path) | downloads *Node.js headers* | Yes | `SHASUMS256.txt`, headers only, TLS/host root, no GPG | (a) | | `prebuildify` + `node-gyp-build` | bundle *all* prebuilds in the one tarball | **No** | npm SRI | (b) | | `pkg-prebuilds` | bundle in tarball | **No** | npm SRI | (b) | | napi-rs / esbuild / sharp / rollup | per-platform npm packages (`optionalDependencies` + `os`/`cpu`/`libc`) | **No** | npm SRI (+ optional provenance) | (b)→(c) | | **install-artifact-from-github (us, today)** | download from GitHub Releases | Yes | **none** | — | Three findings from this that change the decision: **2.1 — The reporter's precedent is factually wrong.** He states `prebuild-install` "already uses" a `SHASUMS256.txt` model. It does not: `prebuild-install/download.js` streams the HTTP response straight to disk and extracts it on `statusCode === 200`, with no `crypto`, no hash, no signature — the README never mentions checksum/integrity. The `SHASUMS256.txt` he is thinking of belongs to **`node-gyp`**, which uses it only to verify downloaded *Node.js build headers* on the compile-from-source path — a different code path that never touches a prebuilt addon. So "just do what `prebuild-install` does" points at a verification model that does not exist where he thinks it does. (This should be corrected, gently but explicitly, in the reply.) **2.2 — The field retired the download; it did not add verifiers to it.** The safe tools are safe by *not downloading*: they ship the binary inside an npm package so registry SRI (class b) covers it, making CWE-494 *not-applicable* rather than *mitigated*. The most on-point precedent is **sharp**, which migrated away from our exact model (node-pre-gyp downloading from GitHub) to npm-hosted binaries in **v0.33.0 (2023-11-29)**, stating the goal almost verbatim as this advisory: "use only package manager mechanics at install time, without custom scripts, and without downloading binaries from hosts other than those controlled by a package manager." **2.3 — The download generation is uniformly unverified, and it has been exploited.** The two tools that actually download at install time verify nothing. The canonical real-world exploit of exactly this class: **GHSA-7cgc-fjv4-52x6** — `bignum`'s node-pre-gyp S3 bucket expired, an attacker re-registered it and served data-exfiltrating malware; the absent integrity check is precisely why the swap went undetected. A clean articulation of the threat model (later withdrawn but accurate) is **GHSA-gv7w-rqvm-qjhr**: "esbuild/Deno missing binary integrity verification enables RCE via `NPM_CONFIG_REGISTRY`." --- ## 3. The attestation model (our preferred fix) is mature and adopted — but new to Node Class (c) is not experimental: - **npm provenance** is GA: `npm publish --provenance` produces Sigstore-signed attestations (source repo + commit + CI workflow), recorded in Rekor; consumers verify via `npm audit signatures` and the registry UI. `@sigstore/verify` (the library npm uses internally) is **pure JS, credential-free, version-pinnable, and offline-capable given a pinned trusted root** — which fits our "probe it in the consumer, stay zero-dep here" design. - **PyPI PEP 740** went GA (2024-11-14): a first-tier ecosystem adopting exactly this model. Its own stated rationale is verbatim our argument: attestations "do not increase trust in the index itself" — the value comes entirely from the *external* Sigstore identity binding, not from trusting the host. `cibuildwheel` + `gh-action-pypi-publish` now attach attestations **by default** for CI-built native wheels. - **Homebrew** already verifies a Sigstore attestation of a *downloaded binary* (a bottle) at `brew install` time (beta) — a direct precedent for exactly what the design note proposes. - **Go** `sum.golang.org` is a transparency-log trust root at ecosystem scale that predates Sigstore — proof the pattern holds up under load. **Where we would be early:** no Node native-addon *installer* does install-time attestation verification today. Concept is proven (Homebrew) and endorsed upstream (npm, PyPI); we would be an early mover *within the Node ecosystem*, not inventing anything. Operational note carried over to the design: `gh attestation verify` is a poor *runtime* dependency (version-gated to recent `gh`, needs auth, default-fetches from the API). `@sigstore/verify` / `sigstore` (sigstore-js) is the better building block for a hands-off install-time check. --- ## 4. Measurement: is "retire the download" actually viable for node-re2? The industry answer (§2.2) is "ship the binary in npm." Whether that is *viable* for node-re2 depends entirely on binary size, because node-re2 statically links the RE2 library. Measured against the real `1.25.2` GitHub Release assets (2026-07-07). **Matrix shape:** assets are named `--` with ABIs 127/137/147 — node-re2 ships **per-Node-major (ABI-specific)** binaries: **8 platform/arch combos × 3 ABIs = 24 binaries per release**. The matrix grows by one full platform-column every Node major (the ABI treadmill). Release assets are brotli-compressed; a `prebuildify` bundle ships *uncompressed* `.node` files and lets npm gzip the tarball, so the numbers below convert brotli → uncompressed → gzip using measured ratios (linux-x64: 6.14 MiB brotli → **27.64 MiB unpacked** → 8.59 MiB gzip; sample-wide gzip/brotli ≈ 1.39×; unpacked/brotli ≈ 3.9× darwin, 4.5× linux, 2.7× win). ### Option 1a — bundle all prebuilds in one tarball (prebuildify / Model A) Every user downloads and unpacks *every* platform's binary. | Matrix | Gzipped (npm download) | Unpacked (on disk) | | --- | --- | --- | | Current per-ABI (24 binaries) | **~107 MiB** | **~340 MiB** | | N-API-collapsed (8 binaries) | **~36 MiB** | **~115 MiB** | **Verdict: not viable.** Even the best case — migrate to N-API to collapse the ABI axis — ships ~36 MiB gzipped and unpacks ~115 MiB to every user on every install, regardless of platform. This is disqualifying for a 2.5M-downloads/week package. ### Option 1b — per-platform npm packages (napi-rs/esbuild/sharp style / Model B) Each user installs only the one platform package that matches `os`/`cpu`/`libc`. | User platform | Gzipped download | vs today (brotli from GitHub) | | --- | --- | --- | | linux-x64 | ~8.6 MiB | ~6.1 MiB (≈ 40% larger — gzip vs brotli) | | macOS / Windows | sub-MiB | comparable | **Verdict: size-viable** — per-user footprint is comparable to today (~40% larger download on Linux because npm tarballs are gzip, not brotli). Gets class-(b) integrity for free (npm SRI) + optional class-(c) provenance. **But** it is a real restructure: node-re2 becomes a main package publishing 8–24 satellite packages every release; it inherits the well-known npm `optionalDependencies` resolver bug (the recurring `Cannot find module @rollup/rollup-linux-x64-gnu` class of lockfile-omission failures across CI/Docker); requires npm ≥ 9.6.5 for musl filtering and drops yarn v1. And it **obsoletes install-artifact-from-github for node-re2**. ### Side finding (independent of the integrity decision) The Linux binary is **27.64 MiB unpacked vs macOS 1.45 MiB — 19×**, with a high (4.5×) compression ratio. This strongly suggests the Linux `.node` is **unstripped** (or statically links abseil/ICU with full symbol tables). Hypothesis, not confirmed — worth a `strip` check on the build output. If it holds, stripping would shrink today's download *and* improve every bundle option's math; it is a cheap win worth pursuing regardless of which option is chosen. (N-API migration is a second orthogonal win: it collapses 24 → 8 binaries and ends the per-Node-major treadmill, and install-artifact-from-github already supports it via `--napi`.) --- ## 5. The decision fork and recommendation The survey splits the problem into two genuinely different strategies. Both land at class-(b) or class-(c) integrity; they differ architecturally. - **Option 1 — retire the download for node-re2.** Only survives as **Model B** (per-platform npm packages); Model A (bundle-all) is disqualified by size (§4). Model B moves the binary into npm (removing the trust boundary) at the cost of a substantial restructure, the npm optional-dep resolver fragility, dropping yarn-classic, and obsoleting this package for its primary consumer. It does *not* cleanly preserve the audited-mirror / air-gap `--host` flexibility this package was built for (though those deployers already mirror npm, so it is not a hard loss). - **Option 2 — keep the downloader, harden it to class (c).** The design note's plan: optional Sigstore attestation via `@sigstore/verify` (probed in the consumer; this package stays zero-dep), node-re2 ships the `.sigstore` bundle + `sigstore` dep, verification optional/source-scoped so mirror + air-gap deployments keep working. Smaller, well-precedented change; keeps the package's reason to exist; we are early-but-not-alone in Node. **Recommendation:** the measurement tilts toward **Option 2 for node-re2**. The clean simple version of "retire the download" (bundle-all) is impossible at these sizes; the surviving version (Model B) is a heavy restructure with a known reliability tax, and it does not clearly dominate hardening the existing boundary with the strongest available trust root. Option 2 keeps the mirror/air-gap deployments intact and is a proportionate response to the advisory. The one remedy the survey positively rules out is the reporter's own (in-band `SHASUMS256.txt`, class a). Orthogonal wins worth taking regardless of the option chosen: **strip the Linux binaries** (§4 side finding) and **migrate node-re2 to N-API** (24 → 8 binaries, ends the ABI treadmill). --- ## 6. Sources - prebuild-install source + README: github.com/prebuild/prebuild-install (`download.js`, `util.js`) - @mapbox/node-pre-gyp: github.com/mapbox/node-pre-gyp (`lib/install.js`) - node-gyp header verification: github.com/nodejs/node-gyp (`lib/install.js`) - pkg-prebuilds: github.com/julusian/pkg-prebuilds - prebuildify + node-gyp-build: github.com/prebuild/prebuildify, github.com/prebuild/node-gyp-build - napi-rs release model: napi.rs/docs/deep-dive/release; package-template: github.com/napi-rs/package-template - esbuild optionalDependencies move: github.com/evanw/esbuild/pull/1621, issue #789 - sharp migration to npm-hosted binaries: sharp.pixelplumbing.com/changelog/v0.33.0, github.com/lovell/sharp#3750 - npm optional-dep resolver bug: github.com/npm/cli#4828, #8320; vitejs/vite#15532 - GHSA-7cgc-fjv4-52x6 (bignum/node-pre-gyp S3 takeover): github.com/advisories/GHSA-7cgc-fjv4-52x6 - GHSA-gv7w-rqvm-qjhr (withdrawn esbuild/Deno integrity RCE): github.com/advisories/GHSA-gv7w-rqvm-qjhr - npm provenance: docs.npmjs.com/generating-provenance-statements; github.blog "Introducing npm package provenance" - @sigstore/verify / sigstore-js: github.com/sigstore/sigstore-js - GitHub artifact attestations: github.com/actions/attest-build-provenance; cli.github.com/manual/gh_attestation_verify - PyPI PEP 740: peps.python.org/pep-0740; blog.pypi.org/posts/2024-11-14-pypi-now-supports-digital-attestations - cibuildwheel + attestations: cibuildwheel.pypa.io/en/stable/deliver-to-pypi; github.com/pypa/gh-action-pypi-publish - Homebrew build provenance: blog.sigstore.dev/homebrew-build-provenance; Homebrew/brew#17019 - Go checksum database: go.dev/blog/module-mirror-launch - node-re2 1.25.2 release assets (measured 2026-07-07): github.com/uhop/node-re2/releases **Measurement caveats:** unpacked/gzip figures are computed from three sampled binaries (darwin-arm64, linux-x64, win32-x64 at ABI 137) extrapolated across the matrix by the measured per-platform ratios, not a full 24-binary download. The Linux-unstripped hypothesis is inferred from the size/ratio, not confirmed against the build. Third-party download counts and tool version/deprecation states are as of the 2026-07-07 survey and will drift. uhop-install-artifact-from-github-443ee1f/dev-docs/artifact-hash-bag-verification.md000066400000000000000000000517201522333415600305460ustar00rootroot00000000000000# Design note: artifact integrity via a client-owned hash bag **Status:** Proposed — chosen design. Supersedes the Sigstore/attestation direction in `artifact-integrity-verification.md` (that note's threat-model and trust-root reasoning still stand; its *mechanism* is now an "alternative considered" — see §11). **Date:** 2026-07-07. **Origin:** the CWE-494 disclosure (draft advisory GHSA-88q3-gch3-5396). The field survey (`artifact-distribution-field-survey.md`) and the design discussion that followed converged here. This note specifies how a downloaded native addon is verified against a **hash bag embedded in the consuming package's own `package.json`** — the one channel that is immutable (npm) and that an attacker who compromises the GitHub account/release cannot rewrite. No signing key, no Sigstore, no transparency-log service, no extra network, no runtime dependency. Three goals, in the author's words: **tight, secure, good DX.** --- ## 1. What we defend (and what we don't) **Defended — the disclosure's core:** an attacker who can influence the *default* download (swap a GitHub Release asset after publish, a compromised CDN hop, an on-path swap on the `github.com` path) substitutes a malicious `.node`, which is then written and `require()`-loaded → code execution. The hash bag rejects any binary whose bytes are not the ones the maintainer published. **The decisive scenario (why this and not signing):** the attacker owns the GitHub account, rebuilds malicious code, and drops the malicious binary onto the *old* release **without publishing a new npm version**. Because the expected hashes live in the **already-published, immutable** `re2@X.Y.Z` tarball, the swapped binary's SHA-256 won't match, and the attacker cannot rewrite that tarball (that would require a new npm publish — a loud, immutable, opt-in event, further guarded by npm 2FA). → reject → build from source. **Explicitly out of scope** (unchanged from the original threat model): - **Custom-mirror / `--host` deployments.** The bytes there are the deployer's trust root (Company A serves its own audited build; Company B is air-gapped). We do **not** check them — see §6 source-scoping. An attacker who can set your mirror env var already owns the machine. - **A compromise *before* the maintainer's clean publish** (garbage-in): no post-hoc check helps. - **A new malicious npm version.** Immutable, visible, opt-in, npm-2FA-guarded — a different, louder attack than the silent old-release swap. - **Build-system compromise** (malware injected into CI without a source change): SLSA build-integrity territory, not a consumer-side concern. --- ## 2. The mechanism in one paragraph At release time the maintainer computes the SHA-256 of each platform's **decompressed `.node`** and embeds a map `{ slot → sha256 }` into the package's `package.json`, then publishes. At install time, `install-from-cache` (already running in the consumer's `install` hook) downloads the binary from the **default `github.com`** source, decompresses it, and — before writing it to disk — compares its SHA-256 against the bag it reads from the same immutable `package.json`. Match → write. Mismatch, or a binary present that the bag doesn't cover → **reject → source build**. Custom `--host`, or no bag at all → skip (unchanged behavior). The trust root is the immutability of the npm-published `package.json`. Nothing else. --- ## 3. Where the bag lives — the client package, never here The hash bag is **data owned by the client** (`node-re2`, or any consumer), embedded in **the client's** `package.json`. It is emphatically **not** stored in `install-artifact-from-github` or any third package: - `install-artifact-from-github` is a generic, zero-dependency tool shared by many consumers. It cannot hold any one consumer's hashes, and it must not grow per-consumer data. - The hashes are only trustworthy because they ride the **consumer's own immutable npm version**. A hash bag in a *shared* package would be pinned to *that* package's version, not the consumer's release — the wrong immutability boundary. `install-from-cache` already reads the consumer's `package.json` at install time (it parses `process.env.npm_package_json` for `github`, `version`, `scripts.verify-build`). Reading one more field — the bag — is free and requires no new plumbing. The *tooling* to generate the bag is shared (§8); the *bag* is the client's. --- ## 4. Bag format A top-level object in the client `package.json`, keyed by the same **slot string** `install-from-cache` already computes for the download URL: ```jsonc { "name": "re2", "version": "1.25.2", "artifactHashes": { "linux-x64-137": "sha256:9e68bb76…", "linux-musl-arm64-147": "sha256:c27d339e…", "darwin-arm64-137": "sha256:fe4fe40a…", "win32-x64-127": "sha256:aad2c369…" // one entry per built (platform-arch-abi) slot; N-API slots read `…-napi-v8` } } ``` - **Key** = `${platform}-${arch}-${abiSlot}` — identical to what `install-from-cache` computes (`abiSlot` is `${modules}` or `napi-v${level}`). Prefix/suffix are *not* part of the key (they're transport-layer decoration); the key is the platform identity. Both sides derive it from one shared function so they can never disagree. - **Value** = `sha256:` + lowercase hex of the **decompressed `.node`** (the bytes that run), never the `.br`/`.gz` wire bytes (compression is non-deterministic across encoders/mirrors). The algorithm prefix leaves room for future hashes; SRI form (`sha256-`) is an acceptable alternative if matching npm's own `integrity` style is preferred. Field name (`artifactHashes`), value form, and bin name (§8) are the only bikeable choices; the mechanism doesn't depend on them. --- ## 5. Verification — the change in `install-from-cache` `install-from-cache.js` today: for each compression format it does `get → decompress → write → copied=true`. The change: the fallback chain produces the **decompressed buffer only**; then a single verify step gates the write. ```js import {createHash} from 'node:crypto'; // built-in, keeps the package zero-dep const isDefaultSource = !mirrorHost && !process.env[mirrorEnvVar]; // §6 const slot = `${platform}-${platformArch}-${abiSlot}`; // shared slot fn const bag = pkg.artifactHashes; // read from parsed package.json // … fallback chain yields the decompressed `artifact` Buffer … const verdict = verify(artifact); // 'accept' | 'reject' | 'skip' if (verdict !== 'reject') { await write(artifactPath, artifact); copied = true; } // 'reject' leaves copied=false → falls through to rebuild function verify(bytes) { if (!isDefaultSource || !bag) return 'skip'; // custom host, or bagless package const expected = bag[slot]; const actual = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); if (expected && expected === actual) return 'accept'; console.log(`Integrity check failed for ${slot}: building from sources …`); return 'reject'; // mismatch OR missing entry → reject } ``` The three-case behavior, exhaustively: | Situation | Verdict | Result | |---|---|---| | Default `github.com`, bag present, hash **matches** | accept | write the binary | | Default `github.com`, bag present, hash **mismatches** | reject | discard → `npm run rebuild` | | Default `github.com`, bag present, **no entry for this slot** | reject | discard → `npm run rebuild` | | `--host` / mirror env set | skip | write (deployer's trust root) | | No `artifactHashes` in `package.json` | skip | write (unchanged; every existing consumer) | **Why "missing entry → reject" and not "→ proceed":** because the maintainer builds *before* publishing (see §9), the bag is **complete** — every binary that exists in a release is hashed. So a binary that downloads from the default source but has *no* bag entry is an anomaly the maintainer's workflow cannot produce; the safe reading is "this shouldn't exist" → reject. This closes the otherwise-open hole where an attacker uploads a malicious binary for a slot that had no prebuilt at release (hence no hash). A false positive (a hashing bug that omits a real slot) degrades safely to a source build. Verification runs wherever the `install` hook runs. Under npm 12's script-off default the user approves the install script exactly as they approve the download today — same gate, no new surface (see `[[topics/npm-12-install-scripts-default-off]]`). --- ## 6. Source-scoping Verification is gated on `isDefaultSource = !mirrorHost && !process.env[mirrorEnvVar]` — i.e., we verify **only** when the binary comes from the built-in `https://github.com` origin that the maintainer actually publishes and hashes. The instant a consumer sets `--host` / `--host-var` / the mirror env var, we **skip** verification entirely. This is not a gap — it's the point. A curated mirror legitimately serves *different* bytes (its own audited build), whose hash isn't in our bag; checking it would wrongly reject a valid binary and break exactly the deployment the mirror feature exists for. We never look, so we never break it. The download already resolves `mirrorHost || process.env[mirrorEnvVar] || 'https://github.com'`, so "is this the default origin?" is known for free at the gate. --- ## 7. Opt-out: force a source build (download nothing) The strongest integrity choice a consumer can make is to **not download at all** — compile from source, trusting only the immutable npm-delivered JS plus the local toolchain. Nothing is fetched, so there is nothing to verify; the hash bag is moot. This is already possible, but implicitly: `install-from-cache` short-circuits straight to `npm run rebuild` when `DEVELOPMENT_SKIP_GETTING_ASSET` is set or a `.development` file exists. We add an explicit, security-framed control beside them, following the project's `--flag` / `--flag-var ENVVAR` / `DEFAULT_ENVVAR` cascade, so the intent reads as a security choice rather than a "development" flag: - `--force-build` - `--force-build-var ` — lets a library expose a namespaced var, e.g. node-re2 wiring `--force-build-var RE2_FORCE_BUILD` - `DOWNLOAD_FORCE_BUILD` Any of these (the pre-existing `DEVELOPMENT_SKIP_GETTING_ASSET` / `.development` stay, for back-compat) extends the existing top-of-flow short-circuit: `install-from-cache` skips the download attempt entirely and goes straight to `npm run rebuild`. Because it fires **before any URL is built, any byte fetched, or any hash checked**, it composes cleanly with everything above — the download path, and therefore §6 source-scoping and §5 verification, simply never run. **Why it belongs in the security story.** It collapses the trust surface to the two things a consumer already trusts unavoidably — **npm** (the JS + this control) and **their own compiler** — and gives a deployer who doesn't want to trust *any* prebuilt binary (ours or a mirror's) a one-word, self-documenting opt-out, with no `.development` file to invent or mirror flags to reason about. It is the natural companion to the default: *verify what you download, or download nothing and build it yourself.* ## 8. The generator utility A new **bin in `install-artifact-from-github`** (shared tooling; the *data* it writes lands in the client). Working name `hash-github-cache` — sibling to `install-from-cache` / `save-to-github-cache`; bikeable. **Job:** sniff the release's artifacts, compute each decompressed `.node`'s SHA-256 keyed by slot, and write the `artifactHashes` bag into the client `package.json`. **Inputs (two source modes):** - `--from-release []` *(recommended; defaults to the current `package.json` `version`, so hooks need no shell-variable plumbing)* — enumerate the GitHub Release assets (via the repo from `package.json` `github`), download each slot's asset, decompress, hash. Hashes **exactly the bytes users will download**, from the same place, and a single invocation sees every platform (no cross-matrix collection needed). Public-repo asset download needs no token. - `--from ` — hash slot-named artifacts already collected locally (for single-host builds or network-free CI). Same decompress-then-hash path. **Reuses `install-from-cache`'s own logic** for the slot string and the decompression fallback, so the generator and the verifier are guaranteed to agree on both the key and the hashed bytes. **Modes:** - `--write` — stamp/refresh `artifactHashes` in `package.json` (the release path). - `--check` — recompute and **exit non-zero** if the bag is missing, stale, or wrong, writing nothing (the publish guard, §9). **Faithfulness:** it decompresses and hashes the `.node`, identical to what the verifier does at install — the two share the code, so a bag produced here always verifies there. --- ## 9. Automation & DX The maintainer builds **before** publishing and never adds artifacts post-publish (the "build after publish / rebuild botched artifact" capability exists but has never been used in practice). So the release ordering is: **tag → CI builds every platform → `save-to-github-cache` uploads the assets → hash the release → publish.** The bag is always complete at publish time. One npm hook makes plain `npm publish` self-completing. This relies on npm packing an in-hook `package.json` edit, which is **verified** (npm 11.17.0): a lifecycle hook that rewrites `package.json` before packing produces a tarball containing the edit — the pack step reads files from disk after the hooks run. `npm publish` runs `prepublishOnly` → `prepack` → *pack*, so: ```jsonc { "scripts": { "prepublishOnly": "hash-github-cache --write" } } ``` - **Why `prepublishOnly`:** it runs **only** on `npm publish` — never on `npm pack` or `npm install` — so the network fetch of release assets happens exactly when publishing and nowhere else. (`prepack` would also work timing-wise but fires on every `npm pack`.) - **The write *is* the guard.** The bag is regenerated from the live release on every publish, so it cannot be stale by construction; and `hash-github-cache` **fails hard** — aborting the publish — if the release is missing or incomplete for the version being published. There is no "publish → check → fail → fix → publish again" loop; the fix happens inline or the publish dies. - **`--check` remains** for everything else: CI verification, and **post-publish tamper monitoring** — `hash-github-cache --check --from-release ` at any later time confirms the live release assets still match the published bag; a failure means an asset changed after publish. One tool: prevention at install (consumers reject a swap), detection after publish (the maintainer notices it). - **Caveats:** never mutate `name`/`version` in a hook (npm reads those at CLI start; a custom field like `artifactHashes` is safe — verified above). The hook leaves the stamped `package.json` in the working tree — commit it after the release. The tagged commit necessarily predates the bag (artifacts are built *from* the tag), which is fine: the npm tarball, not git, is the trust root. After the first release, `npm view @ artifactHashes` is a quick sanity check that the registry metadata saw the field; regardless of that metadata view, the tarball — what installs actually read — is the verified carrier. For a maintainer who prefers `package.json` never to be machine-edited mid-publish, the two-command alternative avoids lifecycle timing entirely, with `--check` as the publish guard: ```jsonc { "scripts": { "release": "hash-github-cache --write && npm publish", "prepublishOnly": "hash-github-cache --check" } } ``` **Prerequisite either way:** all release assets must exist before the bag is generated — already true given build-before-publish. A publish attempted before the release is populated fails loudly rather than shipping a partial bag. --- ## 10. Client adoption guide (e.g. node-re2) For a consumer that already downloads via `install-from-cache`: 1. **Bump `install-artifact-from-github`** to the version that ships `hash-github-cache` + source-scoped verification. No other dependency — verification is `node:crypto`. 2. **Add the `prepublishOnly` script** from §9 — plain `npm publish` then stamps a fresh bag and ships it in the packed tarball. 3. **First bag:** the next `npm publish` stamps it automatically (run `hash-github-cache --write` beforehand to preview the diff); commit the stamped `package.json` after the release. 4. **CI:** ensure the release is fully populated (all matrix builds uploaded) before the publish job runs `npm publish` — the hook does the rest. 5. **Nothing changes for consumers of *your* package** — they just start getting a verified binary on the default source; mirror users are unaffected (§6). Pairs naturally with the **strip step** already queued for node-re2 (`[[projects/node-re2/queue]]`): stripping shrinks the very bytes this hashes, and both live in the same pre-publish build stage. --- ## 11. Alternatives considered, and why each loses - **In-band `SHASUMS256.txt` in the release** (the reporter's suggestion) — class-(a): the hash file sits in the *same mutable release* as the binary, so the same account compromise rewrites both. Useless against the decisive scenario. The hash bag is the *same idea moved to the immutable npm channel*, which is the whole difference. - **Sigstore keyless attestation** — the trust root (Rekor + CI OIDC) sits outside the account, which defeats a binary swap by someone who *can't* run your CI. But an **account-compromise attacker can run your CI**, minting a *genuinely valid* attestation over the malicious binary (append-only Rekor never rejects a second attestation for the same version). So it doesn't stop the decisive scenario without an extra immutable pin — at which point the pin (a hash/commit in npm) is doing the work and the Sigstore machinery (a `sigstore` dep + a `.sigstore` asset per binary + a trust root) is dead weight. Research confirmed the account-compromise hole and that the rich attestation fields (commit, ref, run-id, Rekor time) don't close it. - **Maintainer-held signing key (minisign/GPG-style)** — *would* defeat the scenario if the key lived outside the account. But research confirmed **no signing key stored in GitHub can be protected** against a compromised account/token: Actions secrets are handed to any triggerable job with no use-time 2FA, and environment "required reviewer" gates are approvable via a plain REST call by a held token (self-approval on by default). So a key-based scheme needs an *external hardware token* — real key-custody burden — to beat what the hash bag achieves with no key at all. - **Reject-duplicates via the transparency log** ("count how many binaries were attested as vX") — not implementable: Rekor and GitHub's attestations API are both keyed by *artifact digest*, not version/ref, so you cannot enumerate "all attestations claiming `refs/tags/1.25.2`"; the malicious binary is a different digest with its own single valid attestation (count = 1); Rekor v2 removed online search; and even hypothetically, "earliest-wins" is unsound (an attacker who attests *first* is selected). Also inherently online. - **Ship binaries in npm (prebuildify / per-platform `optionalDependencies`)** — makes CWE-494 not-applicable (npm SRI covers the bytes) and is the industry trend (sharp migrated this way), but for node-re2 the full-matrix bundle is disqualified by size and the per-platform-packages route is a heavy restructure with the well-known npm optional-dep resolver breakage. See the field survey. The hash bag keeps the download model and adds the missing integrity in ~30 lines. **Complementary hardening (not a replacement):** enabling GitHub **immutable releases** (recent, off by default) makes release assets tamper-proof after publication and blocks tag resurrection — it blunts the swap-on-old-release vector at the GitHub layer. Worth turning on; the hash bag stays primary because it is self-contained and also covers mirror/MITM/any-source cases a GitHub-only feature can't. --- ## 12. Security properties, summarized - **Trust root:** immutability of the client's npm-published `package.json`. No key, no third-party service, no transparency log. - **Prevents:** silent post-publish swap of a `github.com` release binary (the disclosure's decisive scenario) — mismatch or unbagged-binary → source build. - **Zero added attack surface:** no new network call (bag is local), no new dependency (`node:crypto`), no signing identity to leak. - **Non-breaking:** custom-host and bagless installs behave exactly as today. - **Opt-out:** `--force-build` / `DOWNLOAD_FORCE_BUILD` (§7) skips the download entirely — download nothing, verify nothing, trust only npm + the local toolchain. - **Fail-safe:** every rejection path degrades to the existing lossless source build — the worst case is "the user compiles, as they would have without us." uhop-install-artifact-from-github-443ee1f/dev-docs/artifact-integrity-verification.md000066400000000000000000000463101522333415600311110ustar00rootroot00000000000000# Design note: optional artifact integrity verification **Status:** Proposed / draft — no code yet. **Date:** 2026-07-06. **Origin:** a private CWE-494 disclosure (downloaded native addon written and loaded with no integrity check), and the design discussion it triggered. See `SECURITY.md`. This note proposes how `install-from-cache` can verify the integrity/provenance of a downloaded artifact **without breaking any existing consumer or deployment**, and without adding a runtime dependency to this (zero-dep) package. It is deliberately *optional, source-scoped, and trust-root-agnostic* — the reasoning for those three words is most of the note. --- ## 1. Problem and threat model `install-from-cache` downloads a prebuilt native addon (`*.node`) and writes it to the artifact path; the consumer then `require()`s it, executing native code. Today nothing checks that the bytes are the ones the author published. Anyone who can influence the download — a swapped GitHub Release asset, a compromised mirror, an on-path attacker on a plaintext hop — achieves code execution at install and at every later `require()`. **What we can meaningfully defend:** the *default public path* (fetch from `github.com`), specifically the **release-asset-swap** scenario: an attacker who compromises the repo/account and replaces the release binary *after publish*, with no npm republish and no lockfile change. **What we explicitly do NOT try to defend** (see §9 for why each is out of scope): transport security in general, curated-mirror deployments, air-gapped deployments, and source-poisoning-via-CI. Those either belong to the deployer's trust boundary or cannot be addressed without breaking the deployments that depend on the current behavior. --- ## 2. Constraints that shape the design (the hard ones) 1. **Zero runtime dependencies** in this package. Verification crypto must live in the *consumer's* dependency tree, probed at runtime — never bundled here. 2. **Generic tool, unknown consumers.** Any new default must be **non-breaking**: an existing consumer that does nothing must install exactly as it does today. 3. **Must not break curated mirrors (Company A).** A deployment that serves its *own* audited builds from its *own* servers (`--host` + naming flags) has a different trust root than GitHub. Mandatory verification against GitHub/Sigstore would reject their legitimate, intentionally-different binaries. 4. **Must not break air-gapped mirrors (Company B).** A closed network that mirrors npm but not GitHub cannot reach Sigstore's TUF/Rekor, and often runs plaintext `http`. Mandatory online verification (or mandatory TLS) locks them out. 5. **Convenience-vs-trust is irreducible.** The package exists to *skip compilation*; the strongest posture is *compile everything*. You cannot have both as the default. The design exposes switches so each deployer picks a side; it does not pick for them. The recurring lesson: **integrity is "matches trust root X," and which X is correct is a per-deployment decision.** Hardwiring one X breaks the deployers who chose another. --- ## 3. Trust model: three deployments, three boundaries | Deployment | Source | Integrity boundary | This design's role | | --- | --- | --- | --- | | **Public default** | `github.com` | GitHub's TLS; residual = release-asset swap | **auto-verify GitHub provenance** when the consumer opted in via a dep | | **Curated mirror (A)** | own servers, own naming | network isolation + own audited build | stay out of the way; verification off unless the org supplies its own | | **Air-gapped (B)** | internal store, no internet | network isolation | force-build, or a consumer-supplied offline verifier | `install-from-cache` enforces none of these boundaries by fiat; it provides mechanisms each deployment opts into. --- ## 4. Design ### 4.1 Decision flow ``` if force-build (env/flag/.development): → recompile # opt-out, unchanged mechanism download artifact (existing .br → .gz → plain chain), decompress to `bytes` pick a verifier: --verifier supplied → custom verifier else source is GitHub AND a Sigstore lib resolves → built-in Sigstore verifier else → none run verifier(bytes, ctx) → { result, detail? }: result === 'accept' → write + optional functional smoke-test + done result === 'reject' → REJECT → recompile # definitive failure: ALWAYS fatal anything else / no verifier: # undefined, unknown string, or a throw require-verify set → REJECT → recompile else → write + continue # non-breaking default ``` Two invariants make this safe: - **A definitive verification failure (digest mismatch / wrong signer / not in log) is always fatal**, regardless of `require-verify`. "Can't verify" and "verification failed" are different outcomes; conflating them is the classic dangerous bug. - **The built-in Sigstore verifier only auto-activates on the GitHub source.** A mirror host never triggers it, so Company A/B are untouched by default. `require-verify` + a custom `--verifier` still work on any source for deployments that want them. Verification runs on the **decompressed bytes in memory, before `write()` and before the functional smoke-test**, so a bad artifact is never written or loaded. (Today's `verify-build`/`test` step runs *after* load and is a functional smoke-test, not an integrity gate — it stays, but is no longer security-relevant.) Note this also verifies **cross-builds** (`npm_config_platform*`), which currently skip *all* checking — provenance is platform-independent, so that path strictly improves. ### 4.2 Verifier contract (the `--verifier` hook) Loaded exactly like `--agent` (`loadAgent`): a module whose default export is an async function returning a single verdict object. ```js // export default async (bytes, ctx) => { result, detail? } // ctx = { assetName, assetUrl, host, isGithubSource, // repo: { owner, name }, version, platform, arch, abi, napiLevel } // // result === 'accept' → use the artifact // result === 'reject' → recompile — ALWAYS, ignores require-verify (definitive failure) // anything else → indeterminate → require-verify decides (default: continue) // (undefined, an unknown string, or a thrown error all land here) // `detail` is an optional human-readable string for any result (logged). ``` Only the exact string `'accept'` uses the binary, so a broken or confused verifier fails **toward not-accepting**, never toward silent acceptance. A throw is `indeterminate`, not special-cased — so under `require-verify` a verifier that dies on a crafted input still fails closed, and under the default policy it continues (no worse than having no verifier). The built-in Sigstore verifier implements this same contract internally. ### 4.3 Built-in Sigstore verifier (the GitHub path) - Probe `@sigstore/verify` (lean) or `sigstore` (umbrella) via dynamic `import()`; if neither resolves → indeterminate (`detail: 'no-verifier'`). - Obtain the attestation **bundle** for the artifact (see §6 for retrieval); if none → indeterminate (`detail: 'no-attestation'`). - Verify with a **policy pinning the signer**: certificate SAN under `https://github.com///…` (owner/repo from `npm_package_github`), issuer `https://token.actions.githubusercontent.com`. Optionally tighten to a specific workflow file via `--signer-workflow`. - Verify the artifact digest (of the decompressed bytes) against the attested subject, and the Rekor inclusion proof. Pass → `{ result: 'accept' }`; any check fails → `{ result: 'reject' }`. **Why the bundle can live anywhere (incl. a mutable release asset):** verification pins the *signer identity* (which rides the immutable npm channel in the consumer's config) and checks Rekor's append-only log. An attacker who swaps the binary cannot produce a bundle that simultaneously attests *their* digest, is signed by *node-re2's* workflow identity, and is in the log — they'd need a Fulcio cert for that OIDC identity (i.e. to actually run that workflow). So bundle location is security-irrelevant; shipping it as an asset only buys **no-auth / offline** verification. ### 4.4 Force-build opt-out (already exists; add a clear alias) `isDev()` already short-circuits to a source build on `DEVELOPMENT_SKIP_GETTING_ASSET` or a `.development` file. For the security framing, add an alias that reads correctly in a hardened config: - `--force-build` / `--force-build-var` (default env `DOWNLOAD_FORCE_BUILD`) → same `break checks` → `npm run rebuild` path. Setting it collapses trust to **npm + the local toolchain** — no download, no extra root, no hashes needed because there is nothing downloaded to verify. ### 4.5 Config surface (all additive, all optional) | Flag | `-var` env default | Meaning | | --- | --- | --- | | `--force-build` | `DOWNLOAD_FORCE_BUILD` | skip download, build from source | | `--verifier ` | `DOWNLOAD_VERIFIER` | consumer-supplied verifier module | | `--require-verify` | `DOWNLOAD_REQUIRE_VERIFY` | "can't verify" ⇒ reject+recompile (default: continue) | | `--signer-workflow ` | `DOWNLOAD_SIGNER_WORKFLOW` | tighten built-in policy to one workflow file | Precedence for `require-verify`: an explicit env value of `0`/`false` overrides the flag (operator escape hatch). An attacker who can set env vars can already set `--host` to their own mirror, so this does not widen the threat model. ### 4.6 What must be pinned where (invariants) - **Expected signer identity** → the consumer's package (immutable npm channel). If the verifier trusted "whatever identity the bundle carries," swapping would win. - **The verifier dependency** → the consumer's `dependencies`, so it is present at install time and lockfile-integrity-pinned (an attacker can't silently drop it). - Nothing security-relevant is pinned in the GitHub Release, which is mutable. --- ## 5. Producer side (CI) node-re2 already emits GitHub artifact attestations — `actions/attest-build-provenance@v4` over `build/Release/re2.node` in every matrix job (`build.yml`). Two small hardenings: 1. **Fail the job if attestation fails** (don't leave an un-attested asset on the release). 2. **Publish the bundle as a release asset** next to the binary (e.g. `.sigstore`), so end users verify offline with no `gh` and no token — and so air-gapped mirrors can mirror it. `attest-build-provenance` exposes the bundle via its `bundle-path` output; add an upload step. (`save-to-github-cache` may grow a `--bundle` companion upload.) --- ## 6. Attestation retrieval (open, but recommended path) Three ways for the built-in verifier to get the bundle: - **(recommended) shipped asset** — fetch `.sigstore`; no auth, offline-capable, mirrorable. Requires the producer step in §5.2. - GitHub attestation API by digest — authoritative but generally needs a token (fine in CI, awkward on end-user machines). - Rekor search by digest — public, no auth, but more work. Recommend the shipped asset for the default path; allow a custom `--verifier` to choose otherwise. --- ## 7. Worked examples ### 7.1 node-re2 (canonical consumer) **`package.json`** — add the verifier to real `dependencies` (needed at install time, so not devDeps): ```jsonc { "dependencies": { "sigstore": "^5.0.0" // or the lean subset: @sigstore/verify + @sigstore/bundle + @sigstore/tuf }, "scripts": { "install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --skip-path-var RE2_DOWNLOAD_SKIP_PATH --skip-ver-var RE2_DOWNLOAD_SKIP_VER --require-verify --require-verify-var RE2_REQUIRE_VERIFY --force-build-var RE2_FORCE_BUILD || node-gyp -j max rebuild" } } ``` **What each deployment gets, no extra user action:** - **Public user** → source is `github.com`, `sigstore` is present (it's a dep), a bundle exists → **provenance verified before the binary is written**. A swapped binary → digest mismatch → recompile. A stripped attestation → `require-verify` → recompile. The ~99% are protected **by default**, because the dep is always present and mismatch is always fatal. - **Company A** (`RE2_DOWNLOAD_MIRROR=https://artifacts.corp …`) → non-GitHub source → built-in verifier does not auto-activate; `require-verify` has no verifier to satisfy on a mirror source **and A did not ask for it** → their own audited binary installs unchanged. - **Company B** (air-gapped mirror) → same as A; or they set `RE2_FORCE_BUILD=1` on the few capable build boxes; or they supply an offline verifier (§7.3). - **Anyone** who wants zero download-trust → `RE2_FORCE_BUILD=1` → compile from source. **Per-version immutability handles old releases for free:** the `--require-verify` in the hook ships from the *next* release onward, and *that* release is attested. Installs of older, un-attested versions carry their *old* hook (no `--require-verify`) and keep working. No flag day, no conditional logic. **CI:** already attests; add the two hardenings in §5. ### 7.2 Simpler generic consumer (backward-compatible no-op) A small addon that never opted into provenance: ```jsonc { "scripts": { "install": "install-from-cache --artifact build/Release/foo.node || node-gyp rebuild" } } ``` No `sigstore` dep, no new flags. Behavior is **identical to today**: download from GitHub, built-in verifier unavailable → `{ indeterminate: 'no-verifier' }`, `require-verify` off → write and continue, silently. Zero friction, zero new surface. If it later wants provenance, it adds the dep + CI attestation (and optionally `--require-verify`) — a strictly additive upgrade. ### 7.3 More complex generic consumers **(a) Air-gapped, verifying mirrored GitHub-provenanced binaries offline.** The org mirrors the binaries *and* their `.sigstore` bundles, ships a pinned Sigstore trusted root, and supplies a custom verifier plus `--require-verify`: ```jsonc "install": "install-from-cache --artifact build/Release/re2.node --host-var RE2_DOWNLOAD_MIRROR --verifier ./verifiers/offline-sigstore.mjs --require-verify || node-gyp -j max rebuild" ``` ```js // verifiers/offline-sigstore.mjs import { readFile } from 'node:fs/promises'; import { Verifier, toTrustMaterial } from '@sigstore/verify'; import { bundleFromJSON } from '@sigstore/bundle'; const trustedRoot = JSON.parse(await readFile(new URL('./trusted-root.json', import.meta.url))); export default async (bytes, ctx) => { try { const bundle = bundleFromJSON(JSON.parse(await readFile(ctx.assetUrl + '.sigstore', 'utf8'))); const verifier = new Verifier(toTrustMaterial(trustedRoot)); // no TUF/Rekor network verifier.verify(bundle, { subjectDigest: { sha256: sha256(bytes) }, // decompressed artifact certificateIdentity: { issuer: 'https://token.actions.githubusercontent.com', subjectAlternativeName: `https://github.com/uhop/node-re2/` // prefix-pinned } }); return { verified: true }; } catch (e) { return { verified: false, reason: e.message }; } }; ``` Here the trust root is *shipped*, not fetched; the mirror serves both binary and bundle; and a swap still fails because the pinned identity + signature can't be forged offline either. **(b) Own signing infrastructure (not GitHub/Sigstore at all).** An org that signs its addons with its own key (cosign, minisign, x509, whatever) supplies a verifier implementing *their* scheme. `install-from-cache` stays trust-root-agnostic — it only cares about the verdict: ```jsonc "install": "install-from-cache --artifact build/Release/foo.node --host-var FOO_MIRROR --verifier ./verifiers/minisign.mjs --require-verify || node-gyp rebuild" ``` This is the general shape: **the tool routes bytes + context to a verifier and enforces the verdict; the trust root lives entirely in the consumer's module.** GitHub/Sigstore is just the batteries-included default for the common case. --- ## 8. Testing - **Existing mock-server suite stays green** — it runs the "no verifier" path (no `sigstore` installed) → indeterminate + continue → unchanged. - **New tests are additive**, and mostly use an **injected fake verifier** (`--verifier ./tests/helpers/fake-verifier.js` returning a scripted verdict) to exercise the decision tree: verified→use, failed→reject (assert recompile), indeterminate×{require, continue}. No real Fulcio/Rekor in unit tests. - **One integration test** may verify a **checked-in real bundle fixture** with `@sigstore/verify` to guard the built-in path end-to-end; keep it out of the fast unit loop. - The `force-build` alias needs a test that it short-circuits to `rebuild` like `.development`. --- ## 9. Explicitly rejected alternatives (so they aren't re-proposed) - **Mandatory hash pinned in the package.** Breaks Company A (their audited build ≠ upstream bytes) and forces a per-release, per-tuple manifest + build-before-publish choreography. Superseded by opt-in provenance. - **`SHASUMS256.txt` in the release.** Defeated by the *same* account compromise that swaps the binary — the attacker rewrites both. Provenance's trust root is outside the account; this isn't. - **Mandatory hashing at all.** A hash encodes one trust root; mandating it locks out deployers who chose another (A and B). Verification must be opt-in and source-scoped. - **Reject `http://` / force TLS.** The mirror population is precisely the one without validatable TLS (closed nets, self-signed certs that Node can't validate anyway). Forcing TLS breaks them and buys nothing on the public path (GitHub never downgrades; its TLS can't be injected). Transport security is not a lever this tool can pull. - **Making source-build the default (safe-by-default).** Destroys the package's reason to exist (skip compilation). Opt-out, not opt-in, is the only viable direction. --- ## 10. Open questions 1. `sigstore` (umbrella) vs `@sigstore/verify` + `@sigstore/bundle` + `@sigstore/tuf` (lean) as node-re2's dep — footprint vs simplicity. 2. Should node-re2 default `--require-verify` on immediately, or ship it "warn-only" for one release to observe field behavior first? 3. Exact built-in policy default: prefix-pin the repo (any workflow) vs require `--signer-workflow`. 4. Attestation retrieval default (§6) — commit to the shipped `.sigstore` asset? 5. Env-var names (bikeshed): `DOWNLOAD_*` prefix confirmed; exact suffixes TBD. --- ## 11. One-paragraph summary (for the reporter) We are not missing an integrity primitive — every node-re2 release binary already carries an unforgeable, transparency-logged GitHub/Sigstore provenance attestation whose trust root a compromised account cannot reach. The gap is *consumer-side verification*, which we are adding as an **optional, source-scoped, trust-root-agnostic** check: auto-on for the public GitHub path (default-on for node-re2 by making the verifier a dependency), a definitive mismatch always fatal, missing-attestation enforced per-consumer via `--require-verify`, a `--verifier` hook for air-gapped/custom roots, and a `force-build` opt-out for npm-only trust. We decline mandatory hashing and forced TLS because both would lock out the curated-mirror and air-gapped deployments the tool was built to serve. uhop-install-artifact-from-github-443ee1f/llms-full.txt000066400000000000000000000370561522333415600232510ustar00rootroot00000000000000# install-artifact-from-github > A no-dependency micro helper for developers of binary Node addons. Three single-file bins integrated with GitHub Releases: `save-to-github-cache` uploads pre-built binary artifacts from CI; `install-from-cache` downloads the right artifact at install time, optionally verifies its SHA-256 against a hash bag pinned in the addon's `package.json`, and falls back to building from sources on any failure; `hash-github-cache` generates that hash bag at release time. Zero dependencies, ESM, Node >= 18. The package has no importable API — it is consumed entirely through the three bins, wired into a consuming addon's `package.json` scripts. The download path is a lossless optimization: every failure mode (missing asset, network error, failed verification, misconfiguration) degrades to `npm run rebuild`, the textbook `node-gyp` flow. ## Install ```bash npm install --save install-artifact-from-github ``` ## Consumer setup In the addon's `package.json`: ```json { "scripts": { "save-to-github": "save-to-github-cache --artifact build/Release/ABC.node", "install": "install-from-cache --artifact build/Release/ABC.node", "verify-build": "node scripts/verify-build.js", "rebuild": "node-gyp rebuild" } } ``` - `install` — runs on the user's machine during `npm install` of the addon. - `verify-build` — used by `install` to check the downloaded artifact works; if absent, `npm test` is used; if both are absent, the download is not trusted and the source build runs. - `rebuild` — used by `install` to build from sources when the download path fails; must be provided. - `save-to-github` — run from CI (GitHub Actions) after building the artifact on a tagged release. ## install-from-cache Algorithm: 1. Short-circuit to the source build if `DEVELOPMENT_SKIP_GETTING_ASSET` is set, a `.development` file exists in the project folder, or *(since 1.7.0)* a forced build is requested (`--force-build` / `DOWNLOAD_FORCE_BUILD`). 2. Compute the asset URL (see "Asset URL format" below). 3. Try downloading `${url}.br` (if the running Node supports brotli), then `${url}.gz`, then `${url}` uncompressed. Each failure falls through silently. HTTP 3xx redirects are followed; a host that is not `http(s)://` is read as a local filesystem path. 4. *(since 1.7.0)* Integrity check: if the consumer ships an `artifactHashes` bag and the download came from the canonical source, the decompressed bytes' SHA-256 must match the bag entry for this slot before anything is written; a failure rejects the artifact and falls through to the source build (see "Artifact integrity verification" below). 5. Decompress and write the artifact to the `--artifact` path (directories are created as needed). 6. Verify: run `npm run verify-build` if the consumer defines it, else `npm test`, suppressing output unless `DEVELOPMENT_SHOW_VERIFICATION_RESULTS` is set. Exit code 0 means done. 7. Any failure above ends in `npm run rebuild`. When any of the cross-build overrides (`npm_config_platform`, `npm_config_platform_arch`, `npm_config_platform_abi`) is set, the `verify-build` step is skipped — a foreign binary cannot be tested on the build machine. (Integrity verification, which only checks bytes, still runs.) ### Command-line parameters - `--artifact path` — location to write the downloaded artifact. Required. - `--prefix prefix` — prefix for the generated artifact name. Default: `''`. - `--suffix suffix` — suffix for the generated artifact name. Default: `''`. - `--host host` — download host with optional path prefix, no trailing `/`. Default: `https://github.com`. A non-HTTP value (e.g. `/opt/third-party/re2`) serves artifacts from the local filesystem. - `--host-var ENVVAR` — name of the env var carrying the host. Default name: `DOWNLOAD_HOST`. Used only if `--host` is absent. - `--skip-path` — drop the `/${owner}/${repo}/releases/download` part of the URL (for mirrors with a flat layout). - `--skip-path-var ENVVAR` — env-var name for the same. Default name: `DOWNLOAD_SKIP_PATH`. - `--skip-ver` — drop the `/${version}` part of the URL. - `--skip-ver-var ENVVAR` — env-var name for the same. Default name: `DOWNLOAD_SKIP_VER`. - `--agent module-path` — path (resolved against `process.cwd()`) to a JS module whose default export is an `http.Agent` instance; used as the `agent` option for every download (proxy support). A load failure prints a warning and continues without a proxy. - `--agent-var ENVVAR` — env-var name for the agent module path. Default name: `DOWNLOAD_AGENT`. - `--napi level` — declare the N-API level of the published binaries; replaces the Node-ABI URL slot with `napi-v${level}`. - `--napi-var ENVVAR` — env-var name for the N-API level. Default name: `DOWNLOAD_NAPI`. (Also honors `npm_config_platform_napi` for cross-builds.) - *(since 1.7.0)* `--force-build` — skip the download entirely and go straight to `npm run rebuild`; nothing is fetched or verified. A clearly-named alias for the `DEVELOPMENT_SKIP_GETTING_ASSET` / `.development` short-circuit, for consumers who prefer to trust only npm plus their own toolchain. - *(since 1.7.0)* `--force-build-var ENVVAR` — env-var name for the forced build. Default name: `DOWNLOAD_FORCE_BUILD`. The `--flag` form always wins over `--flag-var`; the `--flag-var`-named env var wins over the default-named env var. Library authors should prefer `--xxx-var MYPKG_XXX` so consumers can configure one addon without affecting others. ### Environment variables - `DEVELOPMENT_SKIP_GETTING_ASSET` — non-empty forces the source build (for development and CI builds). - `DEVELOPMENT_SHOW_VERIFICATION_RESULTS` — non-empty shows `verify-build` output. - `DOWNLOAD_HOST`, `DOWNLOAD_SKIP_PATH`, `DOWNLOAD_SKIP_VER`, `DOWNLOAD_AGENT`, `DOWNLOAD_NAPI` — defaults for the corresponding flags above. - *(since 1.7.0)* `DOWNLOAD_FORCE_BUILD` — non-empty forces the source build (default name for `--force-build-var`). - *(since 1.7.0)* `GITHUB_SERVER_URL` — the canonical GitHub host for the default (verified) source; defaults to `https://github.com`. Set by GitHub Actions automatically; also lets GitHub Enterprise point the verified download at its own instance. Distinct from `DOWNLOAD_HOST`: a `GITHUB_SERVER_URL` source is still integrity-checked, a `DOWNLOAD_HOST` mirror is not. ### Recognized npm parameters (cross-platform builds) ```bash npm install re2 --platform=linux --platform-arch=x64 --platform-abi=108 ``` - `--platform=XXX` — `darwin`, `win32`, `linux`, `linux-musl`, ... Default: `process.platform` (with musl auto-detection). - `--platform-arch=XXX` — CPU architecture. Default: `process.arch`. - `--platform-abi=XXX` — Node ABI version. Default: `process.versions.modules`. With any override set, the `verify-build` step is skipped (the integrity check, below, still runs). ## Artifact integrity verification (since 1.7.0) `install-from-cache` can verify that a downloaded binary is exactly the one the addon's author published, closing the "downloaded code with no integrity check" gap for the default GitHub path. It is opt-in per addon, source-scoped, and adds no dependency (`node:crypto`). The addon pins a **hash bag** in its own `package.json` — an `artifactHashes` object mapping each `${platform}-${arch}-${abiSlot}` slot to `sha256:` of the **decompressed** `.node`: ```json { "artifactHashes": { "linux-x64-137": "sha256:9e68bb76…", "darwin-arm64-137": "sha256:fe4fe40a…" } } ``` Because it rides the addon's immutable, npm-published `package.json`, an attacker who swaps a GitHub release asset after publish cannot also rewrite the expected hash. Generate and maintain the bag with `hash-github-cache` (below). Behavior at install time: - **Canonical source + bag present + hash matches** → the artifact is written. - **Canonical source + bag present + hash mismatches, or the bag has no entry for this slot** → the artifact is rejected and the install falls through to `npm run rebuild` (strict: an uncovered slot is treated as a failure, since a complete bag means "anything unlisted should not exist"). - **A `--host` / `DOWNLOAD_HOST` mirror** → never checked. A curated mirror legitimately serves the deployer's own build, whose bytes need not match the upstream bag; the mirror is the deployer's trust root. - **No `artifactHashes` in `package.json`** → nothing to check; installs exactly as before (non-breaking). The check compares the **decompressed** bytes (what actually runs), not the compressed wire bytes, so a mirror recompressing the same binary does not spuriously fail. "Canonical source" means the default host — `GITHUB_SERVER_URL` or `https://github.com` — with no `--host`/`--host-var`/`DOWNLOAD_HOST` override. ## save-to-github-cache Runs in GitHub Actions on a tag build (or manually with a personal token). Reads `GITHUB_REPOSITORY` and `GITHUB_REF` to identify the release, authenticates with `GITHUB_TOKEN` (or `PERSONAL_TOKEN` when `GITHUB_TOKEN` is absent), resolves the release's upload URL via the GitHub REST API (`GITHUB_API_URL` overridable), compresses the artifact, and uploads each requested format in parallel. Appends `CREATED_ASSET_NAME=` to `GITHUB_ENV` for downstream steps. Exits non-zero on failure (annotated as `::error::` for Actions logs). ### Command-line parameters - `--artifact path` — the file to upload. Required. - `--prefix prefix` / `--suffix suffix` — artifact name decoration; must match the install side. - `--format list` — comma-separated compression formats to upload: `br` (brotli, max quality), `gz` (gzip, best compression), `none` (uncompressed). Default: `br`. - `--napi level` / `--napi-var ENVVAR` — same N-API slot convention as the install side. Default env-var name: `DOWNLOAD_NAPI`. ## hash-github-cache (since 1.7.0) Generates or checks the `artifactHashes` integrity bag (see "Artifact integrity verification" above) in an addon's `package.json`. Run at release time, once all binaries exist for the version being published — typically from a `prepublishOnly` hook, so a plain `npm publish` stamps a fresh bag into the packed tarball. Zero dependencies (`node:crypto`). For each artifact it recovers the slot from the file name (`${prefix}${slot}${suffix}` minus any `.br`/`.gz`), decompresses, and records `sha256:` of the resulting `.node`. One entry per slot; all compression formats of a slot decode to the same bytes. ### Command-line parameters - `--write` — compute the bag and write/refresh `artifactHashes` in `package.json`. - `--check` — compute the bag and compare it to `package.json`; exit non-zero with a per-slot diff (`missing:` / `mismatch:` / `stale:`) if it differs. Exactly one of `--write` / `--check` is required. - `--from-release [tag]` — hash the assets attached to the GitHub release (default tag: the `package.json` `version`). This is the default source; the repo is read from `package.json` `github` / `repository.url` or `GITHUB_REPOSITORY`. Honors `GITHUB_API_URL`, and `GITHUB_TOKEN` / `PERSONAL_TOKEN` for private repos (auth is dropped on the redirect to the asset CDN). - `--from dir` — hash slot-named artifacts in a local directory instead of the release. - `--prefix prefix` / `--suffix suffix` — artifact name decoration; must match the install / save sides. - `--package path` — the `package.json` to read/stamp. Default: `./package.json`. ### Recommended release wiring ```json { "scripts": { "prepublishOnly": "hash-github-cache --write" } } ``` A plain `npm publish` then hashes the release, stamps `artifactHashes` into `package.json`, and packs the updated file — the write-on-every-publish is itself the guard, and the publish aborts if the release is incomplete. `--check` doubles as a post-publish tamper monitor: `hash-github-cache --check --from-release ` re-hashes the live release and fails if an asset changed after publish. Commit the stamped `package.json` after the release. ## Asset URL format ``` ${host}/${owner}/${repo}/releases/download/${version}/${prefix}${platform}-${arch}-${abiSlot}${suffix}${compression} ``` - `host` — `https://github.com` by default (or `GITHUB_SERVER_URL`, since 1.7.0); mirror or local path via `--host` / `DOWNLOAD_HOST`. - `owner` / `repo` — parsed from the consumer's `package.json` `github` field or `repository.url`. - `version` — the consumer's `package.json` version (equals the release tag). - `platform` — `process.platform`, with musl Linux reported as `linux-musl`. - `arch` — `process.arch`. - `abiSlot` — `process.versions.modules` (legacy Node ABI), or `napi-v${level}` when an N-API level is declared. - `compression` — `.br`, `.gz`, or empty. Example: `https://github.com/uhop/node-re2/releases/download/1.15.2/linux-x64-83.br`. With `--skip-path` the `/${owner}/${repo}/releases/download` segment is dropped; with `--skip-ver` the `/${version}` segment is dropped — both exist to simplify mirror layouts. ## npm 12 and install scripts (July 2026) npm 12 stops running `preinstall` / `install` / `postinstall` scripts of dependencies by default (opt-in warnings since npm 11.16, February–May 2026). `install-from-cache` runs as the consuming addon's `install` script, so under npm 12 defaults a plain `npm install ` runs neither the prebuilt download nor the `node-gyp` fallback — the addon ends up without a binary. What end users must do (once per addon): ```bash npm install # npm reports the addon's scripts were not run npm approve-scripts # writes a version-pinned allowScripts entry to package.json npm rebuild # runs the addon's install script ``` Or pre-approve before installing: add `"allowScripts": {"": true}` to the consuming project's `package.json`. Approvals are version-pinned by default (`@1.2.3`); `npm approve-scripts --no-allow-scripts-pin ` approves all versions. `npm approve-scripts --allow-scripts-pending` lists packages awaiting review. What addon authors should do: document the approval step in the addon's install instructions; consider mentioning it in a `postinstall`-adjacent README section. Nothing in this package can bypass the gate — it is a consumer-side security decision. ## Security model - Artifacts are downloaded over HTTPS from GitHub Releases — public, writable only by the addon's maintainers. No separate binary CDN. - *(since 1.7.0)* **Integrity verification.** When the addon pins an `artifactHashes` bag, a downloaded binary from the canonical GitHub source must match the pinned SHA-256 before it is written; a mismatch rebuilds from source. The bag lives in the addon's immutable, npm-published `package.json` — the one channel an attacker who can swap a mutable release asset cannot also rewrite — so it defends against a post-publish asset swap without any signing key, extra network call, or dependency. Opt-in per addon and source-scoped (mirrors excluded). See "Artifact integrity verification" above. - The downloader never executes downloaded content; it writes a file, and the consumer's own `verify-build` / `require()` path decides whether it works. - A failed verification (integrity or `verify-build`) discards the download and rebuilds from source. `--force-build` / `DOWNLOAD_FORCE_BUILD` skips the download entirely, collapsing trust to npm plus the local toolchain. - Corporate environments can mirror artifacts (`DOWNLOAD_HOST` + skip flags) or route through a proxy (`DOWNLOAD_AGENT`) without weakening any of the above; a mirror serves the deployer's own trust root and is intentionally not integrity-checked against the upstream bag. ## Documentation Full docs, including local-mirror recipes, proxy setup, N-API guidance, and GitHub Actions workflow examples: https://github.com/uhop/install-artifact-from-github/wiki uhop-install-artifact-from-github-443ee1f/llms.txt000066400000000000000000000117171522333415600223050ustar00rootroot00000000000000# install-artifact-from-github > A no-dependency micro helper for developers of binary Node addons. Three bins: `save-to-github-cache` uploads pre-built binary artifacts to a GitHub release from CI; `install-from-cache` downloads the right artifact at install time, optionally verifies its SHA-256 against a hash bag pinned in the addon's `package.json`, and falls back to building from sources on any failure; `hash-github-cache` generates that hash bag at release time. Zero dependencies, ESM, Node >= 18. ## Install npm install --save install-artifact-from-github ## Quick start In the addon's `package.json`: ```json { "scripts": { "save-to-github": "save-to-github-cache --artifact build/Release/ABC.node", "install": "install-from-cache --artifact build/Release/ABC.node", "verify-build": "node scripts/verify-build.js", "rebuild": "node-gyp rebuild" } } ``` `save-to-github` runs in GitHub Actions on a tag build. `install` runs on the user's machine: it tries to download `${prefix}${platform}-${arch}-${abi}${suffix}` (`.br`, then `.gz`, then uncompressed) from the matching GitHub release, verifies its SHA-256 against the `artifactHashes` bag if the addon ships one, runs `verify-build` (or `test`), and on any failure runs `rebuild`. ## install-from-cache flags - `--artifact path` — where to write the downloaded artifact (required). - `--prefix s` / `--suffix s` — artifact name decoration. - `--host url` / `--host-var ENVVAR` (default `DOWNLOAD_HOST`) — mirror; a non-HTTP value is a local path. - `--skip-path` / `--skip-path-var` (default `DOWNLOAD_SKIP_PATH`) — drop `/${owner}/${repo}/releases/download` from the URL (mirrors). - `--skip-ver` / `--skip-ver-var` (default `DOWNLOAD_SKIP_VER`) — drop `/${version}` from the URL (mirrors). - `--agent module-path` / `--agent-var ENVVAR` (default `DOWNLOAD_AGENT`) — module default-exporting an `http.Agent` (proxy support; consumer brings their own proxy package). - `--napi level` / `--napi-var ENVVAR` (default `DOWNLOAD_NAPI`) — use `napi-v${level}` instead of the Node ABI in the asset name. - `--force-build` / `--force-build-var ENVVAR` (default `DOWNLOAD_FORCE_BUILD`) — skip the download entirely and build from sources. Integrity (since 1.7.0): if the addon's `package.json` carries an `artifactHashes` map, the decompressed download's SHA-256 must equal `artifactHashes["${platform}-${arch}-${abiSlot}"]` before it is written; a mismatch — or a slot the bag doesn't cover — falls through to the source build. Checked only for the canonical `github.com` source (override with `GITHUB_SERVER_URL`); a `--host` / `DOWNLOAD_HOST` mirror is the deployer's own trust root and is never checked. Zero new dependencies (`node:crypto`). Env short-circuits: `DEVELOPMENT_SKIP_GETTING_ASSET` / a `.development` file / `DOWNLOAD_FORCE_BUILD` (or `--force-build`) force a source build; `DEVELOPMENT_SHOW_VERIFICATION_RESULTS` shows verification output. Cross-build overrides: `npm install --platform=linux --platform-arch=x64 --platform-abi=108`. ## save-to-github-cache flags - `--artifact path` — the file to upload (required). - `--prefix s` / `--suffix s` — artifact name decoration (must match the install side). - `--format list` — comma-separated `br`, `gz`, `none`; default `br`. - `--napi level` / `--napi-var ENVVAR` — same N-API slot as the install side. Requires GitHub Actions env (`GITHUB_REPOSITORY`, `GITHUB_REF`, `GITHUB_TOKEN`) or `PERSONAL_TOKEN` for manual runs. ## hash-github-cache flags (since 1.7.0) Generates or checks the `artifactHashes` bag in a `package.json`, run at release time (e.g. from a `prepublishOnly` hook). The bag maps each `${platform}-${arch}-${abiSlot}` slot to `sha256:` of the decompressed `.node`. - `--write` / `--check` — write the bag into `package.json`, or exit non-zero with a slot-level diff if it is stale/missing/wrong (exactly one required). - `--from-release [tag]` — hash the assets of the GitHub release (default tag: the `package.json` version); the default source. - `--from dir` — hash slot-named artifacts in a local directory instead. - `--prefix s` / `--suffix s` — artifact name decoration (must match the install / save sides). - `--package path` — the `package.json` to read/stamp (default `./package.json`). Recommended wiring: `"prepublishOnly": "hash-github-cache --write"` so a plain `npm publish` stamps a fresh bag into the packed tarball. `--check` also serves as a post-publish tamper monitor (re-hash the live release against the published bag). ## npm 12 note (July 2026) npm 12 disables dependency lifecycle scripts by default, and `install-from-cache` runs as the consumer's `install` script. End users must approve the consuming addon once: `npm approve-scripts ` (writes a version-pinned `allowScripts` entry to their package.json; available since npm 11.16). Without approval neither the prebuilt download nor the `node-gyp` fallback runs. ## Documentation Full docs: https://github.com/uhop/install-artifact-from-github/wiki uhop-install-artifact-from-github-443ee1f/package-lock.json000066400000000000000000000060701522333415600240050ustar00rootroot00000000000000{ "name": "install-artifact-from-github", "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "install-artifact-from-github", "version": "1.7.0", "license": "BSD-3-Clause", "bin": { "hash-github-cache": "bin/hash-github-cache.js", "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" }, "devDependencies": { "@types/node": "^26.1.0", "prettier": "^3.9.4", "tape-six": "^1.14.0", "typescript": "^6.0.3" }, "engines": { "node": ">=18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/uhop" } }, "node_modules/@types/node": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" } }, "node_modules/prettier": { "version": "3.9.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/tape-six": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/tape-six/-/tape-six-1.14.0.tgz", "integrity": "sha512-XohHlJp6qJGdi+/9fsfTJjXHIqTOfGp05nBDV/8odfcU4Ul/3VoFMOlo3YN6XZQfzByx5XxmzTTWhTipe7QIDw==", "dev": true, "license": "BSD-3-Clause", "bin": { "tape6": "bin/tape6.js", "tape6-bun": "bin/tape6-bun.js", "tape6-deno": "bin/tape6-deno.js", "tape6-node": "bin/tape6-node.js", "tape6-runner": "bin/tape6-runner.js", "tape6-seq": "bin/tape6-seq.js", "tape6-server": "bin/tape6-server.js" }, "funding": { "url": "https://github.com/sponsors/uhop" } }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=14.17" } }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" } } } uhop-install-artifact-from-github-443ee1f/package.json000066400000000000000000000031541522333415600230570ustar00rootroot00000000000000{ "name": "install-artifact-from-github", "version": "1.7.0", "type": "module", "engines": { "node": ">=18" }, "description": "Create binary artifacts hosted by github and install them without compiling.", "homepage": "https://github.com/uhop/install-artifact-from-github", "bugs": "https://github.com/uhop/install-artifact-from-github/issues", "github": "https://github.com/uhop/install-artifact-from-github", "repository": { "type": "git", "url": "git+https://github.com/uhop/install-artifact-from-github.git" }, "files": [ "/bin", "LICENSE", "README.md", "llms.txt", "llms-full.txt" ], "bin": { "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js", "hash-github-cache": "bin/hash-github-cache.js" }, "keywords": [ "helper", "node addons" ], "author": "Eugene Lazutkin (https://lazutkin.com/)", "license": "BSD-3-Clause", "funding": { "type": "github", "url": "https://github.com/sponsors/uhop" }, "scripts": { "test": "tape6 --flags FO", "test:seq": "tape6-seq --flags FO", "dump-env": "node scripts/dump-env.js", "lint": "prettier --check .", "lint:fix": "prettier --write .", "js-check": "tsc --project tsconfig.check.json", "save-to-github": "node bin/save-to-github-cache --artifact package.json --format br,gz,none" }, "tape6": { "tests": [ "/tests/test-*.js" ] }, "devDependencies": { "prettier": "^3.9.4", "tape-six": "^1.14.0", "typescript": "^6.0.3", "@types/node": "^26.1.0" } } uhop-install-artifact-from-github-443ee1f/scripts/000077500000000000000000000000001522333415600222555ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/scripts/dump-env.js000066400000000000000000000012351522333415600243470ustar00rootroot00000000000000const prefix = /^npm/i; const main = () => { const npmVars = [], others = []; for (const name in process.env) { if (prefix.test(name)) { npmVars.push(name); } else { others.push(name); } } npmVars.sort(); others.sort(); console.log('# NPM environment variables'); console.log('| Name | Value |'); console.log('|------|-------|'); npmVars.forEach(name => console.log('|', name, '|', process.env[name], '|')); console.log('\n# Other environment variables'); console.log('| Name | Value |'); console.log('|------|-------|'); others.forEach(name => console.log('|', name, '|', process.env[name], '|')); }; main(); uhop-install-artifact-from-github-443ee1f/scripts/example-save.sh000066400000000000000000000015261522333415600252040ustar00rootroot00000000000000#!/bin/bash # This is an example script to run save-to-github-cache.js locally, not in the context of Github Actions. # Copy and modify for your specific project. # IMPORTANT! Do not save the script with secrets in it in a publicly-available repository! if [ -z "$1" ]; then echo "Use: bash this-script.sh VERSION" echo "Example: bash save-local.sh 1.2.3-test" exit 1 fi # Set the repository: export GITHUB_REPOSITORY=uhop/install-artifact-from-github # Set the release as a tag: export GITHUB_REF=refs/tags/$1 # Use a personal token: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token # Set it to read/write code of the repository, all we need is to write an artifact into existing release. export PERSONAL_TOKEN=github_... # Run build and save commands: npm run save-to-github uhop-install-artifact-from-github-443ee1f/tests/000077500000000000000000000000001522333415600217305ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/tests/fixtures/000077500000000000000000000000001522333415600236015ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/tests/fixtures/recording-agent.js000066400000000000000000000017121522333415600272100ustar00rootroot00000000000000// Fake DOWNLOAD_AGENT module for the test harness. Subclasses http.Agent // (the same shape proxy-agent / https-proxy-agent expose), records every // addRequest call to a sidecar JSON file so the test can assert on it, // and otherwise behaves like a default agent for HTTP and HTTPS. import http from 'node:http'; import https from 'node:https'; import fs from 'node:fs'; const recordPath = process.env.RECORD_PATH; class RecordingAgent extends http.Agent { addRequest(req, options) { if (recordPath) { const entry = { host: options.host || options.hostname, port: options.port, path: options.path, method: options.method, protocol: options.protocol }; fs.appendFileSync(recordPath, JSON.stringify(entry) + '\n'); } const delegate = options.protocol === 'https:' ? https.globalAgent : http.globalAgent; return delegate.addRequest(req, options); } } export default new RecordingAgent(); uhop-install-artifact-from-github-443ee1f/tests/helpers/000077500000000000000000000000001522333415600233725ustar00rootroot00000000000000uhop-install-artifact-from-github-443ee1f/tests/helpers/mock-server.js000066400000000000000000000055301522333415600261700ustar00rootroot00000000000000import http from 'node:http'; // Local HTTP server impersonating the bits of GitHub the two CLIs touch. // install-from-cache hits asset URLs: // GET /:owner/:repo/releases/download/:ver/:asset(.br|.gz|none) // save-to-github-cache hits the API + an upload URL: // GET /repos/:owner/:repo/releases/tags/:tag -> {upload_url} // POST /_uploads/?name=...&label=... -> 201, body recorded // // Use setAsset(path, body) to pre-stage a download fixture. Anything // else returns 404. Posted uploads land in `recorded` for assertions. export const startMockServer = async (opts = {}) => { const assets = new Map(); // path -> {body: Buffer, contentType?: string} const recorded = []; // {name, label, body, headers} const releaseHandler = opts.releaseHandler || ((req, res, ctx) => { const uploadUrl = `http://${req.headers.host}/_uploads/{?name,label}`; res.writeHead(200, {'content-type': 'application/json'}); res.end(JSON.stringify({upload_url: uploadUrl, tag_name: ctx.tag})); }); const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const pathname = url.pathname; // GET /repos/:owner/:repo/releases/tags/:tag let m = req.method === 'GET' && pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/releases\/tags\/(.+)$/); if (m) { const [, owner, repo, tag] = m; releaseHandler(req, res, {owner, repo, tag}); return; } // POST /_uploads/...?name=...&label=... if (req.method === 'POST' && pathname.startsWith('/_uploads')) { const chunks = []; req.on('data', c => chunks.push(c)); req.on('end', () => { recorded.push({ name: url.searchParams.get('name'), label: url.searchParams.get('label'), body: Buffer.concat(chunks), headers: {...req.headers} }); res.writeHead(201, {'content-type': 'application/json'}); res.end(JSON.stringify({id: recorded.length})); }); return; } // GET asset if (req.method === 'GET') { const asset = assets.get(pathname); if (asset) { res.writeHead(200, {'content-type': asset.contentType || 'application/octet-stream', 'content-length': asset.body.length}); res.end(asset.body); return; } res.writeHead(404); res.end(); return; } res.writeHead(404); res.end(); }); await new Promise(r => server.listen(0, '127.0.0.1', r)); const {port} = /** @type {import('node:net').AddressInfo} */ (server.address()); return { port, url: `http://127.0.0.1:${port}`, recorded, setAsset(pathname, body, contentType) { assets.set(pathname, {body, contentType}); }, clearAssets() { assets.clear(); recorded.length = 0; }, close: () => new Promise(r => server.close(() => r())) }; }; uhop-install-artifact-from-github-443ee1f/tests/helpers/run-bin.js000066400000000000000000000033411522333415600253030ustar00rootroot00000000000000import {spawn} from 'node:child_process'; import {promises as fsp} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..', '..'); // Run a bin script with a controlled env and cwd. Resolves once the process exits. // Always isolates env (no inherited npm_*); caller passes exactly what the test needs. export const runBin = async (binName, {args = [], env = {}, cwd}) => { const bin = path.join(REPO_ROOT, 'bin', binName); return new Promise((resolve, reject) => { const proc = spawn(process.execPath, [bin, ...args], { cwd: cwd || REPO_ROOT, env: {PATH: process.env.PATH, HOME: process.env.HOME, ...env} }); const out = []; const err = []; proc.stdout.on('data', d => out.push(d)); proc.stderr.on('data', d => err.push(d)); proc.on('error', reject); proc.on('exit', (code, signal) => { resolve({ code, signal, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }); }); }); }; // Make a sandbox directory with a stub package.json that has a no-op rebuild // script (so the install-from-cache "Building locally" fallback doesn't blow // up the test runner with an `npm error Missing script: "rebuild"`). export const makeSandbox = async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'iafg-test-')); await fsp.writeFile(path.join(dir, 'package.json'), JSON.stringify({name: 'fake', version: '1.0.0', scripts: {rebuild: 'node -e ""'}}, null, 2)); return { dir, cleanup: () => fsp.rm(dir, {recursive: true, force: true}) }; }; uhop-install-artifact-from-github-443ee1f/tests/test-hash-github-cache.js000066400000000000000000000136601522333415600265150ustar00rootroot00000000000000import test from 'tape-six'; import {promises as fsp} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import {createHash} from 'node:crypto'; import {startMockServer} from './helpers/mock-server.js'; import {runBin} from './helpers/run-bin.js'; const brotli = promisify(zlib.brotliCompress); const gzip = promisify(zlib.gzip); const VERSION = '1.0.0'; const sha = buffer => 'sha256:' + createHash('sha256').update(buffer).digest('hex'); const PAYLOAD_A = Buffer.from('linux-x64-payload'); const PAYLOAD_B = Buffer.from('darwin-arm64-payload'); const makeGenSandbox = async () => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'iafg-gen-')); const pkgJson = path.join(dir, 'package.json'); await fsp.writeFile(pkgJson, JSON.stringify({name: 'demo', version: VERSION, github: 'owner/repo'}, null, 2) + '\n'); return {dir, pkgJson, cleanup: () => fsp.rm(dir, {recursive: true, force: true})}; }; const readPkg = async pkgJson => JSON.parse(await fsp.readFile(pkgJson, 'utf8')); test('hash-github-cache: --write from a directory stamps a sorted bag of decompressed hashes', async t => { const sandbox = await makeGenSandbox(); const arts = path.join(sandbox.dir, 'arts'); try { await fsp.mkdir(arts); await fsp.writeFile(path.join(arts, 'linux-x64-108.br'), await brotli(PAYLOAD_A)); await fsp.writeFile(path.join(arts, 'darwin-arm64-108'), PAYLOAD_B); // uncompressed await fsp.writeFile(path.join(arts, 'README.txt'), 'not an artifact'); // ignored const r = await runBin('hash-github-cache.js', {args: ['--write', '--from', arts, '--package', sandbox.pkgJson]}); t.equal(r.code, 0, `exited 0 (stderr=${r.stderr})`); const pkg = await readPkg(sandbox.pkgJson); t.deepEqual( pkg.artifactHashes, {'darwin-arm64-108': sha(PAYLOAD_B), 'linux-x64-108': sha(PAYLOAD_A)}, 'bag holds decompressed hashes for both slots (README ignored)' ); t.deepEqual(Object.keys(pkg.artifactHashes), ['darwin-arm64-108', 'linux-x64-108'], 'keys are sorted'); } finally { await sandbox.cleanup(); } }); test('hash-github-cache: --check passes when the bag matches and fails (exit 1) when it does not', async t => { const sandbox = await makeGenSandbox(); const arts = path.join(sandbox.dir, 'arts'); try { await fsp.mkdir(arts); await fsp.writeFile(path.join(arts, 'linux-x64-108.br'), await brotli(PAYLOAD_A)); await runBin('hash-github-cache.js', {args: ['--write', '--from', arts, '--package', sandbox.pkgJson]}); const ok = await runBin('hash-github-cache.js', {args: ['--check', '--from', arts, '--package', sandbox.pkgJson]}); t.equal(ok.code, 0, 'matching bag → exit 0'); const pkg = await readPkg(sandbox.pkgJson); pkg.artifactHashes['linux-x64-108'] = 'sha256:deadbeef'; await fsp.writeFile(sandbox.pkgJson, JSON.stringify(pkg, null, 2) + '\n'); const bad = await runBin('hash-github-cache.js', {args: ['--check', '--from', arts, '--package', sandbox.pkgJson]}); t.equal(bad.code, 1, 'tampered bag → exit 1'); t.ok(bad.stderr.includes('mismatch: linux-x64-108'), 'names the mismatching slot'); } finally { await sandbox.cleanup(); } }); test('hash-github-cache: --check flags a slot present in the release but missing from the bag', async t => { const sandbox = await makeGenSandbox(); const arts = path.join(sandbox.dir, 'arts'); try { await fsp.mkdir(arts); await fsp.writeFile(path.join(arts, 'linux-x64-108.br'), await brotli(PAYLOAD_A)); await fsp.writeFile(path.join(arts, 'darwin-arm64-108'), PAYLOAD_B); // Bag covers only one of the two artifacts. const pkg = await readPkg(sandbox.pkgJson); pkg.artifactHashes = {'linux-x64-108': sha(PAYLOAD_A)}; await fsp.writeFile(sandbox.pkgJson, JSON.stringify(pkg, null, 2) + '\n'); const r = await runBin('hash-github-cache.js', {args: ['--check', '--from', arts, '--package', sandbox.pkgJson]}); t.equal(r.code, 1, 'incomplete bag → exit 1'); t.ok(r.stderr.includes('missing: darwin-arm64-108'), 'reports the uncovered slot'); } finally { await sandbox.cleanup(); } }); test('hash-github-cache: --from-release fetches assets and stamps the bag', async t => { const assets = [ {name: 'linux-x64-108.br', body: await brotli(PAYLOAD_A)}, {name: 'linux-x64-108.gz', body: await gzip(PAYLOAD_A)}, // same slot, lower rank → br wins {name: 'darwin-arm64-108', body: PAYLOAD_B} ]; const server = await startMockServer({ releaseHandler: (req, res) => { const base = `http://${req.headers.host}`; res.writeHead(200, {'content-type': 'application/json'}); res.end(JSON.stringify({tag_name: VERSION, assets: assets.map(a => ({name: a.name, browser_download_url: `${base}/dl/${a.name}`}))})); } }); const sandbox = await makeGenSandbox(); try { for (const a of assets) server.setAsset(`/dl/${a.name}`, a.body); const r = await runBin('hash-github-cache.js', { args: ['--write', '--from-release', '--package', sandbox.pkgJson], env: {GITHUB_API_URL: server.url} }); t.equal(r.code, 0, `exited 0 (stderr=${r.stderr})`); const pkg = await readPkg(sandbox.pkgJson); t.deepEqual(pkg.artifactHashes, {'darwin-arm64-108': sha(PAYLOAD_B), 'linux-x64-108': sha(PAYLOAD_A)}, 'release assets hashed by slot, one entry per slot'); } finally { await server.close(); await sandbox.cleanup(); } }); test('hash-github-cache: requires exactly one of --write / --check', async t => { const sandbox = await makeGenSandbox(); try { const neither = await runBin('hash-github-cache.js', {args: ['--from', sandbox.dir, '--package', sandbox.pkgJson]}); t.equal(neither.code, 2, 'neither → exit 2'); const both = await runBin('hash-github-cache.js', {args: ['--write', '--check', '--from', sandbox.dir, '--package', sandbox.pkgJson]}); t.equal(both.code, 2, 'both → exit 2'); } finally { await sandbox.cleanup(); } }); uhop-install-artifact-from-github-443ee1f/tests/test-install.js000066400000000000000000000337511522333415600247220ustar00rootroot00000000000000import test from 'tape-six'; import {promises as fsp} from 'node:fs'; import path from 'node:path'; import url from 'node:url'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import {startMockServer} from './helpers/mock-server.js'; import {runBin, makeSandbox} from './helpers/run-bin.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const RECORDING_AGENT = path.join(__dirname, 'fixtures', 'recording-agent.js'); const brotli = promisify(zlib.brotliCompress); const gzip = promisify(zlib.gzip); const PLATFORM = 'linux'; const ARCH = 'x64'; const ABI = '108'; const PREFIX = 'testpkg-'; const SUFFIX = '.bin'; const ASSET = `${PREFIX}${PLATFORM}-${ARCH}-${ABI}${SUFFIX}`; const VERSION = '1.0.0'; const ASSET_PATH = `/owner/repo/releases/download/${VERSION}/${ASSET}`; // Common env that pins platform (and thereby skips the build-verification step // inside install-from-cache) and points the bin at our mock server. const installEnv = host => ({ npm_config_platform: PLATFORM, npm_config_platform_arch: ARCH, npm_config_platform_abi: ABI, npm_package_github: 'owner/repo', npm_package_version: VERSION, DOWNLOAD_HOST: host }); const runInstall = async (server, sandbox, extraEnv = {}) => { return runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX], env: {...installEnv(server.url), ...extraEnv} }); }; test('install-from-cache: brotli artifact wins when available', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const payload = Buffer.from('hello-from-brotli'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(server, sandbox); t.equal(r.code, 0, `bin exited 0 (stdout=${r.stdout})`); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'artifact written matches the original payload'); t.ok(r.stdout.includes('Done.'), 'reports Done.'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: falls back to gzip when brotli is missing', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const payload = Buffer.from('hello-from-gzip'); server.setAsset(ASSET_PATH + '.gz', await gzip(payload)); const r = await runInstall(server, sandbox); t.equal(r.code, 0, 'bin exited 0'); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'gzip-decoded payload matches'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: falls back to uncompressed when br + gz are missing', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const payload = Buffer.from('hello-uncompressed'); server.setAsset(ASSET_PATH, payload); const r = await runInstall(server, sandbox); t.equal(r.code, 0, 'bin exited 0'); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'uncompressed payload matches'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: format precedence — br beats gz beats none', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('B'))); server.setAsset(ASSET_PATH + '.gz', await gzip(Buffer.from('G'))); server.setAsset(ASSET_PATH, Buffer.from('U')); const r = await runInstall(server, sandbox); t.equal(r.code, 0, 'bin exited 0'); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.equal(written.toString(), 'B', 'brotli copy wins'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: no asset available → falls through to npm run rebuild', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { // No fixtures registered → server returns 404 for every variant. const r = await runInstall(server, sandbox); t.equal(r.code, 0, `rebuild stub exited 0 (stdout=${r.stdout})`); t.ok(r.stdout.includes('Building locally'), 'announced fallback'); let exists = true; try { await fsp.access(path.join(sandbox.dir, 'out/artifact.bin')); } catch { exists = false; } t.notOk(exists, 'no artifact written when all formats 404'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --artifact missing → no download attempted, falls back to rebuild', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('should-not-be-fetched'))); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: [], // no --artifact env: installEnv(server.url) }); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('No artifact path was specified'), 'logs the missing-flag reason'); t.equal(server.recorded.length, 0, 'no upload calls (sanity)'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: DEVELOPMENT_SKIP_GETTING_ASSET short-circuits the download', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('would-have-been-served'))); const r = await runInstall(server, sandbox, {DEVELOPMENT_SKIP_GETTING_ASSET: '1'}); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('Development flag was detected'), 'logs the dev short-circuit'); let exists = true; try { await fsp.access(path.join(sandbox.dir, 'out/artifact.bin')); } catch { exists = false; } t.notOk(exists, 'no artifact written in dev mode'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: DOWNLOAD_AGENT loads a custom agent and routes requests through it', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); const recordPath = path.join(sandbox.dir, 'agent-calls.jsonl'); try { const payload = Buffer.from('routed-via-custom-agent'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(server, sandbox, { DOWNLOAD_AGENT: RECORDING_AGENT, RECORD_PATH: recordPath }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'artifact still downloaded correctly'); const log = await fsp.readFile(recordPath, 'utf8'); const entries = log.trim().split('\n').map(JSON.parse); t.ok(entries.length >= 1, `recording agent saw ≥1 request (got ${entries.length})`); t.ok( entries.some(e => typeof e.path === 'string' && e.path.endsWith(ASSET + '.br')), 'recording agent saw the .br asset request' ); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --agent flag overrides the env var', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); const recordPath = path.join(sandbox.dir, 'agent-calls.jsonl'); try { const payload = Buffer.from('routed-via-flag'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX, '--agent', RECORDING_AGENT], env: {...installEnv(server.url), RECORD_PATH: recordPath} }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'artifact downloaded via flag-supplied agent'); const entries = (await fsp.readFile(recordPath, 'utf8')).trim().split('\n').map(JSON.parse); t.ok(entries.length >= 1, 'recording agent saw the request'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --agent-var picks the env var name (project-namespacing)', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); const recordPath = path.join(sandbox.dir, 'agent-calls.jsonl'); try { const payload = Buffer.from('routed-via-custom-envvar'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX, '--agent-var', 'MYPROJECT_AGENT'], env: { ...installEnv(server.url), MYPROJECT_AGENT: RECORDING_AGENT, RECORD_PATH: recordPath, // Default DOWNLOAD_AGENT must NOT be consulted when --agent-var is set. DOWNLOAD_AGENT: '/nonexistent/should-not-be-loaded.js' } }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); t.notOk(r.stderr.includes('Failed to load'), 'no fallback warning — DOWNLOAD_AGENT was correctly ignored'); const entries = (await fsp.readFile(recordPath, 'utf8')).trim().split('\n').map(JSON.parse); t.ok(entries.length >= 1, 'project-specific env var routed the request'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: DOWNLOAD_AGENT pointing at a bogus path degrades gracefully', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const payload = Buffer.from('still-works-without-agent'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(server, sandbox, { DOWNLOAD_AGENT: '/nonexistent/path/to/agent.js' }); t.equal(r.code, 0, 'bin still exited 0'); t.ok(r.stderr.includes('Failed to load download agent'), 'logged the loader failure to stderr'); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'install still succeeded with default agent'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --napi switches the URL slot from ABI to napi-v', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const napiAsset = `${PREFIX}${PLATFORM}-${ARCH}-napi-v8${SUFFIX}`; const napiPath = `/owner/repo/releases/download/${VERSION}/${napiAsset}`; const payload = Buffer.from('napi-routed-binary'); server.setAsset(napiPath + '.br', await brotli(payload)); // ABI-named asset deliberately absent — proving the bin asks for the N-API path. const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX, '--napi', '8'], env: installEnv(server.url) }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'N-API artifact downloaded'); t.ok(r.stdout.includes('napi-v8'), 'log line shows the N-API URL was attempted'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --napi-var indirection reads project-specific env var', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const napiAsset = `${PREFIX}${PLATFORM}-${ARCH}-napi-v9${SUFFIX}`; const napiPath = `/owner/repo/releases/download/${VERSION}/${napiAsset}`; const payload = Buffer.from('napi-via-custom-envvar'); server.setAsset(napiPath + '.br', await brotli(payload)); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX, '--napi-var', 'MYPKG_NAPI'], env: { ...installEnv(server.url), MYPKG_NAPI: '9', // Default DOWNLOAD_NAPI must be ignored when --napi-var is set. DOWNLOAD_NAPI: '999' } }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'project-specific env var routed the request'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: --napi missing → ABI mode unchanged', async t => { // Regression: existing behavior must be byte-identical when no --napi is passed. const server = await startMockServer(); const sandbox = await makeSandbox(); try { const payload = Buffer.from('still-uses-abi-slot'); server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(server, sandbox); t.equal(r.code, 0, 'bin exited 0'); const written = await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')); t.deepEqual(written, payload, 'ABI-named asset was downloaded as before'); } finally { await server.close(); await sandbox.cleanup(); } }); test('install-from-cache: missing repo info → no download, falls back', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { const env = installEnv(server.url); delete env.npm_package_github; const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--prefix', PREFIX, '--suffix', SUFFIX], env }); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('No github repository was identified'), 'logs the missing-repo reason'); } finally { await server.close(); await sandbox.cleanup(); } }); uhop-install-artifact-from-github-443ee1f/tests/test-save.js000066400000000000000000000132321522333415600242020ustar00rootroot00000000000000import test from 'tape-six'; import {promises as fsp} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import {startMockServer} from './helpers/mock-server.js'; import {runBin} from './helpers/run-bin.js'; const brotliDecompress = promisify(zlib.brotliDecompress); const gunzip = promisify(zlib.gunzip); const TAG = '1.2.3'; const PREFIX = 'testpkg-'; const SUFFIX = '.bin'; const writeArtifact = async body => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'iafg-save-')); const file = path.join(dir, 'artifact.bin'); await fsp.writeFile(file, body); return {dir, file, cleanup: () => fsp.rm(dir, {recursive: true, force: true})}; }; const saveEnv = host => ({ GITHUB_API_URL: host, GITHUB_REPOSITORY: 'owner/repo', GITHUB_REF: `refs/tags/${TAG}`, GITHUB_TOKEN: 'fake-token-do-not-use' }); test('save-to-github-cache: uploads brotli + gzip + uncompressed for --format br,gz,none', async t => { const server = await startMockServer(); const payload = Buffer.from('hello-save-bin'); const fixture = await writeArtifact(payload); try { const r = await runBin('save-to-github-cache.js', { args: ['--artifact', fixture.file, '--prefix', PREFIX, '--suffix', SUFFIX, '--format', 'br,gz,none'], env: saveEnv(server.url) }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); t.equal(server.recorded.length, 3, 'three uploads recorded'); const byExt = Object.fromEntries(server.recorded.map(u => [path.extname(u.name), u])); t.ok(byExt['.br'], 'brotli upload present'); t.ok(byExt['.gz'], 'gzip upload present'); t.ok(byExt[''] || byExt[SUFFIX], 'uncompressed upload present'); t.deepEqual(await brotliDecompress(byExt['.br'].body), payload, 'brotli payload round-trips'); t.deepEqual(await gunzip(byExt['.gz'].body), payload, 'gzip payload round-trips'); const uncompressed = byExt[SUFFIX] || byExt['']; t.deepEqual(uncompressed.body, payload, 'uncompressed body matches input'); } finally { await server.close(); await fixture.cleanup(); } }); test('save-to-github-cache: --format br only uploads the brotli variant', async t => { const server = await startMockServer(); const payload = Buffer.from('only-brotli'); const fixture = await writeArtifact(payload); try { const r = await runBin('save-to-github-cache.js', { args: ['--artifact', fixture.file, '--prefix', PREFIX, '--suffix', SUFFIX, '--format', 'br'], env: saveEnv(server.url) }); t.equal(r.code, 0, 'bin exited 0'); t.equal(server.recorded.length, 1, 'exactly one upload'); t.ok(server.recorded[0].name.endsWith('.br'), 'it is the brotli one'); t.deepEqual(await brotliDecompress(server.recorded[0].body), payload, 'payload round-trips'); } finally { await server.close(); await fixture.cleanup(); } }); test('save-to-github-cache: filename encodes platform + arch + abi', async t => { const server = await startMockServer(); const payload = Buffer.from('platform-encoding-check'); const fixture = await writeArtifact(payload); try { const r = await runBin('save-to-github-cache.js', { args: ['--artifact', fixture.file, '--prefix', PREFIX, '--suffix', SUFFIX, '--format', 'none'], env: saveEnv(server.url) }); t.equal(r.code, 0, 'bin exited 0'); t.equal(server.recorded.length, 1, 'one upload (uncompressed)'); const name = server.recorded[0].name; t.ok(name.startsWith(PREFIX), 'name starts with prefix'); t.ok(name.endsWith(SUFFIX), 'name ends with suffix'); // Middle slot is platform-arch-abi; we don't pin to a specific one because // the test runs on whatever the host happens to be. Sanity-check that all // three slots are present (two hyphens between prefix and suffix). const middle = name.slice(PREFIX.length, name.length - SUFFIX.length); t.ok(middle.split('-').length >= 3, `platform-arch-abi triple present (${middle})`); } finally { await server.close(); await fixture.cleanup(); } }); test('save-to-github-cache: --napi puts napi-v in the upload filename', async t => { const server = await startMockServer(); const payload = Buffer.from('napi-upload'); const fixture = await writeArtifact(payload); try { const r = await runBin('save-to-github-cache.js', { args: ['--artifact', fixture.file, '--prefix', PREFIX, '--suffix', SUFFIX, '--format', 'br', '--napi', '8'], env: saveEnv(server.url) }); t.equal(r.code, 0, `bin exited 0 (stderr=${r.stderr})`); t.equal(server.recorded.length, 1, 'one upload'); const name = server.recorded[0].name; t.ok(name.includes('-napi-v8'), `filename contains napi-v8 slot (got ${name})`); t.notOk(/-\d+\.bin\.br$/.test(name), `filename does NOT contain a numeric ABI slot (got ${name})`); } finally { await server.close(); await fixture.cleanup(); } }); test('save-to-github-cache: API 404 surfaces an error and exits non-zero', async t => { const server = await startMockServer({ releaseHandler(_req, res) { res.writeHead(404); res.end('not found'); } }); const payload = Buffer.from('no-release-yet'); const fixture = await writeArtifact(payload); try { const r = await runBin('save-to-github-cache.js', { args: ['--artifact', fixture.file, '--prefix', PREFIX, '--suffix', SUFFIX, '--format', 'br'], env: saveEnv(server.url) }); t.notEqual(r.code, 0, `bin exited non-zero (got ${r.code})`); t.ok(/Status 404/.test(r.stdout + r.stderr), 'reports the 404'); t.equal(server.recorded.length, 0, 'no uploads on lookup failure'); } finally { await server.close(); await fixture.cleanup(); } }); uhop-install-artifact-from-github-443ee1f/tests/test-verify.js000066400000000000000000000211571522333415600245550ustar00rootroot00000000000000import test from 'tape-six'; import {promises as fsp} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; import {promisify} from 'node:util'; import {createHash} from 'node:crypto'; import {startMockServer} from './helpers/mock-server.js'; import {runBin, makeSandbox} from './helpers/run-bin.js'; const brotli = promisify(zlib.brotliCompress); const PLATFORM = 'linux'; const ARCH = 'x64'; const ABI = '108'; const SLOT = `${PLATFORM}-${ARCH}-${ABI}`; const VERSION = '1.0.0'; const ASSET_PATH = `/owner/repo/releases/download/${VERSION}/${SLOT}`; const sha = buffer => 'sha256:' + createHash('sha256').update(buffer).digest('hex'); // A sandbox whose package.json is read by install-from-cache (via npm_package_json), carrying the // github/version it needs and an optional integrity bag. const makeBagSandbox = async artifactHashes => { const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'iafg-verify-')); const pkg = {name: 'fake', version: VERSION, github: 'owner/repo', scripts: {rebuild: 'node -e ""'}}; if (artifactHashes) pkg.artifactHashes = artifactHashes; const pkgJson = path.join(dir, 'package.json'); await fsp.writeFile(pkgJson, JSON.stringify(pkg, null, 2)); return {dir, pkgJson, cleanup: () => fsp.rm(dir, {recursive: true, force: true})}; }; // npm_config_platform* pins the slot and short-circuits the post-write build check. // GITHUB_SERVER_URL points the *canonical* (verified) source at the mock, without tripping the // mirror bypass the way DOWNLOAD_HOST would. const verifyEnv = (sandbox, serverUrl, extra = {}) => ({ npm_config_platform: PLATFORM, npm_config_platform_arch: ARCH, npm_config_platform_abi: ABI, npm_package_json: sandbox.pkgJson, GITHUB_SERVER_URL: serverUrl, ...extra }); const runInstall = (sandbox, env) => runBin('install-from-cache.js', {cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin'], env}); const artifactExists = async sandbox => { try { await fsp.access(path.join(sandbox.dir, 'out/artifact.bin')); return true; } catch { return false; } }; test('verify: matching hash on the canonical source is written', async t => { const server = await startMockServer(); const payload = Buffer.from('the-real-binary'); const sandbox = await makeBagSandbox({[SLOT]: sha(payload)}); try { server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(sandbox, verifyEnv(sandbox, server.url)); t.equal(r.code, 0, `bin exited 0 (stdout=${r.stdout})`); t.ok(r.stdout.includes('Done.'), 'reports Done.'); t.deepEqual(await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')), payload, 'verified artifact written'); } finally { await server.close(); await sandbox.cleanup(); } }); test('verify: a hash mismatch is rejected and falls through to a source build', async t => { const server = await startMockServer(); const payload = Buffer.from('swapped-malicious-binary'); const sandbox = await makeBagSandbox({[SLOT]: sha(Buffer.from('what-the-author-published'))}); try { server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(sandbox, verifyEnv(sandbox, server.url)); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes(`Integrity check failed for ${SLOT}`), 'announces the integrity failure'); t.ok(r.stdout.includes('Building locally'), 'falls through to the source build'); t.notOk(await artifactExists(sandbox), 'the mismatching artifact is NOT written'); } finally { await server.close(); await sandbox.cleanup(); } }); test('verify: a downloaded slot the bag does not cover is rejected (strict)', async t => { const server = await startMockServer(); const payload = Buffer.from('unbagged-slot-binary'); const sandbox = await makeBagSandbox({'linux-x64-999': sha(Buffer.from('some-other-slot'))}); try { server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(sandbox, verifyEnv(sandbox, server.url)); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes(`Integrity check failed for ${SLOT}`), 'unbagged slot is treated as a failure'); t.notOk(await artifactExists(sandbox), 'nothing written for an unbagged slot'); } finally { await server.close(); await sandbox.cleanup(); } }); test('verify: a package with no bag installs unchanged (non-breaking)', async t => { const server = await startMockServer(); const payload = Buffer.from('no-bag-here'); const sandbox = await makeBagSandbox(null); try { server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const r = await runInstall(sandbox, verifyEnv(sandbox, server.url)); t.equal(r.code, 0, 'bin exited 0'); t.deepEqual(await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')), payload, 'bagless install writes the artifact'); } finally { await server.close(); await sandbox.cleanup(); } }); test('verify: a consumer mirror bypasses verification (its bytes are the deployer trust root)', async t => { const server = await startMockServer(); const payload = Buffer.from('mirror-served-bytes'); // Bag deliberately wrong for the slot; the mirror path must NOT check it. const sandbox = await makeBagSandbox({[SLOT]: sha(Buffer.from('would-mismatch'))}); try { server.setAsset(ASSET_PATH + '.br', await brotli(payload)); const env = { npm_config_platform: PLATFORM, npm_config_platform_arch: ARCH, npm_config_platform_abi: ABI, npm_package_json: sandbox.pkgJson, DOWNLOAD_HOST: server.url // mirror override → verification skipped }; const r = await runInstall(sandbox, env); t.equal(r.code, 0, `bin exited 0 (stdout=${r.stdout})`); t.notOk(r.stdout.includes('Integrity check failed'), 'no integrity check on a mirror'); t.deepEqual(await fsp.readFile(path.join(sandbox.dir, 'out/artifact.bin')), payload, 'mirror artifact written despite the wrong bag'); } finally { await server.close(); await sandbox.cleanup(); } }); const forceBuildEnv = serverUrl => ({ DOWNLOAD_HOST: serverUrl, npm_package_github: 'owner/repo', npm_package_version: VERSION, npm_config_platform: PLATFORM, npm_config_platform_arch: ARCH, npm_config_platform_abi: ABI }); test('force-build: --force-build skips the download and builds from source', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('should-not-be-fetched'))); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--force-build'], env: forceBuildEnv(server.url) }); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('Forced build from sources was requested'), 'logs the forced-build reason'); t.ok(r.stdout.includes('Building locally'), 'falls through to the source build'); let exists = true; try { await fsp.access(path.join(sandbox.dir, 'out/artifact.bin')); } catch { exists = false; } t.notOk(exists, 'no artifact fetched when forced to build'); } finally { await server.close(); await sandbox.cleanup(); } }); test('force-build: DOWNLOAD_FORCE_BUILD env has the same effect', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('should-not-be-fetched'))); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin'], env: {...forceBuildEnv(server.url), DOWNLOAD_FORCE_BUILD: '1'} }); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('Forced build from sources was requested'), 'env var triggers the forced build'); } finally { await server.close(); await sandbox.cleanup(); } }); test('force-build: --force-build-var reads a project-namespaced env var', async t => { const server = await startMockServer(); const sandbox = await makeSandbox(); try { server.setAsset(ASSET_PATH + '.br', await brotli(Buffer.from('should-not-be-fetched'))); const r = await runBin('install-from-cache.js', { cwd: sandbox.dir, args: ['--artifact', 'out/artifact.bin', '--force-build-var', 'RE2_FORCE_BUILD'], env: {...forceBuildEnv(server.url), RE2_FORCE_BUILD: '1', DOWNLOAD_FORCE_BUILD: ''} }); t.equal(r.code, 0, 'rebuild stub exited 0'); t.ok(r.stdout.includes('Forced build from sources was requested'), 'project-specific env var triggers the forced build'); } finally { await server.close(); await sandbox.cleanup(); } }); uhop-install-artifact-from-github-443ee1f/tsconfig.check.json000066400000000000000000000005161522333415600243530ustar00rootroot00000000000000{ "include": ["bin/**/*.js"], "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "noEmit": true, "allowJs": true, "checkJs": true, "noUnusedLocals": true, "noUnusedParameters": true, "strict": false, "skipLibCheck": true, "types": ["node"] } } uhop-install-artifact-from-github-443ee1f/wiki/000077500000000000000000000000001522333415600215315ustar00rootroot00000000000000