From 598542227a69b0ed2158a37ad937699737daca43 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Mon, 6 Jul 2026 14:51:05 -0400 Subject: [PATCH 1/6] Add npm trusted-publishing package + ci.yaml workflow Distribute the volcano CLI via npm as a thin wrapper (@kong/volcano-cli): - package.json with bin shim, files allowlist, postinstall, public access - bin/volcano.js launcher execs the platform binary, forwarding args/exit code - scripts/npm/download.js downloads + SHA256-verifies the binary from the matching GitHub Release; self-heals on first run if postinstall is skipped - .github/workflows/ci.yaml publishes to npm via OIDC trusted publishing (id-token: write, no NPM_TOKEN) on stable release publish - README + .gitignore updates --- .github/workflows/ci.yaml | 103 ++++++++++++++++++++++++ .gitignore | 3 + README.md | 12 +++ bin/volcano.js | 49 ++++++++++++ package.json | 46 +++++++++++ scripts/npm/download.js | 164 ++++++++++++++++++++++++++++++++++++++ scripts/npm/install.js | 28 +++++++ 7 files changed, 405 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 bin/volcano.js create mode 100644 package.json create mode 100644 scripts/npm/download.js create mode 100644 scripts/npm/install.js diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f558aec --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,103 @@ +name: Publish to npm + +# Publishes the @kong/volcano-cli npm package using npm trusted publishing +# (OIDC) — no long-lived NPM_TOKEN required. +# +# This runs AFTER publish-cli.yml has created the GitHub Release and uploaded +# the signed binaries + SHA256SUMS, because the published npm package downloads +# those assets in its postinstall step. +# +# Trusted publisher setup (one-time, on npmjs.com -> package Settings -> +# Trusted Publisher): +# Organization or user: Kong +# Repository: volcano-cli +# Workflow filename: ci.yaml (must match this file's name exactly) + +on: + # Fires when publish-cli.yml publishes the GitHub Release for a version tag. + release: + types: [published] + # Manual re-publish (e.g. if the release existed before this workflow). + workflow_dispatch: + inputs: + version: + description: "Version to publish, without a leading v (e.g. 1.2.3)" + required: true + type: string + +permissions: + contents: read + id-token: write # REQUIRED for npm trusted publishing (OIDC) + +concurrency: + group: npm-publish-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + publish-npm: + name: Publish npm package + runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners + # Only publish stable (non-prerelease) versions to npm. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'release' && github.event.release.prerelease == false) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + # Trusted publishing requires npm >= 11.5.1 (newer than what ships by default). + - name: Upgrade npm + run: npm install -g npm@latest + + - name: Resolve release version + id: version + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + RAW="${{ github.event.inputs.version }}" + else + RAW="${{ github.event.release.tag_name }}" + fi + VERSION="${RAW#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish non-stable version to npm: ${RAW}" + echo "Only stable vMAJOR.MINOR.PATCH releases are published to npm." + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Set package version + run: | + npm version "${{ steps.version.outputs.version }}" \ + --no-git-tag-version --allow-same-version + + - name: Verify release assets exist + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" + for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do + asset="volcano-${target}" + if [ "$target" = "windows-amd64" ]; then + asset="${asset}.exe" + fi + echo "Checking ${base}/${asset}" + curl -fsSL --retry 3 --retry-delay 5 -o /dev/null -I "${base}/${asset}" + done + echo "Checking ${base}/SHA256SUMS" + curl -fsSL --retry 3 --retry-delay 5 -o /dev/null -I "${base}/SHA256SUMS" + + # OIDC trusted publishing: npm detects the id-token and authenticates + # automatically. Provenance is generated automatically for public repos. + # VOLCANO_SKIP_DOWNLOAD prevents the postinstall from fetching a binary + # in CI if any install lifecycle runs. + - name: Publish to npm + env: + VOLCANO_SKIP_DOWNLOAD: "1" + run: npm publish diff --git a/.gitignore b/.gitignore index 6be6ad5..7f8dfb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /volcano /dist/ +# Downloaded platform binaries fetched by the npm postinstall (keep bin/volcano.js) +/bin/volcano-* +node_modules/ /.env /.env.local /.env.* diff --git a/README.md b/README.md index 128b57f..a251c82 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ Install the latest published release: curl -fsSL https://github.com/Kong/volcano-cli/releases/latest/download/install.sh | bash ``` +Or install from npm: + +```bash +npm install -g @kong/volcano-cli +volcano --help +``` + +The npm package is a thin wrapper: its `postinstall` step downloads the +platform-specific binary from the matching GitHub Release and verifies it +against that release's `SHA256SUMS`. Set `VOLCANO_SKIP_DOWNLOAD=1` to skip the +download (it is fetched on first run instead). + Build from source: ```bash diff --git a/bin/volcano.js b/bin/volcano.js new file mode 100644 index 0000000..34c2f30 --- /dev/null +++ b/bin/volcano.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +'use strict'; + +// Launcher shim: exec the platform-specific Volcano CLI binary, forwarding all +// arguments, stdio, and the exit code. If the binary is missing (e.g. install +// ran with --ignore-scripts, or the postinstall download failed) it is fetched +// and verified on demand. + +const fs = require('fs'); +const { spawn } = require('child_process'); +const { binaryPath, ensureBinary } = require('../scripts/npm/download.js'); + +async function resolveBinary() { + const bin = binaryPath(); + if (fs.existsSync(bin)) { + return bin; + } + try { + return await ensureBinary(); + } catch (err) { + console.error(`volcano: failed to download the CLI binary: ${err.message}`); + console.error( + 'Download it manually from https://github.com/Kong/volcano-cli/releases ' + + 'or re-install the package.' + ); + process.exit(1); + } +} + +async function main() { + const bin = await resolveBinary(); + const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' }); + + child.on('error', (err) => { + console.error(`volcano: failed to launch binary: ${err.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + // Re-raise the signal so the parent reflects the child's termination. + process.kill(process.pid, signal); + return; + } + process.exit(code == null ? 1 : code); + }); +} + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..94fd100 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@kong/volcano-cli", + "version": "0.0.0", + "description": "Command-line client for Volcano, Kong's hosting platform.", + "keywords": [ + "volcano", + "kong", + "cli" + ], + "homepage": "https://github.com/Kong/volcano-cli#readme", + "bugs": { + "url": "https://github.com/Kong/volcano-cli/issues" + }, + "license": "Apache-2.0", + "type": "commonjs", + "repository": { + "type": "git", + "url": "git+https://github.com/Kong/volcano-cli.git" + }, + "bin": { + "volcano": "bin/volcano.js" + }, + "files": [ + "bin/volcano.js", + "scripts/npm/" + ], + "scripts": { + "postinstall": "node scripts/npm/install.js", + "prepublishOnly": "node -e \"if (require('./package.json').version === '0.0.0') { console.error('Refusing to publish version 0.0.0; set the real version first (npm version ).'); process.exit(1); }\"" + }, + "engines": { + "node": ">=18" + }, + "os": [ + "linux", + "darwin", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/scripts/npm/download.js b/scripts/npm/download.js new file mode 100644 index 0000000..8d1aacd --- /dev/null +++ b/scripts/npm/download.js @@ -0,0 +1,164 @@ +'use strict'; + +// Shared helpers for downloading and verifying the platform-specific Volcano +// CLI binary from GitHub Releases. Used by the postinstall script +// (scripts/npm/install.js) and, as a self-healing fallback, by the launcher +// shim (bin/volcano.js). + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); +const { pipeline } = require('stream'); +const { promisify } = require('util'); + +const streamPipeline = promisify(pipeline); + +const pkg = require('../../package.json'); + +// Allow pointing at a mirror (kept consistent with scripts/install-volcano.sh). +const RELEASES_BASE = ( + process.env.VOLCANO_GITHUB_RELEASES_URL || + 'https://github.com/Kong/volcano-cli/releases' +).replace(/\/+$/, ''); + +// Map Node's platform-arch to the release asset target id. +const TARGETS = { + 'linux-x64': 'linux-amd64', + 'linux-arm64': 'linux-arm64', + 'darwin-x64': 'macos-amd64', + 'darwin-arm64': 'macos-arm64', + 'win32-x64': 'windows-amd64', +}; + +function resolveTarget() { + const key = `${process.platform}-${process.arch}`; + const target = TARGETS[key]; + if (!target) { + throw new Error( + `Unsupported platform "${key}". Volcano CLI ships binaries for: ` + + `${Object.keys(TARGETS).join(', ')}.` + ); + } + return target; +} + +function binaryExt() { + return process.platform === 'win32' ? '.exe' : ''; +} + +function assetName() { + return `volcano-${resolveTarget()}${binaryExt()}`; +} + +// Absolute path where the downloaded binary lives (next to the launcher shim). +function binaryPath() { + return path.join(__dirname, '..', '..', 'bin', assetName()); +} + +function releaseTag() { + // The npm package version maps 1:1 to the GitHub release tag `v`. + return `v${pkg.version}`; +} + +function get(url, redirects = 0) { + return new Promise((resolve, reject) => { + if (redirects > 10) { + reject(new Error(`Too many redirects while fetching ${url}`)); + return; + } + const req = https.get( + url, + { headers: { 'User-Agent': `volcano-cli-npm/${pkg.version}` } }, + (res) => { + const { statusCode, headers } = res; + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); + const next = new URL(headers.location, url).toString(); + resolve(get(next, redirects + 1)); + return; + } + if (statusCode !== 200) { + res.resume(); + reject(new Error(`Request to ${url} failed with status ${statusCode}`)); + return; + } + resolve(res); + } + ); + req.on('error', reject); + req.setTimeout(60000, () => { + req.destroy(new Error(`Request to ${url} timed out`)); + }); + }); +} + +async function getText(url) { + const res = await get(url); + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +// Parse a `shasum -a 256` style manifest and return the hex digest for `name`. +function parseChecksum(manifest, name) { + const lines = manifest.split('\n'); + for (const line of lines) { + const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match && match[2].trim() === name) { + return match[1].toLowerCase(); + } + } + return null; +} + +async function downloadToFile(url, dest) { + const hash = crypto.createHash('sha256'); + const tmp = `${dest}.download-${process.pid}`; + const res = await get(url); + res.on('data', (chunk) => hash.update(chunk)); + await streamPipeline(res, fs.createWriteStream(tmp)); + fs.renameSync(tmp, dest); + return hash.digest('hex'); +} + +// Download the correct binary for the current platform and verify its checksum +// against the release's SHA256SUMS manifest. Idempotent: no-op if present. +async function ensureBinary({ force = false } = {}) { + const dest = binaryPath(); + if (!force && fs.existsSync(dest)) { + return dest; + } + + const name = assetName(); + const tag = releaseTag(); + const base = `${RELEASES_BASE}/download/${tag}`; + + const manifest = await getText(`${base}/SHA256SUMS`); + const expected = parseChecksum(manifest, name); + if (!expected) { + throw new Error(`No SHA256 checksum for ${name} in ${tag}/SHA256SUMS`); + } + + fs.mkdirSync(path.dirname(dest), { recursive: true }); + const actual = (await downloadToFile(`${base}/${name}`, dest)).toLowerCase(); + if (actual !== expected) { + fs.rmSync(dest, { force: true }); + throw new Error( + `Checksum mismatch for ${name}: expected ${expected}, got ${actual}` + ); + } + + if (process.platform !== 'win32') { + fs.chmodSync(dest, 0o755); + } + return dest; +} + +module.exports = { + resolveTarget, + assetName, + binaryPath, + releaseTag, + ensureBinary, +}; diff --git a/scripts/npm/install.js b/scripts/npm/install.js new file mode 100644 index 0000000..b77a771 --- /dev/null +++ b/scripts/npm/install.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +'use strict'; + +// npm postinstall hook: download the platform-specific Volcano CLI binary. +// +// Failures here are non-fatal so that `npm install` still succeeds in +// restricted/offline environments (or with `--ignore-scripts`). In that case +// the launcher shim (bin/volcano.js) downloads the binary on first use. + +const { ensureBinary, resolveTarget } = require('./download.js'); + +async function main() { + if (process.env.VOLCANO_SKIP_DOWNLOAD === '1') { + console.log('Skipping Volcano CLI download (VOLCANO_SKIP_DOWNLOAD=1).'); + return; + } + + const target = resolveTarget(); + const binary = await ensureBinary(); + console.log(`Installed Volcano CLI (${target}) -> ${binary}`); +} + +main().catch((err) => { + console.warn(`Warning: could not download the Volcano CLI binary: ${err.message}`); + console.warn('It will be downloaded automatically the first time you run "volcano".'); + // Exit 0 so installation does not hard-fail; the shim self-heals on first run. + process.exit(0); +}); From 3db70f3ce6163ebf163e182e4f6732d78668c2d3 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Mon, 6 Jul 2026 16:33:31 -0400 Subject: [PATCH 2/6] Address Copilot review: protocol-aware fetch, temp cleanup, safe overwrite, pin npm - download.js: select http/https client by URL scheme (supports http mirrors), reject unsupported schemes with a clear error - download.js: remove partial temp file on download failure; clear destination before rename so force re-download / Windows overwrite works - ci.yaml: pin npm to the 11 line instead of npm@latest for reproducibility --- .github/workflows/ci.yaml | 5 +++-- scripts/npm/download.js | 34 +++++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f558aec..cba5c38 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,9 +50,10 @@ jobs: registry-url: "https://registry.npmjs.org" package-manager-cache: false - # Trusted publishing requires npm >= 11.5.1 (newer than what ships by default). + # Trusted publishing requires npm >= 11.5.1. Pin to the npm 11 line for + # reproducibility rather than tracking npm@latest across major versions. - name: Upgrade npm - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Resolve release version id: version diff --git a/scripts/npm/download.js b/scripts/npm/download.js index 8d1aacd..c1f8077 100644 --- a/scripts/npm/download.js +++ b/scripts/npm/download.js @@ -7,6 +7,7 @@ const fs = require('fs'); const path = require('path'); +const http = require('http'); const https = require('https'); const crypto = require('crypto'); const { pipeline } = require('stream'); @@ -67,7 +68,21 @@ function get(url, redirects = 0) { reject(new Error(`Too many redirects while fetching ${url}`)); return; } - const req = https.get( + let protocol; + try { + protocol = new URL(url).protocol; + } catch (err) { + reject(new Error(`Invalid download URL "${url}": ${err.message}`)); + return; + } + // Pick the client by scheme so an http:// mirror + // (VOLCANO_GITHUB_RELEASES_URL) works too. + const client = protocol === 'https:' ? https : protocol === 'http:' ? http : null; + if (!client) { + reject(new Error(`Unsupported URL scheme "${protocol}" for ${url}; use http or https.`)); + return; + } + const req = client.get( url, { headers: { 'User-Agent': `volcano-cli-npm/${pkg.version}` } }, (res) => { @@ -115,10 +130,19 @@ function parseChecksum(manifest, name) { async function downloadToFile(url, dest) { const hash = crypto.createHash('sha256'); const tmp = `${dest}.download-${process.pid}`; - const res = await get(url); - res.on('data', (chunk) => hash.update(chunk)); - await streamPipeline(res, fs.createWriteStream(tmp)); - fs.renameSync(tmp, dest); + try { + const res = await get(url); + res.on('data', (chunk) => hash.update(chunk)); + await streamPipeline(res, fs.createWriteStream(tmp)); + // rename does not overwrite an existing destination on Windows, so clear it + // first (also covers a check-then-write race and force re-downloads). + fs.rmSync(dest, { force: true }); + fs.renameSync(tmp, dest); + } catch (err) { + // Never leave a partial download behind. + fs.rmSync(tmp, { force: true }); + throw err; + } return hash.digest('hex'); } From d0afc8bce59fabba6844afeea4976992ec854384 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Mon, 6 Jul 2026 17:11:03 -0400 Subject: [PATCH 3/6] Rename npm publish workflow to publish-npm.yml + review fixes - Rename .github/workflows/ci.yaml -> publish-npm.yml (parallels publish-cli.yml; avoids confusion with the existing ci.yml Go checks) - bin/volcano.js: handle unsupported platforms (e.g. win32-arm64, which passes the package.json os/cpu gate) with a clean error instead of an unhandled promise rejection; add a last-resort .catch() on main() - publish-npm.yml: scope id-token: write to the publish job (least privilege); widen release-asset verification retries (~90s) to ride out the window where the release event can fire before all assets finish uploading --- .../workflows/{ci.yaml => publish-npm.yml} | 12 +++++++---- bin/volcano.js | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) rename .github/workflows/{ci.yaml => publish-npm.yml} (86%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/publish-npm.yml similarity index 86% rename from .github/workflows/ci.yaml rename to .github/workflows/publish-npm.yml index cba5c38..cd21d52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/publish-npm.yml @@ -11,7 +11,7 @@ name: Publish to npm # Trusted Publisher): # Organization or user: Kong # Repository: volcano-cli -# Workflow filename: ci.yaml (must match this file's name exactly) +# Workflow filename: publish-npm.yml (must match this file's name exactly) on: # Fires when publish-cli.yml publishes the GitHub Release for a version tag. @@ -27,7 +27,6 @@ on: permissions: contents: read - id-token: write # REQUIRED for npm trusted publishing (OIDC) concurrency: group: npm-publish-${{ github.ref_name }} @@ -37,6 +36,9 @@ jobs: publish-npm: name: Publish npm package runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners + permissions: + contents: read + id-token: write # REQUIRED for npm trusted publishing (OIDC); scoped to this job # Only publish stable (non-prerelease) versions to npm. if: >- github.event_name == 'workflow_dispatch' || @@ -83,16 +85,18 @@ jobs: run: | set -euo pipefail base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" + # The release event can fire before publish-cli.yml finishes uploading + # every asset, so retry generously (~90s) to ride out that window. for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do asset="volcano-${target}" if [ "$target" = "windows-amd64" ]; then asset="${asset}.exe" fi echo "Checking ${base}/${asset}" - curl -fsSL --retry 3 --retry-delay 5 -o /dev/null -I "${base}/${asset}" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -o /dev/null -I "${base}/${asset}" done echo "Checking ${base}/SHA256SUMS" - curl -fsSL --retry 3 --retry-delay 5 -o /dev/null -I "${base}/SHA256SUMS" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -o /dev/null -I "${base}/SHA256SUMS" # OIDC trusted publishing: npm detects the id-token and authenticates # automatically. Provenance is generated automatically for public repos. diff --git a/bin/volcano.js b/bin/volcano.js index 34c2f30..f46db53 100644 --- a/bin/volcano.js +++ b/bin/volcano.js @@ -11,16 +11,19 @@ const { spawn } = require('child_process'); const { binaryPath, ensureBinary } = require('../scripts/npm/download.js'); async function resolveBinary() { - const bin = binaryPath(); - if (fs.existsSync(bin)) { - return bin; - } try { + const bin = binaryPath(); + if (fs.existsSync(bin)) { + return bin; + } return await ensureBinary(); } catch (err) { - console.error(`volcano: failed to download the CLI binary: ${err.message}`); + // Covers both download failures and unsupported platforms: binaryPath() -> + // resolveTarget() throws for e.g. Windows arm64, which passes the + // package.json os/cpu gate but has no published binary. + console.error(`volcano: could not obtain the CLI binary: ${err.message}`); console.error( - 'Download it manually from https://github.com/Kong/volcano-cli/releases ' + + 'Install it manually from https://github.com/Kong/volcano-cli/releases ' + 'or re-install the package.' ); process.exit(1); @@ -46,4 +49,8 @@ async function main() { }); } -main(); +main().catch((err) => { + // Last-resort guard so failures never surface as an unhandled rejection. + console.error(`volcano: ${err.message}`); + process.exit(1); +}); From e0f4893f0f8d654ea8856232d67040b48334514a Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Mon, 6 Jul 2026 17:14:22 -0400 Subject: [PATCH 4/6] Harden download hashing and release-asset check (review nits #5, #6) - download.js: hash the fully-written temp file instead of a mid-stream data listener; verifies exactly what landed on disk and avoids flowing-mode subtleties (#5) - publish-npm.yml: verify assets with a ranged GET (first byte) instead of a HEAD request, confirming they are actually downloadable like postinstall does; GitHub honors Range so only 1 byte is fetched (#6) --- .github/workflows/publish-npm.yml | 6 ++++-- scripts/npm/download.js | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index cd21d52..d5ed86f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -93,10 +93,12 @@ jobs: asset="${asset}.exe" fi echo "Checking ${base}/${asset}" - curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -o /dev/null -I "${base}/${asset}" + # Ranged GET (first byte) confirms the asset is actually downloadable + # (what postinstall does), which is more reliable than a HEAD request. + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/${asset}" done echo "Checking ${base}/SHA256SUMS" - curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -o /dev/null -I "${base}/SHA256SUMS" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/SHA256SUMS" # OIDC trusted publishing: npm detects the id-token and authenticates # automatically. Provenance is generated automatically for public repos. diff --git a/scripts/npm/download.js b/scripts/npm/download.js index c1f8077..375eff0 100644 --- a/scripts/npm/download.js +++ b/scripts/npm/download.js @@ -128,22 +128,25 @@ function parseChecksum(manifest, name) { } async function downloadToFile(url, dest) { - const hash = crypto.createHash('sha256'); const tmp = `${dest}.download-${process.pid}`; try { const res = await get(url); - res.on('data', (chunk) => hash.update(chunk)); await streamPipeline(res, fs.createWriteStream(tmp)); + // Hash the fully-written file so we verify exactly what landed on disk + // (also avoids the flowing-mode subtleties of hashing mid-stream). + const hash = crypto.createHash('sha256'); + await streamPipeline(fs.createReadStream(tmp), hash); + const digest = hash.digest('hex'); // rename does not overwrite an existing destination on Windows, so clear it // first (also covers a check-then-write race and force re-downloads). fs.rmSync(dest, { force: true }); fs.renameSync(tmp, dest); + return digest; } catch (err) { // Never leave a partial download behind. fs.rmSync(tmp, { force: true }); throw err; } - return hash.digest('hex'); } // Download the correct binary for the current platform and verify its checksum From b96eac13026944dd15e9ad409371b60324427e41 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 7 Jul 2026 10:44:00 -0400 Subject: [PATCH 5/6] Publish to npm from publish-cli.yml (Option A); add 0.0.0 dev guard Fold npm publishing into publish-cli.yml as a stable-only publish-npm job instead of a standalone release-triggered workflow: - npm's trusted publisher must be the workflow that directly runs npm publish; dispatched/reusable workflows are not supported, and a release: published trigger would not fire for GITHUB_TOKEN-created releases (swkeever) - runs in the tag-push release run, needs publish-github-release so assets are already uploaded (no race); id-token: write scoped to the job - continue-on-error during bootstrap so releases stay green until an npm admin does the manual first publish + configures OIDC (workflow file: publish-cli.yml) - delete standalone .github/workflows/publish-npm.yml Address marckong review (P3): skip download for placeholder version 0.0.0 so maintainers running npm install in a source checkout see a quiet skip instead of a failed-download warning (install.js), and the shim errors cleanly without hitting a nonexistent v0.0.0 release (download.js). --- .github/workflows/publish-cli.yml | 77 +++++++++++++++++++++ .github/workflows/publish-npm.yml | 110 ------------------------------ scripts/npm/download.js | 7 ++ scripts/npm/install.js | 9 +++ 4 files changed, 93 insertions(+), 110 deletions(-) delete mode 100644 .github/workflows/publish-npm.yml diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 8af8db0..4501e75 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -375,3 +375,80 @@ jobs: chmod +x "$TMP_DIR/volcano-linux-amd64" "$TMP_DIR/volcano-linux-amd64" --help "$TMP_DIR/volcano-linux-amd64" --version + + publish-npm: + name: Publish npm package + runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners + needs: [resolve-release, publish-github-release] + # Publish to npm only for stable releases (skip main/nightly prereleases). + # This job runs npm publish directly in the release-triggered run, so npm's + # OIDC trusted publisher must be configured with this workflow's filename + # (publish-cli.yml). Dispatched/reusable workflows are not supported. + if: needs.resolve-release.outputs.prerelease == 'false' + permissions: + contents: read + id-token: write # REQUIRED for npm trusted publishing (OIDC) + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + # Trusted publishing requires npm >= 11.5.1; pin to the npm 11 line for + # reproducibility rather than tracking npm@latest across major versions. + - name: Upgrade npm + run: npm install -g npm@11 + + - name: Resolve npm version + id: npm_version + env: + CLI_VERSION: ${{ needs.resolve-release.outputs.cli_version }} + run: | + set -euo pipefail + VERSION="${CLI_VERSION#v}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish non-stable version to npm: ${CLI_VERSION}" + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Set package version + run: | + npm version "${{ steps.npm_version.outputs.version }}" \ + --no-git-tag-version --allow-same-version + + - name: Verify release assets exist + env: + VERSION: ${{ steps.npm_version.outputs.version }} + run: | + set -euo pipefail + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" + # Ranged GET (first byte) confirms each asset is actually downloadable, + # which is what the package's postinstall does. + for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do + asset="volcano-${target}" + if [ "$target" = "windows-amd64" ]; then + asset="${asset}.exe" + fi + echo "Checking ${base}/${asset}" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/${asset}" + done + echo "Checking ${base}/SHA256SUMS" + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/SHA256SUMS" + + # OIDC trusted publishing: npm detects the id-token and authenticates + # automatically; provenance is generated for public repos. No NPM_TOKEN. + # VOLCANO_SKIP_DOWNLOAD stops the postinstall fetching a binary in CI. + # + # BOOTSTRAP: until an npm admin does the first manual publish and sets the + # OIDC trusted publisher (workflow file: publish-cli.yml), this step fails + # with ENEEDAUTH. continue-on-error keeps the release green meanwhile -- + # REMOVE it once trusted publishing is live so real failures surface. + - name: Publish to npm + continue-on-error: true + env: + VOLCANO_SKIP_DOWNLOAD: "1" + run: npm publish diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index d5ed86f..0000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Publish to npm - -# Publishes the @kong/volcano-cli npm package using npm trusted publishing -# (OIDC) — no long-lived NPM_TOKEN required. -# -# This runs AFTER publish-cli.yml has created the GitHub Release and uploaded -# the signed binaries + SHA256SUMS, because the published npm package downloads -# those assets in its postinstall step. -# -# Trusted publisher setup (one-time, on npmjs.com -> package Settings -> -# Trusted Publisher): -# Organization or user: Kong -# Repository: volcano-cli -# Workflow filename: publish-npm.yml (must match this file's name exactly) - -on: - # Fires when publish-cli.yml publishes the GitHub Release for a version tag. - release: - types: [published] - # Manual re-publish (e.g. if the release existed before this workflow). - workflow_dispatch: - inputs: - version: - description: "Version to publish, without a leading v (e.g. 1.2.3)" - required: true - type: string - -permissions: - contents: read - -concurrency: - group: npm-publish-${{ github.ref_name }} - cancel-in-progress: false - -jobs: - publish-npm: - name: Publish npm package - runs-on: ubuntu-latest # Trusted publishing requires GitHub-hosted runners - permissions: - contents: read - id-token: write # REQUIRED for npm trusted publishing (OIDC); scoped to this job - # Only publish stable (non-prerelease) versions to npm. - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'release' && github.event.release.prerelease == false) - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - package-manager-cache: false - - # Trusted publishing requires npm >= 11.5.1. Pin to the npm 11 line for - # reproducibility rather than tracking npm@latest across major versions. - - name: Upgrade npm - run: npm install -g npm@11 - - - name: Resolve release version - id: version - run: | - set -euo pipefail - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - RAW="${{ github.event.inputs.version }}" - else - RAW="${{ github.event.release.tag_name }}" - fi - VERSION="${RAW#v}" - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Refusing to publish non-stable version to npm: ${RAW}" - echo "Only stable vMAJOR.MINOR.PATCH releases are published to npm." - exit 1 - fi - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - - name: Set package version - run: | - npm version "${{ steps.version.outputs.version }}" \ - --no-git-tag-version --allow-same-version - - - name: Verify release assets exist - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - # The release event can fire before publish-cli.yml finishes uploading - # every asset, so retry generously (~90s) to ride out that window. - for target in linux-amd64 linux-arm64 macos-amd64 macos-arm64 windows-amd64; do - asset="volcano-${target}" - if [ "$target" = "windows-amd64" ]; then - asset="${asset}.exe" - fi - echo "Checking ${base}/${asset}" - # Ranged GET (first byte) confirms the asset is actually downloadable - # (what postinstall does), which is more reliable than a HEAD request. - curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/${asset}" - done - echo "Checking ${base}/SHA256SUMS" - curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors -r 0-0 -o /dev/null "${base}/SHA256SUMS" - - # OIDC trusted publishing: npm detects the id-token and authenticates - # automatically. Provenance is generated automatically for public repos. - # VOLCANO_SKIP_DOWNLOAD prevents the postinstall from fetching a binary - # in CI if any install lifecycle runs. - - name: Publish to npm - env: - VOLCANO_SKIP_DOWNLOAD: "1" - run: npm publish diff --git a/scripts/npm/download.js b/scripts/npm/download.js index 375eff0..8161960 100644 --- a/scripts/npm/download.js +++ b/scripts/npm/download.js @@ -157,6 +157,13 @@ async function ensureBinary({ force = false } = {}) { return dest; } + if (pkg.version === '0.0.0') { + throw new Error( + 'No published binary for development version 0.0.0; build from source ' + + 'with `make build` or install a released version.' + ); + } + const name = assetName(); const tag = releaseTag(); const base = `${RELEASES_BASE}/download/${tag}`; diff --git a/scripts/npm/install.js b/scripts/npm/install.js index b77a771..4bc4ee4 100644 --- a/scripts/npm/install.js +++ b/scripts/npm/install.js @@ -8,6 +8,7 @@ // the launcher shim (bin/volcano.js) downloads the binary on first use. const { ensureBinary, resolveTarget } = require('./download.js'); +const pkg = require('../../package.json'); async function main() { if (process.env.VOLCANO_SKIP_DOWNLOAD === '1') { @@ -15,6 +16,14 @@ async function main() { return; } + // No release exists for the placeholder version, so skip quietly for anyone + // running `npm install` in a source checkout (the publish workflow stamps a + // real version before publishing). + if (pkg.version === '0.0.0') { + console.log('Skipping Volcano CLI download for development version 0.0.0.'); + return; + } + const target = resolveTarget(); const binary = await ensureBinary(); console.log(`Installed Volcano CLI (${target}) -> ${binary}`); From 6dfa87eb8cc776fa518055c26177023720686f14 Mon Sep 17 00:00:00 2001 From: Ted Kim Date: Tue, 7 Jul 2026 13:55:18 -0400 Subject: [PATCH 6/6] Remove no-op package-manager-cache input from setup-node@v4 (review) package-manager-cache is not a v4 input (added in v5), so it was silently ignored. v4 only caches when 'cache' is set, which it is not here, so behavior is unchanged; dropping the dead line. --- .github/workflows/publish-cli.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 4501e75..28aa0fb 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -395,7 +395,6 @@ jobs: with: node-version: "24" registry-url: "https://registry.npmjs.org" - package-manager-cache: false # Trusted publishing requires npm >= 11.5.1; pin to the npm 11 line for # reproducibility rather than tracking npm@latest across major versions.