pax_global_header00006660000000000000000000000064151734436500014522gustar00rootroot0000000000000052 comment=b36eb6e626e286cd6e4e63c2f83075cecf75e18d uhop-install-artifact-from-github-b36eb6e/000077500000000000000000000000001517344365000206535ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/.claude/000077500000000000000000000000001517344365000221665ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/.claude/commands/000077500000000000000000000000001517344365000237675ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/.claude/commands/release-check.md000066400000000000000000000047261517344365000270150ustar00rootroot00000000000000--- description: Pre-release verification checklist for install-artifact-from-github --- # Release Check Run through this checklist before tagging a new release. This package is a zero-dependency, ESM-only, two-binary CLI helper. The deliverables are `bin/install-from-cache.js` and `bin/save-to-github-cache.js` — there is no public module surface, no build step, and primary consumer is `node-re2`. ## Steps 1. **Semver decision.** Review `git log ..HEAD` and classify the diff: - New CLI flag, new env var, new compression format, new behavior toggle → minor. - Bug fix or doc-only change → patch. - Renamed flag, removed flag, changed default behavior, changed env-var name → major. Record the chosen bump before touching anything else. 2. **`README.md`** Release-history table includes a one-line entry for the new version. 3. **`wiki/`** (submodule): if any user-facing flag or env var changed, the relevant page (`Install-from-cache.md` / `Save-to-Github-cache.md` / `Making-local-mirror.md`) reflects it. Commit wiki changes separately inside the submodule. 4. **`package.json` verification:** - `version` reflects the bump chosen in step 1. - `files` whitelist matches what should ship. Tarball contains `bin/`, `package.json`, `README.md`, and `LICENSE`. - `bin` map points at `bin/install-from-cache.js` and `bin/save-to-github-cache.js`. - `repository`, `bugs`, `homepage`, `author`, `license` are accurate. - `dependencies` is empty (zero-dep policy). 5. **LICENSE** copyright year includes the current year. 6. **Sweep dependencies for staleness.** Run `npm outdated`. Bump any devDep with a newer major or minor available and verify `npm run js-check` still passes. (This package ships zero runtime deps, so the sweep is devDep-only — but it still keeps Dependabot quiet.) 7. **Regenerate the lockfile:** ``` npm install ``` 8. **Full check matrix** — all must pass cleanly: ``` npm run lint npm run js-check npm test ``` 9. **Dry-run publish** to verify tarball contents: ``` npm pack --dry-run ``` Confirm NONE of these appear: `tests/`, `wiki/`, `.github/`, `.claude/`, `tsconfig*.json`, `scripts/`. 10. **Stop and report** — surface: - Chosen version bump and the diff summary since the last tag. - Test / lint / pack-dry-run results. - Any unresolved issue flagged during the walkthrough. Do **not** commit, tag, or publish without explicit user confirmation. uhop-install-artifact-from-github-b36eb6e/.editorconfig000066400000000000000000000003061517344365000233270ustar00rootroot00000000000000root = 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-b36eb6e/.gitattributes000066400000000000000000000003321517344365000235440ustar00rootroot00000000000000# 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-b36eb6e/.github/000077500000000000000000000000001517344365000222135ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/.github/dependabot.yml000066400000000000000000000011301517344365000250360ustar00rootroot00000000000000# 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' - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'weekly' uhop-install-artifact-from-github-b36eb6e/.github/workflows/000077500000000000000000000000001517344365000242505ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/.github/workflows/build.yml000066400000000000000000000023621517344365000260750ustar00rootroot00000000000000name: 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@v6 - 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-b36eb6e/.github/workflows/test.yml000066400000000000000000000016511517344365000257550ustar00rootroot00000000000000name: 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: 20} - {os: ubuntu-latest, node: 22} - {os: ubuntu-latest, node: 24} - {os: ubuntu-latest, node: 25} - {os: macos-latest, node: 24} - {os: windows-latest, node: 24} steps: - name: Checkout code uses: actions/checkout@v6 - 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-b36eb6e/.gitignore000066400000000000000000000001201517344365000226340ustar00rootroot00000000000000node_modules/ .AppleDouble /scripts/save-local.sh .claude/settings.local.json uhop-install-artifact-from-github-b36eb6e/.gitmodules000066400000000000000000000001451517344365000230300ustar00rootroot00000000000000[submodule "wiki"] path = wiki url = https://github.com/uhop/install-artifact-from-github.wiki.git uhop-install-artifact-from-github-b36eb6e/.prettierignore000066400000000000000000000000571517344365000237200ustar00rootroot00000000000000node_modules/ package-lock.json wiki/ .github/ uhop-install-artifact-from-github-b36eb6e/.prettierrc000066400000000000000000000001771517344365000230440ustar00rootroot00000000000000{ "printWidth": 160, "singleQuote": true, "bracketSpacing": false, "arrowParens": "avoid", "trailingComma": "none" } uhop-install-artifact-from-github-b36eb6e/LICENSE000066400000000000000000000035651517344365000216710ustar00rootroot00000000000000This library is available under the terms of the modified BSD license. No external contributions are allowed under licenses which are fundamentally incompatible with the BSD license that this library is distributed under. The text of the BSD license is reproduced below. ------------------------------------------------------------------------------- The "New" BSD License: ********************** Copyright (c) 2005-2026, Eugene Lazutkin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * 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. * Neither the name of Eugene Lazutkin nor the names of other 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 OWNER 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-b36eb6e/README.md000066400000000000000000000067551517344365000221470ustar00rootroot00000000000000# 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 two small one-file utilities integrated with [GitHub releases](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/about-releases). The project solves two problems: - [save-to-github-cache](./Saving) saves a binary artifact to a Github release according to the platform, architecture, and Node ABI. - Designed to be used with [GitHub actions](https://github.com/features/actions). - [install-from-cache](./Installing) retrieves a previously saved artifact, tests if it works properly, and rebuilds a project from sources in the case of failure. 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. ## Documentation The full documentation is available in the [wiki](https://github.com/uhop/install-artifact-from-github/wiki). ## Release history - 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._ - 1.2.0 _support for NPM >= 7._ - 1.1.3 _technical release: updated docs._ - 1.1.2 _technical release: updated docs._ - 1.1.1 _numerous bugfixes to please Github REST API._ - 1.1.0 _moved `save-to-github` here from a separate project, reduced 3rd-party dependencies._ - 1.0.2 _fixed a `yarn`-specific bug._ - 1.0.1 _fixed a bug in the environment variable parameter._ - 1.0.0 _initial release (extracted from [node-re2](https://github.com/uhop/node-re2))._ uhop-install-artifact-from-github-b36eb6e/bin/000077500000000000000000000000001517344365000214235ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/bin/install-from-cache.js000077500000000000000000000215731517344365000254440ustar00rootroot00000000000000#!/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'; /** @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'; const napiLevel = napiDirect || process.env[napiEnvVar] || process.env.npm_config_platform_napi || ''; const abiSlot = napiLevel ? `napi-v${napiLevel}` : platformABI; 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] || '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); }; 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) || ''; } 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 (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; // let's try brotli if (zlib.brotliDecompress) { try { console.log(`Trying ${prefix}.br ...`); const artifact = await get(prefix + '.br'); console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, await promisify(zlib.brotliDecompress)(artifact)); copied = true; } catch (e) { // squelch } } // let's try gzip if (!copied && zlib.gunzip) { try { console.log(`Trying ${prefix}.gz ...`); const artifact = await get(prefix + '.gz'); console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, await promisify(zlib.gunzip)(artifact)); copied = true; } catch (e) { // squelch } } // let's try uncompressed if (!copied) { try { console.log(`Trying ${prefix} ...`); const artifact = await get(prefix); console.log(`Writing to ${artifactPath} ...`); await write(artifactPath, artifact); copied = 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-b36eb6e/bin/save-to-github-cache.js000077500000000000000000000157721517344365000256770ustar00rootroot00000000000000#!/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-b36eb6e/package-lock.json000066400000000000000000000056321517344365000240750ustar00rootroot00000000000000{ "name": "install-artifact-from-github", "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "install-artifact-from-github", "version": "1.6.0", "license": "BSD-3-Clause", "bin": { "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" }, "devDependencies": { "@types/node": "^25.6.0", "prettier": "^3.3.3", "tape-six": "^1.7.14", "typescript": "^6.0.3" }, "engines": { "node": ">=18" } }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.19.0" } }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "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.7.14", "resolved": "https://registry.npmjs.org/tape-six/-/tape-six-1.7.14.tgz", "integrity": "sha512-+SkkMf9SsbSXiIuVc1ENlbiu/wGd5eSeuMkIPyh9d1ZNLbC/ZKRlybpa0a/MXnwjlrwEGF9Uq75L1g+14waYUQ==", "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": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" } } } uhop-install-artifact-from-github-b36eb6e/package.json000066400000000000000000000026241517344365000231450ustar00rootroot00000000000000{ "name": "install-artifact-from-github", "version": "1.6.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://github.com/uhop/install-artifact-from-github.git" }, "files": [ "/bin" ], "bin": { "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" }, "keywords": [ "helper", "node addons" ], "author": "Eugene Lazutkin (https://lazutkin.com/)", "license": "BSD-3-Clause", "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.3.3", "tape-six": "^1.7.14", "typescript": "^6.0.3", "@types/node": "^25.6.0" } } uhop-install-artifact-from-github-b36eb6e/scripts/000077500000000000000000000000001517344365000223425ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/scripts/dump-env.js000066400000000000000000000012351517344365000244340ustar00rootroot00000000000000const 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-b36eb6e/scripts/example-save.sh000066400000000000000000000015261517344365000252710ustar00rootroot00000000000000#!/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-b36eb6e/tests/000077500000000000000000000000001517344365000220155ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/tests/fixtures/000077500000000000000000000000001517344365000236665ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/tests/fixtures/recording-agent.js000066400000000000000000000017121517344365000272750ustar00rootroot00000000000000// 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-b36eb6e/tests/helpers/000077500000000000000000000000001517344365000234575ustar00rootroot00000000000000uhop-install-artifact-from-github-b36eb6e/tests/helpers/mock-server.js000066400000000000000000000055301517344365000262550ustar00rootroot00000000000000import 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-b36eb6e/tests/helpers/run-bin.js000066400000000000000000000033411517344365000253700ustar00rootroot00000000000000import {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-b36eb6e/tests/test-install.js000066400000000000000000000337511517344365000250070ustar00rootroot00000000000000import 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-b36eb6e/tests/test-save.js000066400000000000000000000132321517344365000242670ustar00rootroot00000000000000import 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-b36eb6e/tsconfig.check.json000066400000000000000000000005161517344365000244400ustar00rootroot00000000000000{ "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-b36eb6e/wiki/000077500000000000000000000000001517344365000216165ustar00rootroot00000000000000