diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be14e9..ae717a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,12 @@ on: permissions: contents: write + id-token: write jobs: release: + # MUST use GitHub-hosted public runners — npm --provenance requires OIDC id-token + # from a trusted CI environment (GitHub Actions on public runners). runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -24,3 +27,20 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Smoke test npm packages + run: | + VERSION="${GITHUB_REF_NAME#v}" + VERSION="$VERSION" scripts/npm-smoke-test.sh + + - name: Publish to npm + run: | + VERSION="${GITHUB_REF_NAME#v}" + VERSION="$VERSION" scripts/npm-publish.sh + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 5df1fe6..d2e8af3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -cg +/cg *.exe *.exe~ *.dll @@ -29,6 +29,10 @@ Thumbs.db # Build dist/ +# npm platform binaries (copied during CI publish) +npm/cg-*/cg +npm/cg-*/cg.exe + # Secrets .env .env.* diff --git a/npm/cg-darwin-arm64/package.json b/npm/cg-darwin-arm64/package.json new file mode 100644 index 0000000..a50fe14 --- /dev/null +++ b/npm/cg-darwin-arm64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-darwin-arm64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (macOS ARM64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["cg"] +} diff --git a/npm/cg-darwin-x64/package.json b/npm/cg-darwin-x64/package.json new file mode 100644 index 0000000..1732ac8 --- /dev/null +++ b/npm/cg-darwin-x64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-darwin-x64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (macOS x64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["darwin"], + "cpu": ["x64"], + "files": ["cg"] +} diff --git a/npm/cg-linux-arm64/package.json b/npm/cg-linux-arm64/package.json new file mode 100644 index 0000000..5c88ebb --- /dev/null +++ b/npm/cg-linux-arm64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-linux-arm64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (Linux ARM64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["linux"], + "cpu": ["arm64"], + "files": ["cg"] +} diff --git a/npm/cg-linux-x64/package.json b/npm/cg-linux-x64/package.json new file mode 100644 index 0000000..bd8e7a7 --- /dev/null +++ b/npm/cg-linux-x64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-linux-x64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (Linux x64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["linux"], + "cpu": ["x64"], + "files": ["cg"] +} diff --git a/npm/cg-win32-arm64/package.json b/npm/cg-win32-arm64/package.json new file mode 100644 index 0000000..74d0b13 --- /dev/null +++ b/npm/cg-win32-arm64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-win32-arm64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (Windows ARM64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["win32"], + "cpu": ["arm64"], + "files": ["cg.exe"] +} diff --git a/npm/cg-win32-x64/package.json b/npm/cg-win32-x64/package.json new file mode 100644 index 0000000..c998108 --- /dev/null +++ b/npm/cg-win32-x64/package.json @@ -0,0 +1,13 @@ +{ + "name": "@coingecko/cg-win32-x64", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data (Windows x64)", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "os": ["win32"], + "cpu": ["x64"], + "files": ["cg.exe"] +} diff --git a/npm/cg/bin/cg.js b/npm/cg/bin/cg.js new file mode 100644 index 0000000..242b1f3 --- /dev/null +++ b/npm/cg/bin/cg.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +"use strict"; + +const { execFileSync } = require("child_process"); +const path = require("path"); +const os = require("os"); + +// Map Node.js platform/arch to npm package names +const PLATFORMS = { + "darwin arm64": "@coingecko/cg-darwin-arm64", + "darwin x64": "@coingecko/cg-darwin-x64", + "linux arm64": "@coingecko/cg-linux-arm64", + "linux x64": "@coingecko/cg-linux-x64", + "win32 arm64": "@coingecko/cg-win32-arm64", + "win32 x64": "@coingecko/cg-win32-x64", +}; + +function getBinaryPath() { + const platform = os.platform(); + const arch = os.arch(); + const key = `${platform} ${arch}`; + const pkg = PLATFORMS[key]; + + if (!pkg) { + throw new Error( + `Unsupported platform: ${platform} ${arch}. ` + + `Supported: ${Object.keys(PLATFORMS).join(", ")}` + ); + } + + const binary = platform === "win32" ? "cg.exe" : "cg"; + + try { + // resolve the platform package from this package's location + const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`)); + return path.join(pkgDir, binary); + } catch { + throw new Error( + `The platform package ${pkg} is not installed. ` + + `This usually means your package manager excluded optional dependencies. ` + + `Try reinstalling with: npm install @coingecko/cg` + ); + } +} + +const binary = getBinaryPath(); + +try { + execFileSync(binary, process.argv.slice(2), { stdio: "inherit" }); +} catch (err) { + if (err.status !== undefined) { + process.exit(err.status); + } + throw err; +} diff --git a/npm/cg/package.json b/npm/cg/package.json new file mode 100644 index 0000000..7997624 --- /dev/null +++ b/npm/cg/package.json @@ -0,0 +1,34 @@ +{ + "name": "@coingecko/cg", + "version": "0.0.0", + "description": "CoinGecko CLI - Real Time & Historical Crypto Data", + "repository": { + "type": "git", + "url": "https://github.com/coingecko/coingecko-cli.git" + }, + "license": "MIT", + "bin": { + "cg": "bin/cg.js" + }, + "files": [ + "bin/cg.js" + ], + "optionalDependencies": { + "@coingecko/cg-darwin-arm64": "0.0.0", + "@coingecko/cg-darwin-x64": "0.0.0", + "@coingecko/cg-linux-arm64": "0.0.0", + "@coingecko/cg-linux-x64": "0.0.0", + "@coingecko/cg-win32-arm64": "0.0.0", + "@coingecko/cg-win32-x64": "0.0.0" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "coingecko", + "crypto", + "cryptocurrency", + "bitcoin", + "cli" + ] +} diff --git a/scripts/npm-publish.sh b/scripts/npm-publish.sh new file mode 100755 index 0000000..5ccef46 --- /dev/null +++ b/scripts/npm-publish.sh @@ -0,0 +1,162 @@ +#!/bin/sh +set -eu + +# Publish CoinGecko CLI to npm as platform-specific packages. +# Called from CI after goreleaser has built the archives. +# +# Usage: VERSION=1.2.3 scripts/npm-publish.sh +# +# Expects: +# - goreleaser dist/ directory with archives and checksums.txt +# - NPM_TOKEN environment variable (or .npmrc already configured) +# +# Safety: +# - Verifies archive checksums against goreleaser's checksums.txt before extracting +# - Aborts if any platform archive is missing (won't publish broken umbrella) +# - Skips already-published versions for retry safety after partial failures +# - Uses node to rewrite package.json versions (works regardless of current value) +# - Publishes with --provenance for supply-chain verification (requires id-token: write) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +NPM_DIR="$ROOT_DIR/npm" +DIST_DIR="$ROOT_DIR/dist" + +if [ -z "${VERSION:-}" ]; then + echo "Error: VERSION environment variable is required" + exit 1 +fi + +echo "Publishing @coingecko/cg v${VERSION} to npm" + +# Mapping: goreleaser os_arch -> npm package directory + binary name +# darwin_arm64 -> cg-darwin-arm64/cg +# darwin_amd64 -> cg-darwin-x64/cg +# linux_arm64 -> cg-linux-arm64/cg +# linux_amd64 -> cg-linux-x64/cg +# windows_arm64 -> cg-win32-arm64/cg.exe +# windows_amd64 -> cg-win32-x64/cg.exe + +# set_version [dependency_version] +# Rewrites version (and optionalDependencies versions) in a package.json. +set_version() { + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$1', 'utf8')); + pkg.version = '$2'; + if ('$3' !== '' && pkg.optionalDependencies) { + for (const k of Object.keys(pkg.optionalDependencies)) { + pkg.optionalDependencies[k] = '$3'; + } + } + fs.writeFileSync('$1', JSON.stringify(pkg, null, 2) + '\n'); + " +} + +# is_published +# Returns 0 if the exact version is already on the registry. +is_published() { + npm view "$1@$2" version >/dev/null 2>&1 +} + +# verify_checksum +# Verifies a file against goreleaser's checksums.txt. Aborts on mismatch. +verify_checksum() { + local file="$1" + local filename + filename=$(basename "$file") + local expected + expected=$(grep " ${filename}$" "${DIST_DIR}/checksums.txt" | awk '{print $1}') + if [ -z "$expected" ]; then + echo "Error: no checksum entry for ${filename} in checksums.txt" + exit 1 + fi + local actual + if ! command -v sha256sum >/dev/null 2>&1; then + echo "Error: sha256sum not found" + exit 1 + fi + actual=$(sha256sum "$file" | awk '{print $1}') + if [ "$expected" != "$actual" ]; then + echo "Error: checksum mismatch for ${filename}" + echo " Expected: ${expected}" + echo " Actual: ${actual}" + exit 1 + fi +} + +# Phase 1: Verify all platform archives exist and match checksums +echo "Verifying platform archives..." +CHECKSUMS_FILE="${DIST_DIR}/checksums.txt" +if [ ! -f "$CHECKSUMS_FILE" ]; then + echo "Error: ${CHECKSUMS_FILE} not found. Cannot verify archive integrity." + exit 1 +fi +PLATFORMS="darwin:arm64:cg-darwin-arm64:cg:tar.gz +darwin:amd64:cg-darwin-x64:cg:tar.gz +linux:arm64:cg-linux-arm64:cg:tar.gz +linux:amd64:cg-linux-x64:cg:tar.gz +windows:arm64:cg-win32-arm64:cg.exe:zip +windows:amd64:cg-win32-x64:cg.exe:zip" + +MISSING="" +echo "$PLATFORMS" | while IFS=: read -r goos goarch npm_dir binary ext; do + archive="${DIST_DIR}/cg_${VERSION}_${goos}_${goarch}.${ext}" + if [ ! -f "$archive" ]; then + echo " MISSING: ${archive}" + touch "${DIST_DIR}/.npm-missing" + else + verify_checksum "$archive" + fi +done + +if [ -f "${DIST_DIR}/.npm-missing" ]; then + rm -f "${DIST_DIR}/.npm-missing" + echo "Error: one or more platform archives are missing. Aborting npm publish." + exit 1 +fi +echo " All archives present and checksums verified." + +# Phase 2: Extract, version-stamp, and publish each platform package +echo "$PLATFORMS" | while IFS=: read -r goos goarch npm_dir binary ext; do + pkg_name="@coingecko/${npm_dir}" + pkg_dir="${NPM_DIR}/${npm_dir}" + archive="cg_${VERSION}_${goos}_${goarch}.${ext}" + archive_path="${DIST_DIR}/${archive}" + + if is_published "$pkg_name" "$VERSION"; then + echo " ${pkg_name}@${VERSION} already published, skipping" + continue + fi + + echo " Extracting ${archive}..." + tmpdir=$(mktemp -d) + if [ "$ext" = "tar.gz" ]; then + tar -xzf "$archive_path" -C "$tmpdir" + else + unzip -q "$archive_path" -d "$tmpdir" + fi + + cp "${tmpdir}/${binary}" "${pkg_dir}/${binary}" + chmod +x "${pkg_dir}/${binary}" + rm -rf "$tmpdir" + + set_version "${pkg_dir}/package.json" "$VERSION" "" + + echo " Publishing ${pkg_name}@${VERSION}..." + npm publish "${pkg_dir}" --access public --provenance +done + +# Phase 3: Version-stamp and publish the umbrella package +UMBRELLA_DIR="${NPM_DIR}/cg" + +if is_published "@coingecko/cg" "$VERSION"; then + echo " @coingecko/cg@${VERSION} already published, skipping" +else + set_version "${UMBRELLA_DIR}/package.json" "$VERSION" "$VERSION" + + echo " Publishing @coingecko/cg@${VERSION}..." + npm publish "${UMBRELLA_DIR}" --access public --provenance +fi + +echo "Done! Published @coingecko/cg@${VERSION} and all platform packages." diff --git a/scripts/npm-smoke-test.sh b/scripts/npm-smoke-test.sh new file mode 100755 index 0000000..f748851 --- /dev/null +++ b/scripts/npm-smoke-test.sh @@ -0,0 +1,112 @@ +#!/bin/sh +set -eu + +# End-to-end smoke test for the npm wrapper package. +# Runs after goreleaser and before npm publish to catch packaging issues early. +# +# Usage: VERSION=1.2.3 scripts/npm-smoke-test.sh +# +# Tests: +# 1. Extracts the runner's platform binary into its npm package dir +# 2. npm pack both the platform package and the umbrella +# 3. npm install from the tarballs into a temp project +# 4. Invokes "cg version" via the wrapper and verifies output + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +NPM_DIR="$ROOT_DIR/npm" +DIST_DIR="$ROOT_DIR/dist" + +if [ -z "${VERSION:-}" ]; then + echo "Error: VERSION environment variable is required" + exit 1 +fi + +echo "=== npm smoke test v${VERSION} ===" + +# Detect the current runner's platform to pick the right package +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$OS" in + linux) NPM_OS="linux" ;; + darwin) NPM_OS="darwin" ;; + *) echo "Unsupported smoke test OS: $OS"; exit 1 ;; +esac + +case "$ARCH" in + x86_64|amd64) NPM_CPU="x64"; GO_ARCH="amd64" ;; + arm64|aarch64) NPM_CPU="arm64"; GO_ARCH="arm64" ;; + *) echo "Unsupported smoke test arch: $ARCH"; exit 1 ;; +esac + +PLATFORM_PKG="cg-${NPM_OS}-${NPM_CPU}" +PLATFORM_DIR="${NPM_DIR}/${PLATFORM_PKG}" +ARCHIVE="cg_${VERSION}_${OS}_${GO_ARCH}.tar.gz" +ARCHIVE_PATH="${DIST_DIR}/${ARCHIVE}" + +if [ ! -f "$ARCHIVE_PATH" ]; then + echo "Error: archive not found: ${ARCHIVE_PATH}" + exit 1 +fi + +# Step 1: Extract binary into platform package +echo " Extracting ${ARCHIVE} into ${PLATFORM_PKG}/" +tmpdir=$(mktemp -d) +tar -xzf "$ARCHIVE_PATH" -C "$tmpdir" +cp "${tmpdir}/cg" "${PLATFORM_DIR}/cg" +chmod +x "${PLATFORM_DIR}/cg" +rm -rf "$tmpdir" + +# Stamp versions for packing +node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('${PLATFORM_DIR}/package.json', 'utf8')); + pkg.version = '${VERSION}'; + fs.writeFileSync('${PLATFORM_DIR}/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" +node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('${NPM_DIR}/cg/package.json', 'utf8')); + pkg.version = '${VERSION}'; + for (const k of Object.keys(pkg.optionalDependencies || {})) { + pkg.optionalDependencies[k] = '${VERSION}'; + } + fs.writeFileSync('${NPM_DIR}/cg/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" + +# Step 2: npm pack both packages +PACK_DIR=$(mktemp -d) + +echo " Packing platform package..." +PLATFORM_TGZ=$(npm pack "${PLATFORM_DIR}" --pack-destination "$PACK_DIR" 2>/dev/null | tail -1) + +echo " Packing umbrella package..." +UMBRELLA_TGZ=$(npm pack "${NPM_DIR}/cg" --pack-destination "$PACK_DIR" 2>/dev/null | tail -1) + +# Step 3: Install from tarballs into an isolated temp project +echo " Installing from tarballs..." +TEST_DIR=$(mktemp -d) +cd "$TEST_DIR" +npm init -y >/dev/null 2>&1 +npm install "${PACK_DIR}/${PLATFORM_TGZ}" "${PACK_DIR}/${UMBRELLA_TGZ}" >/dev/null 2>&1 + +# Step 4: Invoke the wrapper and check output +echo " Running: npx cg version" +OUTPUT=$(npx cg version 2>&1) || true + +if echo "$OUTPUT" | grep -qi "coingecko\|cg\|${VERSION}"; then + echo " Smoke test passed: ${OUTPUT}" +else + echo " Error: unexpected output from cg version:" + echo " ${OUTPUT}" + rm -rf "$PACK_DIR" "$TEST_DIR" + exit 1 +fi + +# Cleanup +rm -rf "$PACK_DIR" "$TEST_DIR" +# Remove binary extracted for testing (CI will re-extract during publish) +rm -f "${PLATFORM_DIR}/cg" + +echo "=== smoke test passed ==="