From 9967a565ce4adb712e40de09d87288c2b57ff31e Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 13 Aug 2026 17:49:59 +0800 Subject: [PATCH 1/2] update npm packages --- .github/workflows/release.yml | 28 +++- .github/workflows/test.yml | 1 - README.md | 17 ++- internal/cli/root.go | 6 +- internal/cli/update_test.go | 6 +- internal/updater/uninstall_test.go | 52 ++++++-- internal/updater/upgrade.go | 29 +++- internal/updater/upgrade_test.go | 9 +- npm/bin/etherscan.js | 41 ++++-- npm/check-tarball-eol.js | 37 +++--- npm/cli-darwin-arm64/package.json | 14 ++ npm/cli-darwin-x64/package.json | 14 ++ npm/cli-linux-arm64/package.json | 14 ++ npm/cli-linux-x64/package.json | 14 ++ npm/cli-win32-arm64/package.json | 14 ++ npm/cli-win32-x64/package.json | 14 ++ npm/platform.js | 16 +++ npm/postinstall.js | 58 -------- npm/prepublish-check.js | 2 +- npm/publish.js | 205 +++++++++++++++++++++++++++-- npm/publish.test.js | 151 ++++++++++++++++++++- package-lock.json | 20 --- package.json | 16 ++- scripts/test-npm.ps1 | 98 ++++---------- scripts/test-npm.sh | 66 ++++------ 25 files changed, 675 insertions(+), 267 deletions(-) create mode 100644 npm/cli-darwin-arm64/package.json create mode 100644 npm/cli-darwin-x64/package.json create mode 100644 npm/cli-linux-arm64/package.json create mode 100644 npm/cli-linux-x64/package.json create mode 100644 npm/cli-win32-arm64/package.json create mode 100644 npm/cli-win32-x64/package.json create mode 100644 npm/platform.js delete mode 100644 npm/postinstall.js delete mode 100644 package-lock.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 072178e..8b43800 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,16 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + - name: Test - run: go test ./... + run: | + go test ./... + scripts/test-npm.sh - name: Vulnerability scan run: | @@ -41,6 +49,16 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + - name: Save npm release assets + uses: actions/upload-artifact@v4 + with: + name: npm-release-assets + path: | + dist/etherscan_*.tar.gz + dist/etherscan_*.zip + dist/checksums.txt + if-no-files-found: error + npm: if: vars.NPM_PUBLISH_ENABLED == 'true' needs: release @@ -62,5 +80,13 @@ jobs: registry-url: https://registry.npmjs.org package-manager-cache: false + - name: Download npm release assets + uses: actions/download-artifact@v5 + with: + name: npm-release-assets + path: dist + - name: Publish npm package run: node npm/publish.js + env: + NPM_PROVENANCE: "true" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 88ffd37..4e9e9b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,7 +87,6 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24" - cache: npm # Runs on every matrix OS. The Windows leg is the one that matters: it packs # from a CRLF checkout, the combination no other job covered. diff --git a/README.md b/README.md index 4a731c7..ed34da6 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Or run it once without keeping a global installation: npx @etherscan-npm/cli version ``` -The npm package downloads the matching native release archive and verifies its SHA-256 checksum during installation. Lifecycle scripts must be enabled. +The npm package selects a platform-specific optional dependency containing the native binary. It does not run installation lifecycle scripts. Installing with `--omit=optional` is not supported. > The package is currently published as `@etherscan-npm/cli` while the `@etherscan` npm scope is being transferred. The `etherscan` command is unchanged. @@ -488,9 +488,20 @@ go build -o etherscan ./cmd/etherscan Installer changes can be checked with `sh scripts/test-install.sh` on macOS/Linux or `./scripts/test-install.ps1` in PowerShell on Windows. -The npm distribution can be checked with `sh scripts/test-npm.sh` on macOS/Linux or `./scripts/test-npm.ps1` in PowerShell. These tests pack and install the package against local fixture release archives; they do not publish to npm. +The npm distribution can be checked with `sh scripts/test-npm.sh` on macOS/Linux or `./scripts/test-npm.ps1` in PowerShell. These tests pack and install the umbrella and current-platform packages with lifecycle scripts disabled; they do not publish to npm. -For the first npm release, publish the GitHub release assets before publishing the package. From an exact release-tag checkout, run `npm version --no-git-tag-version ` followed by `npm publish --access public`. Then configure npm trusted publishing for `etherscan/etherscan-cli` and `.github/workflows/release.yml`, and set the `NPM_PUBLISH_ENABLED` repository variable to `true` for later tagged releases. +The first seven-package npm release requires a one-time bootstrap by an npm administrator with publish access to the `@etherscan-npm` scope. After the matching GitHub release succeeds, check out its exact tag and run: + +```sh +gh release download v1.0.4 --dir dist +npm login +npm whoami +VERSION=1.0.4 node npm/publish.js +``` + +The publisher verifies all six archives against `checksums.txt`, creates the six public platform packages by publishing them first, and publishes `@etherscan-npm/cli` last. It safely skips exact versions that already exist so a partial publication can be retried. Replace `1.0.4` with the actual unused release version if necessary. + +After the bootstrap, configure npm trusted publishing for the umbrella and all six platform packages, targeting `etherscan/etherscan-cli` and `.github/workflows/release.yml`. Set the `NPM_PUBLISH_ENABLED` repository variable to `true`; subsequent tagged releases publish through GitHub Actions with provenance. ## API coverage and support diff --git a/internal/cli/root.go b/internal/cli/root.go index a58c683..6bd5378 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -672,7 +672,7 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command { detectedMethod := updates.DetectMethod() if detectedMethod == updater.MethodNPM { fmt.Fprintln(cmd.OutOrStdout(), "This installation is managed by npm. Run:") - fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan/cli@latest") + fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan-npm/cli@latest") return nil } if method == "" { @@ -680,7 +680,7 @@ func updateCommand(info BuildInfo, updates updateManager) *cobra.Command { } if method == updater.MethodNPM { fmt.Fprintln(cmd.OutOrStdout(), "This installation is managed by npm. Run:") - fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan/cli@latest") + fmt.Fprintln(cmd.OutOrStdout(), " npm install -g @etherscan-npm/cli@latest") return nil } fmt.Fprintf(cmd.OutOrStdout(), "Updating Etherscan CLI %s -> %s using %s...\n", result.Current, result.Latest, method) @@ -724,7 +724,7 @@ func offerUpdate(ctx context.Context, updates updateManager, current string, in method := updates.DetectMethod() if method == updater.MethodNPM { fmt.Fprintln(out, "This installation is managed by npm. Run:") - fmt.Fprintln(out, " npm install -g @etherscan/cli@latest") + fmt.Fprintln(out, " npm install -g @etherscan-npm/cli@latest") return true, nil } fmt.Fprintf(out, "Updating with %s...\n", method) diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index 0a421d4..73a7fe8 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -95,7 +95,7 @@ func TestOfferUpdateChoices(t *testing.T) { if err != nil || !exit || manager.upgradedVersion != "" { t.Fatalf("unexpected result: exit=%v err=%v manager=%+v", exit, err, manager) } - if !strings.Contains(out.String(), "npm install -g @etherscan/cli@latest") { + if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") { t.Fatalf("output = %q, want npm install instruction", out.String()) } }) @@ -135,7 +135,7 @@ func TestUpdateCommandShowsNPMInstruction(t *testing.T) { if manager.upgradedVersion != "" { t.Fatalf("npm update invoked updater: %+v", manager) } - if !strings.Contains(out.String(), "npm install -g @etherscan/cli@latest") { + if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") { t.Fatalf("output = %q, want npm install instruction", out.String()) } } @@ -156,7 +156,7 @@ func TestUpdateCommandCannotForceScriptForNPMInstallation(t *testing.T) { if manager.upgradedVersion != "" { t.Fatalf("npm update invoked forced script updater: %+v", manager) } - if !strings.Contains(out.String(), "npm install -g @etherscan/cli@latest") { + if !strings.Contains(out.String(), "npm install -g @etherscan-npm/cli@latest") { t.Fatalf("output = %q, want npm install instruction", out.String()) } } diff --git a/internal/updater/uninstall_test.go b/internal/updater/uninstall_test.go index c38246f..6239d84 100644 --- a/internal/updater/uninstall_test.go +++ b/internal/updater/uninstall_test.go @@ -36,22 +36,25 @@ func TestNPMPackageNameWithoutMarkerFailsClosed(t *testing.T) { } } +type npmOwnershipCase struct { + name string + executable string + want string + ok bool +} + func TestNPMPackageFromExecutable(t *testing.T) { t.Setenv("ETHERSCAN_INSTALL_METHOD", "") - tests := []struct { - name string - executable string - want string - ok bool - }{ + tests := []npmOwnershipCase{ + // Legacy <=1.0.3 layout: the binary was vendored inside the umbrella. { - name: "canonical package", + name: "canonical package legacy vendor layout", executable: filepath.Join("C:", "Users", "test", "node_modules", "@etherscan", "cli", "vendor", "etherscan.exe"), want: NPMCanonicalPackage, ok: true, }, { - name: "transitional package", + name: "transitional package legacy vendor layout", executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan-npm", "cli", "vendor", "etherscan"), want: NPMTransitionalPackage, ok: true, @@ -60,12 +63,45 @@ func TestNPMPackageFromExecutable(t *testing.T) { name: "lookalike package", executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan", "cli-malicious", "vendor", "etherscan"), }, + { + name: "lookalike platform package", + executable: filepath.Join("usr", "local", "lib", "node_modules", "@etherscan-npm", "cli-linux-x64-malicious", "etherscan"), + }, + { + name: "unrelated scoped package", + executable: filepath.Join("usr", "local", "lib", "node_modules", "@other", "cli", "etherscan"), + }, { name: "manual install", executable: filepath.Join("usr", "local", "bin", "etherscan"), }, } + // Since 1.0.4 the binary ships in a platform package beside the umbrella. Every + // one of them must classify as npm-owned and report the umbrella, never itself. + for _, scope := range []struct { + directory string + umbrella string + }{ + {directory: "@etherscan", umbrella: NPMCanonicalPackage}, + {directory: "@etherscan-npm", umbrella: NPMTransitionalPackage}, + } { + for _, platform := range []string{ + "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64", + } { + binary := "etherscan" + if strings.HasPrefix(platform, "win32") { + binary = "etherscan.exe" + } + tests = append(tests, npmOwnershipCase{ + name: scope.directory + "/cli-" + platform, + executable: filepath.Join("usr", "local", "lib", "node_modules", scope.directory, "cli-"+platform, binary), + want: scope.umbrella, + ok: true, + }) + } + } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, ok := npmPackageFromExecutable(test.executable) diff --git a/internal/updater/upgrade.go b/internal/updater/upgrade.go index 11fe370..3643f17 100644 --- a/internal/updater/upgrade.go +++ b/internal/updater/upgrade.go @@ -25,6 +25,20 @@ const ( NPMWrapperPIDEnv = "ETHERSCAN_NPM_WRAPPER_PID" ) +// npmPlatformSuffixes are the per-platform package names published alongside each +// umbrella. Since 1.0.4 the umbrella ships only the launcher, so a binary npm owns +// lives in one of these siblings. The empty suffix keeps <=1.0.3 installations, +// which vendored the binary inside the umbrella itself, detectable. +var npmPlatformSuffixes = []string{ + "", + "-darwin-arm64", + "-darwin-x64", + "-linux-arm64", + "-linux-x64", + "-win32-arm64", + "-win32-x64", +} + var runtimeGOOS = runtime.GOOS type commandRunner func(context.Context, string, []string, io.Writer, io.Writer, bool) error @@ -81,11 +95,18 @@ func validateNPMPackageName(name string) (string, error) { } } +// npmPackageFromExecutable reports the umbrella package that owns executable. A +// platform package resolves to its umbrella so uninstall never targets a sibling, +// which npm removes with the umbrella anyway. Both surrounding slashes are +// required, which is what rejects lookalikes: "@etherscan/cli-malicious" and +// "@etherscan-npm/cli-linux-x64-malicious" cannot match a bare suffix. func npmPackageFromExecutable(executable string) (string, bool) { normalized := strings.ToLower(filepath.ToSlash(executable)) - for _, candidate := range []string{NPMCanonicalPackage, NPMTransitionalPackage} { - if strings.Contains(normalized, "/node_modules/"+candidate+"/") { - return candidate, true + for _, umbrella := range []string{NPMCanonicalPackage, NPMTransitionalPackage} { + for _, suffix := range npmPlatformSuffixes { + if strings.Contains(normalized, "/node_modules/"+umbrella+suffix+"/") { + return umbrella, true + } } } return "", false @@ -106,7 +127,7 @@ func (s *Service) Upgrade(ctx context.Context, method, version string, stdout, s return false, fmt.Errorf("unsupported update method %q (use homebrew, npm, or script)", method) } if method == MethodNPM { - return false, fmt.Errorf("npm manages this installation; run npm install -g @etherscan/cli@latest") + return false, fmt.Errorf("npm manages this installation; run npm install -g %s@latest", NPMTransitionalPackage) } if method == MethodHomebrew { if _, err := s.lookPath()("brew"); err != nil { diff --git a/internal/updater/upgrade_test.go b/internal/updater/upgrade_test.go index 9e3a3a1..a0beea3 100644 --- a/internal/updater/upgrade_test.go +++ b/internal/updater/upgrade_test.go @@ -35,6 +35,13 @@ func TestDetectMethod(t *testing.T) { if got := service.DetectMethod(); got != MethodNPM { t.Fatalf("DetectMethod() for transitional scope = %q, want %q", got, MethodNPM) } + // The current layout: the binary lives in a platform package, not the umbrella. + service.Executable = func() (string, error) { + return filepath.Join(string(filepath.Separator), "usr", "lib", "node_modules", "@etherscan-npm", "cli-linux-x64", "etherscan"), nil + } + if got := service.DetectMethod(); got != MethodNPM { + t.Fatalf("DetectMethod() for platform package = %q, want %q", got, MethodNPM) + } t.Setenv("ETHERSCAN_INSTALL_METHOD", MethodNPM) service.Executable = func() (string, error) { return filepath.Join(t.TempDir(), "etherscan"), nil } if got := service.DetectMethod(); got != MethodNPM { @@ -45,7 +52,7 @@ func TestDetectMethod(t *testing.T) { func TestNPMUpgradeReturnsPackageManagerInstruction(t *testing.T) { service := NewService() _, err := service.Upgrade(context.Background(), MethodNPM, "1.2.0", &bytes.Buffer{}, &bytes.Buffer{}) - if err == nil || !strings.Contains(err.Error(), "npm install -g @etherscan/cli@latest") { + if err == nil || !strings.Contains(err.Error(), "npm install -g @etherscan-npm/cli@latest") { t.Fatalf("Upgrade() error = %v, want npm install instruction", err) } } diff --git a/npm/bin/etherscan.js b/npm/bin/etherscan.js index 5da57ee..e5caea7 100644 --- a/npm/bin/etherscan.js +++ b/npm/bin/etherscan.js @@ -3,22 +3,45 @@ "use strict"; const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const packageInfo = require("../../package.json"); +const { PLATFORMS, platformPackage } = require("../platform"); const packageRoot = path.resolve(__dirname, "..", ".."); -const executable = path.join( - packageRoot, - "vendor", - process.platform === "win32" ? "etherscan.exe" : "etherscan", -); + +// Resolve these once so the package lookup and executable name cannot disagree. +const platform = os.platform(); +const arch = os.arch(); +const binaryName = platform === "win32" ? "etherscan.exe" : "etherscan"; +const reinstallHint = `Reinstall ${packageInfo.name} without --omit=optional.`; + +function getExecutable() { + const packageName = platformPackage(platform, arch); + if (!packageName) { + console.error( + `Etherscan CLI does not support ${platform} ${arch}. ` + + `Supported platforms: ${Object.keys(PLATFORMS).join(", ")}.`, + ); + process.exit(1); + } + + try { + const manifest = require.resolve(`${packageName}/package.json`, { + paths: [packageRoot], + }); + return path.join(path.dirname(manifest), binaryName); + } catch { + console.error(`The platform package ${packageName} is not installed. ${reinstallHint}`); + process.exit(1); + } +} + +const executable = getExecutable(); if (!fs.existsSync(executable)) { - console.error( - "Etherscan CLI is not installed in this npm package. " + - "Reinstall @etherscan/cli without --ignore-scripts.", - ); + console.error(`The platform package executable is missing: ${executable}. ${reinstallHint}`); process.exit(1); } diff --git a/npm/check-tarball-eol.js b/npm/check-tarball-eol.js index be6df5b..385982f 100644 --- a/npm/check-tarball-eol.js +++ b/npm/check-tarball-eol.js @@ -1,11 +1,8 @@ "use strict"; -// `npm pack` copies the working tree, so a checkout that rewrote the LF blobs to -// CRLF produces a tarball whose scripts/install.sh cannot be run by /bin/sh. -// That is how @etherscan-npm/cli@1.0.1 shipped an installer that failed on Linux -// and macOS: every CI job either packed on a platform that checks out LF, or -// packed on Windows and then installed through scripts/install.ps1, so the broken -// combination was never exercised. Pack the real tarball and inspect it here. +// Pack the real tarball and inspect every shipped text file. This retains the +// line-ending regression gate that caught the broken 1.0.1 Linux/macOS package, +// while the package itself no longer ships or executes installer scripts. // // This walks everything the tarball actually contains rather than re-reading // package.json "files". The tarball is the ground truth of what ships, and @@ -19,19 +16,19 @@ const { checkFileLineEndings } = require("./prepublish-check"); const repositoryRoot = path.resolve(__dirname, ".."); -function quote(value) { - return `"${value}"`; -} - -// A single command string with shell:true keeps this portable across cmd.exe and -// sh without tripping the DEP0190 warning that args plus shell:true now raises. -function run(command, cwd) { - const result = spawnSync(command, { cwd, encoding: "utf8", shell: true }); +function run(command, args, cwd) { + let executable = command; + let commandArgs = args; + if (command === "npm" && process.env.npm_execpath) { + executable = process.execPath; + commandArgs = [process.env.npm_execpath, ...args]; + } + const result = spawnSync(executable, commandArgs, { cwd, encoding: "utf8" }); if (result.error) { throw result.error; } if (result.status !== 0) { - throw new Error(`${command} failed with status ${result.status}\n${result.stderr || ""}`); + throw new Error(`${executable} ${commandArgs.join(" ")} failed with status ${result.status}\n${result.stderr || ""}`); } return result.stdout; } @@ -66,7 +63,7 @@ function checkTree(rootDir) { function main() { const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "etherscan-tarball-eol-")); try { - const stdout = run(`npm pack --pack-destination ${quote(workDir)}`, repositoryRoot); + const stdout = run("npm", ["pack", "--pack-destination", workDir], repositoryRoot); const tarballName = stdout.trim().split(/\r?\n/).pop(); const tarball = path.join(workDir, tarballName); if (!fs.existsSync(tarball)) { @@ -75,13 +72,13 @@ function main() { // Extract by relative name from workDir: GNU tar treats the colon in an // absolute Windows path as a remote host specification. - run(`tar -xzf ${quote(tarballName)}`, workDir); + run("tar", ["-xzf", tarballName], workDir); const packageDir = path.join(workDir, "package"); // Guard against a silent pass if the tarball layout ever changes. - const installer = path.join(packageDir, "scripts", "install.sh"); - if (!fs.existsSync(installer)) { - throw new Error(`the packed tarball does not contain scripts/install.sh (looked in ${packageDir})`); + const launcher = path.join(packageDir, "npm", "bin", "etherscan.js"); + if (!fs.existsSync(launcher)) { + throw new Error(`the packed tarball does not contain npm/bin/etherscan.js (looked in ${packageDir})`); } const checked = checkTree(packageDir); diff --git a/npm/cli-darwin-arm64/package.json b/npm/cli-darwin-arm64/package.json new file mode 100644 index 0000000..15752be --- /dev/null +++ b/npm/cli-darwin-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-darwin-arm64", + "version": "0.0.0-development", + "description": "Etherscan CLI for macOS ARM64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["etherscan"], + "publishConfig": { "access": "public" } +} diff --git a/npm/cli-darwin-x64/package.json b/npm/cli-darwin-x64/package.json new file mode 100644 index 0000000..9bd543c --- /dev/null +++ b/npm/cli-darwin-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-darwin-x64", + "version": "0.0.0-development", + "description": "Etherscan CLI for macOS x64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["darwin"], + "cpu": ["x64"], + "files": ["etherscan"], + "publishConfig": { "access": "public" } +} diff --git a/npm/cli-linux-arm64/package.json b/npm/cli-linux-arm64/package.json new file mode 100644 index 0000000..7745781 --- /dev/null +++ b/npm/cli-linux-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-linux-arm64", + "version": "0.0.0-development", + "description": "Etherscan CLI for Linux ARM64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["linux"], + "cpu": ["arm64"], + "files": ["etherscan"], + "publishConfig": { "access": "public" } +} diff --git a/npm/cli-linux-x64/package.json b/npm/cli-linux-x64/package.json new file mode 100644 index 0000000..9dc5266 --- /dev/null +++ b/npm/cli-linux-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-linux-x64", + "version": "0.0.0-development", + "description": "Etherscan CLI for Linux x64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["linux"], + "cpu": ["x64"], + "files": ["etherscan"], + "publishConfig": { "access": "public" } +} diff --git a/npm/cli-win32-arm64/package.json b/npm/cli-win32-arm64/package.json new file mode 100644 index 0000000..c6c0648 --- /dev/null +++ b/npm/cli-win32-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-win32-arm64", + "version": "0.0.0-development", + "description": "Etherscan CLI for Windows ARM64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["win32"], + "cpu": ["arm64"], + "files": ["etherscan.exe"], + "publishConfig": { "access": "public" } +} diff --git a/npm/cli-win32-x64/package.json b/npm/cli-win32-x64/package.json new file mode 100644 index 0000000..dc304c4 --- /dev/null +++ b/npm/cli-win32-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@etherscan-npm/cli-win32-x64", + "version": "0.0.0-development", + "description": "Etherscan CLI for Windows x64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/etherscan/etherscan-cli.git" + }, + "os": ["win32"], + "cpu": ["x64"], + "files": ["etherscan.exe"], + "publishConfig": { "access": "public" } +} diff --git a/npm/platform.js b/npm/platform.js new file mode 100644 index 0000000..4f7c8ca --- /dev/null +++ b/npm/platform.js @@ -0,0 +1,16 @@ +"use strict"; + +const PLATFORMS = Object.freeze({ + "darwin arm64": "@etherscan-npm/cli-darwin-arm64", + "darwin x64": "@etherscan-npm/cli-darwin-x64", + "linux arm64": "@etherscan-npm/cli-linux-arm64", + "linux x64": "@etherscan-npm/cli-linux-x64", + "win32 arm64": "@etherscan-npm/cli-win32-arm64", + "win32 x64": "@etherscan-npm/cli-win32-x64", +}); + +function platformPackage(platform, arch) { + return PLATFORMS[`${platform} ${arch}`] || null; +} + +module.exports = { PLATFORMS, platformPackage }; diff --git a/npm/postinstall.js b/npm/postinstall.js deleted file mode 100644 index 06d4fb5..0000000 --- a/npm/postinstall.js +++ /dev/null @@ -1,58 +0,0 @@ -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const packageInfo = require("../package.json"); - -const packageRoot = path.resolve(__dirname, ".."); -const installDir = path.join(packageRoot, "vendor"); -const version = `v${packageInfo.version}`; - -let command; -let args; -if (process.platform === "win32") { - command = "powershell.exe"; - args = [ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - path.join(packageRoot, "scripts", "install.ps1"), - "-Version", - version, - "-InstallDir", - installDir, - "-NoPathUpdate", - ]; -} else { - command = "sh"; - args = [ - path.join(packageRoot, "scripts", "install.sh"), - "--version", - version, - "--install-dir", - installDir, - "--no-path-update", - ]; -} - -const result = spawnSync(command, args, { stdio: "inherit", env: process.env }); -if (result.error) { - console.error(`Unable to run the Etherscan CLI installer: ${result.error.message}`); - process.exit(1); -} -if (result.status !== 0) { - process.exit(result.status === null ? 1 : result.status); -} - -const executable = path.join( - installDir, - process.platform === "win32" ? "etherscan.exe" : "etherscan", -); -if (!fs.existsSync(executable)) { - console.error(`The installer did not create ${executable}.`); - process.exit(1); -} diff --git a/npm/prepublish-check.js b/npm/prepublish-check.js index d3d664e..6b0038a 100644 --- a/npm/prepublish-check.js +++ b/npm/prepublish-check.js @@ -75,7 +75,7 @@ if (require.main === module) { try { main(); } catch (error) { - console.error(`Refusing to publish @etherscan/cli: ${error.message}`); + console.error(`Refusing to publish @etherscan-npm/cli: ${error.message}`); process.exit(1); } } diff --git a/npm/publish.js b/npm/publish.js index fa6d098..befd276 100644 --- a/npm/publish.js +++ b/npm/publish.js @@ -1,12 +1,26 @@ "use strict"; +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { spawnSync } = require("node:child_process"); +const repositoryRoot = path.resolve(__dirname, ".."); const numericIdentifier = "(?:0|[1-9]\\d*)"; const releasePattern = new RegExp( `^v(${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?)$`, ); +const platforms = Object.freeze([ + { packageDir: "cli-darwin-arm64", os: "darwin", arch: "arm64", binary: "etherscan", extension: "tar.gz" }, + { packageDir: "cli-darwin-x64", os: "darwin", arch: "amd64", binary: "etherscan", extension: "tar.gz" }, + { packageDir: "cli-linux-arm64", os: "linux", arch: "arm64", binary: "etherscan", extension: "tar.gz" }, + { packageDir: "cli-linux-x64", os: "linux", arch: "amd64", binary: "etherscan", extension: "tar.gz" }, + { packageDir: "cli-win32-arm64", os: "windows", arch: "arm64", binary: "etherscan.exe", extension: "zip" }, + { packageDir: "cli-win32-x64", os: "windows", arch: "amd64", binary: "etherscan.exe", extension: "zip" }, +]); + function parseReleaseTag(tag) { const match = releasePattern.exec(tag || ""); if (!match) { @@ -18,34 +32,201 @@ function parseReleaseTag(tag) { }; } -function run(command, args) { - const result = spawnSync(command, args, { stdio: "inherit", shell: process.platform === "win32" }); +function run(command, args, options = {}) { + const executable = process.platform === "win32" && command === "npm" ? "npm.cmd" : command; + const result = spawnSync(executable, args, { + cwd: options.cwd || repositoryRoot, + encoding: "utf8", + stdio: options.inherit ? "inherit" : "pipe", + }); if (result.error) { throw result.error; } if (result.status !== 0) { - process.exit(result.status === null ? 1 : result.status); + const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(`${executable} ${args.join(" ")} failed${details ? `:\n${details}` : ""}`); + } + return result.stdout || ""; +} + +function sha256(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function readChecksums(checksumPath) { + const checksums = new Map(); + for (const line of fs.readFileSync(checksumPath, "utf8").split(/\r?\n/)) { + const match = /^([0-9a-fA-F]{64})\s+\*?(.+)$/.exec(line.trim()); + if (match) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +function writeManifest(sourcePath, destinationPath, version, dependencyVersion = "") { + const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + manifest.version = version; + if (dependencyVersion && manifest.optionalDependencies) { + for (const name of Object.keys(manifest.optionalDependencies)) { + manifest.optionalDependencies[name] = dependencyVersion; + } + } + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +function extractBinary(archivePath, platform, destination) { + const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "etherscan-npm-extract-")); + try { + let entries; + if (platform.extension === "zip") { + const listCommand = process.platform === "win32" ? ["tar", ["-tf", archivePath]] : ["unzip", ["-Z1", archivePath]]; + entries = run(listCommand[0], listCommand[1]).split(/\r?\n/).filter(Boolean); + if (entries.filter((entry) => entry === platform.binary).length !== 1) { + throw new Error(`${path.basename(archivePath)} must contain exactly one root-level ${platform.binary}`); + } + if (process.platform === "win32") { + run("tar", ["-xf", archivePath, "-C", extractDir, platform.binary]); + } else { + run("unzip", ["-q", archivePath, platform.binary, "-d", extractDir]); + } + } else { + entries = run("tar", ["-tzf", archivePath]).split(/\r?\n/).filter(Boolean); + if (entries.filter((entry) => entry === platform.binary).length !== 1) { + throw new Error(`${path.basename(archivePath)} must contain exactly one root-level ${platform.binary}`); + } + run("tar", ["-xzf", archivePath, "-C", extractDir, platform.binary]); + } + fs.copyFileSync(path.join(extractDir, platform.binary), destination); + if (platform.extension !== "zip") { + fs.chmodSync(destination, 0o755); + } + } finally { + fs.rmSync(extractDir, { recursive: true, force: true }); + } +} + +function preparePackages(version, distDir, stageRoot) { + const checksumPath = path.join(distDir, "checksums.txt"); + if (!fs.existsSync(checksumPath)) { + throw new Error(`release checksums not found: ${checksumPath}`); + } + const checksums = readChecksums(checksumPath); + const prepared = []; + + for (const platform of platforms) { + const archiveName = `etherscan_${version}_${platform.os}_${platform.arch}.${platform.extension}`; + const archivePath = path.join(distDir, archiveName); + if (!fs.existsSync(archivePath)) { + throw new Error(`release archive not found: ${archivePath}`); + } + const expected = checksums.get(archiveName); + if (!expected) { + throw new Error(`no checksum was published for ${archiveName}`); + } + const actual = sha256(archivePath); + if (actual !== expected) { + throw new Error(`checksum verification failed for ${archiveName}`); + } + + const sourceDir = path.join(repositoryRoot, "npm", platform.packageDir); + const packageDir = path.join(stageRoot, platform.packageDir); + fs.mkdirSync(packageDir, { recursive: true }); + const manifest = writeManifest( + path.join(sourceDir, "package.json"), + path.join(packageDir, "package.json"), + version, + ); + fs.copyFileSync(path.join(repositoryRoot, "LICENSE"), path.join(packageDir, "LICENSE")); + extractBinary(archivePath, platform, path.join(packageDir, platform.binary)); + prepared.push({ name: manifest.name, directory: packageDir }); + } + + const umbrellaDir = path.join(stageRoot, "cli"); + const umbrellaManifest = writeManifest( + path.join(repositoryRoot, "package.json"), + path.join(umbrellaDir, "package.json"), + version, + version, + ); + for (const relative of [ + "LICENSE", + "README.md", + "npm/bin/etherscan.js", + "npm/platform.js", + "npm/prepublish-check.js", + ]) { + const source = path.join(repositoryRoot, relative); + const destination = path.join(umbrellaDir, relative); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + } + prepared.push({ name: umbrellaManifest.name, directory: umbrellaDir }); + return prepared; +} + +function isPublished(name, version) { + const executable = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(executable, ["view", `${name}@${version}`, "version", "--json"], { + encoding: "utf8", + }); + if (result.status === 0) { + return true; + } + const output = `${result.stdout || ""}\n${result.stderr || ""}`; + if (/E404|404 Not Found/.test(output)) { + return false; + } + throw new Error(`could not query ${name}@${version}: ${output.trim()}`); +} + +function publishPackages(packages, release) { + for (const pkg of packages) { + run("npm", ["pack", "--dry-run", "--json", pkg.directory]); + } + for (const pkg of packages) { + if (isPublished(pkg.name, release.version)) { + console.log(`${pkg.name}@${release.version} is already published; skipping.`); + continue; + } + const args = ["publish", pkg.directory, "--access", "public"]; + if (release.distTag !== "latest") { + args.push("--tag", release.distTag); + } + if (process.env.NPM_PROVENANCE === "true") { + args.push("--provenance"); + } + run("npm", args, { inherit: true }); } } function main() { - const release = parseReleaseTag(process.env.GITHUB_REF_NAME); - run("npm", ["version", "--no-git-tag-version", release.version]); - run("npm", ["pack", "--dry-run"]); - const publishArgs = ["publish", "--access", "public"]; - if (release.distTag !== "latest") { - publishArgs.push("--tag", release.distTag); + const tag = process.env.GITHUB_REF_NAME || (process.env.VERSION ? `v${process.env.VERSION}` : ""); + const release = parseReleaseTag(tag); + const distDir = path.resolve(process.env.DIST_DIR || path.join(repositoryRoot, "dist")); + const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "etherscan-npm-publish-")); + try { + const packages = preparePackages(release.version, distDir, stageRoot); + publishPackages(packages, release); + } finally { + fs.rmSync(stageRoot, { recursive: true, force: true }); } - run("npm", publishArgs); } -module.exports = { parseReleaseTag }; +module.exports = { + parseReleaseTag, + platforms, + readChecksums, + preparePackages, +}; if (require.main === module) { try { main(); } catch (error) { - console.error(error.message); + console.error(`npm publish failed: ${error.message}`); process.exit(1); } } diff --git a/npm/publish.test.js b/npm/publish.test.js index b8cf9c8..87a52f9 100644 --- a/npm/publish.test.js +++ b/npm/publish.test.js @@ -1,10 +1,13 @@ "use strict"; const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { parseReleaseTag } = require("./publish"); +const { spawnSync } = require("node:child_process"); +const { parseReleaseTag, platforms, preparePackages } = require("./publish"); +const { PLATFORMS, platformPackage } = require("./platform"); const { checkVersion, checkLineEndings } = require("./prepublish-check"); const { listFiles, checkTree } = require("./check-tarball-eol"); @@ -36,6 +39,63 @@ test("invalid release tags are rejected", () => { } }); +test("all release targets have npm platform packages", () => { + assert.equal(platforms.length, 6); + assert.deepEqual( + platforms.map((platform) => platform.packageDir).sort(), + [ + "cli-darwin-arm64", + "cli-darwin-x64", + "cli-linux-arm64", + "cli-linux-x64", + "cli-win32-arm64", + "cli-win32-x64", + ], + ); +}); + +test("launcher maps every supported Node platform and architecture", () => { + assert.equal(platformPackage("darwin", "arm64"), "@etherscan-npm/cli-darwin-arm64"); + assert.equal(platformPackage("darwin", "x64"), "@etherscan-npm/cli-darwin-x64"); + assert.equal(platformPackage("linux", "arm64"), "@etherscan-npm/cli-linux-arm64"); + assert.equal(platformPackage("linux", "x64"), "@etherscan-npm/cli-linux-x64"); + assert.equal(platformPackage("win32", "arm64"), "@etherscan-npm/cli-win32-arm64"); + assert.equal(platformPackage("win32", "x64"), "@etherscan-npm/cli-win32-x64"); + assert.equal(platformPackage("freebsd", "x64"), null); + assert.equal(Object.keys(PLATFORMS).length, 6); +}); + +// The mapping table above is pure, so it cannot prove the launcher acts on a nil +// lookup. Run the real launcher to cover the branch that the retired postinstall +// suite covered with ETHERSCAN_INSTALL_TEST_ARCH. +test("the launcher exits nonzero and names supported platforms on an unsupported one", () => { + const launcher = path.join(__dirname, "bin", "etherscan.js"); + const script = [ + 'const os = require("node:os");', + 'os.platform = () => "freebsd";', + 'os.arch = () => "x64";', + `require(${JSON.stringify(launcher)});`, + ].join("\n"); + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /does not support freebsd x64/); + assert.match(result.stderr, /Supported platforms: darwin arm64/); +}); + +test("launcher reinstall guidance names the umbrella package it ships in", () => { + const packageInfo = require("../package.json"); + const source = fs.readFileSync(path.join(__dirname, "bin", "etherscan.js"), "utf8"); + // A hardcoded name here is how 1.0.3 shipped a message naming a package that + // appeared nowhere in the source. + assert.ok( + !source.includes(`Reinstall ${packageInfo.name}`), + "the reinstall hint must be derived from packageInfo.name, not hardcoded", + ); + assert.match(source, /Reinstall \$\{packageInfo\.name\} without --omit=optional\./); +}); + test("the development placeholder version cannot be published", () => { assert.throws(() => checkVersion("0.0.0-development"), /placeholder/); }); @@ -137,11 +197,94 @@ test("the tree walk refuses an empty tree instead of passing", () => { assert.throws(() => checkTree(dir), /no files found/); }); -test("every file the package ships is checked by default", () => { +test("umbrella package ships only the launcher and documentation", () => { const packageInfo = require("../package.json"); - for (const entry of ["scripts/install.sh", "scripts/install.ps1", "npm/postinstall.js"]) { + assert.equal(packageInfo.name, "@etherscan-npm/cli"); + assert.equal(packageInfo.scripts.postinstall, undefined); + for (const entry of ["npm/bin/etherscan.js", "npm/platform.js", "README.md", "LICENSE"]) { assert.ok(packageInfo.files.includes(entry), `${entry} must stay in package.json files`); } - // The repository working tree must already satisfy the gate. + assert.equal(Object.keys(packageInfo.optionalDependencies).length, 6); + for (const name of Object.values(PLATFORMS)) { + assert.equal(packageInfo.optionalDependencies[name], packageInfo.version); + } checkLineEndings(path.resolve(__dirname, "..")); }); + +test("platform manifests enforce the intended os and cpu", () => { + for (const [key, name] of Object.entries(PLATFORMS)) { + const [expectedOS, expectedCPU] = key.split(" "); + const directory = name.replace("@etherscan-npm/", ""); + const manifest = require(path.join(__dirname, directory, "package.json")); + assert.equal(manifest.name, name); + assert.deepEqual(manifest.os, [expectedOS]); + assert.deepEqual(manifest.cpu, [expectedCPU]); + assert.equal(manifest.publishConfig.access, "public"); + assert.equal(manifest.version, require("../package.json").version); + } +}); + +test("publisher verifies and stages all six release archives before the umbrella", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "etherscan-publish-fixture-")); + const dist = path.join(root, "dist"); + const source = path.join(root, "source"); + const stage = path.join(root, "stage"); + fs.mkdirSync(dist); + fs.mkdirSync(source); + const checksums = []; + + const runArchive = (command, args) => { + const result = spawnSync(command, args, { encoding: "utf8" }); + if (result.error || result.status !== 0) { + throw result.error || new Error(`${command} failed: ${result.stderr}`); + } + }; + + try { + for (const platform of platforms) { + const binary = path.join(source, platform.binary); + fs.writeFileSync(binary, `${platform.packageDir}\n`); + const archiveName = `etherscan_1.2.3_${platform.os}_${platform.arch}.${platform.extension}`; + const archive = path.join(dist, archiveName); + if (platform.extension === "zip") { + if (process.platform === "win32") { + runArchive("tar", ["-a", "-cf", archive, "-C", source, platform.binary]); + } else { + runArchive("zip", ["-jq", archive, binary]); + } + } else { + runArchive("tar", ["-czf", archive, "-C", source, platform.binary]); + } + const hash = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex"); + checksums.push(`${hash} ${archiveName}`); + fs.rmSync(binary); + } + fs.writeFileSync(path.join(dist, "checksums.txt"), `${checksums.join("\n")}\n`); + + const prepared = preparePackages("1.2.3", dist, stage); + assert.equal(prepared.length, 7); + assert.equal(prepared.at(-1).name, "@etherscan-npm/cli"); + for (const pkg of prepared) { + const manifest = JSON.parse(fs.readFileSync(path.join(pkg.directory, "package.json"), "utf8")); + assert.equal(manifest.version, "1.2.3"); + } + const umbrella = JSON.parse( + fs.readFileSync(path.join(prepared.at(-1).directory, "package.json"), "utf8"), + ); + for (const dependencyVersion of Object.values(umbrella.optionalDependencies)) { + assert.equal(dependencyVersion, "1.2.3"); + } + + const firstArchive = `etherscan_1.2.3_${platforms[0].os}_${platforms[0].arch}.${platforms[0].extension}`; + fs.writeFileSync( + path.join(dist, "checksums.txt"), + `${"0".repeat(64)} ${firstArchive}\n${checksums.slice(1).join("\n")}\n`, + ); + assert.throws( + () => preparePackages("1.2.3", dist, path.join(root, "invalid-stage")), + /checksum verification failed/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index bac5f9f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@etherscan/cli", - "version": "0.0.0-development", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@etherscan/cli", - "version": "0.0.0-development", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "etherscan": "npm/bin/etherscan.js" - }, - "engines": { - "node": ">=18" - } - } - } -} diff --git a/package.json b/package.json index 11710c0..ad723a8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@etherscan/cli", + "name": "@etherscan-npm/cli", "version": "0.0.0-development", "description": "Command-line client and interactive explorer for the Etherscan V2 API", "license": "MIT", @@ -16,7 +16,6 @@ "etherscan": "npm/bin/etherscan.js" }, "scripts": { - "postinstall": "node npm/postinstall.js", "prepublishOnly": "node npm/prepublish-check.js", "test:package": "npm pack --dry-run", "test:release": "node npm/publish.test.js", @@ -24,13 +23,18 @@ }, "files": [ "npm/bin/etherscan.js", - "npm/postinstall.js", - "npm/prepublish-check.js", - "scripts/install.sh", - "scripts/install.ps1", + "npm/platform.js", "README.md", "LICENSE" ], + "optionalDependencies": { + "@etherscan-npm/cli-darwin-arm64": "0.0.0-development", + "@etherscan-npm/cli-darwin-x64": "0.0.0-development", + "@etherscan-npm/cli-linux-arm64": "0.0.0-development", + "@etherscan-npm/cli-linux-x64": "0.0.0-development", + "@etherscan-npm/cli-win32-arm64": "0.0.0-development", + "@etherscan-npm/cli-win32-x64": "0.0.0-development" + }, "engines": { "node": ">=18" }, diff --git a/scripts/test-npm.ps1 b/scripts/test-npm.ps1 index 6f5f4ba..a4717ad 100644 --- a/scripts/test-npm.ps1 +++ b/scripts/test-npm.ps1 @@ -3,30 +3,23 @@ $ErrorActionPreference = "Stop" $repositoryRoot = Split-Path -Parent $PSScriptRoot $version = "0.0.0-development" $tempDirectory = Join-Path ([IO.Path]::GetTempPath()) "etherscan-npm-test-$PID-$([Guid]::NewGuid().ToString('N'))" -$fixtureDirectory = Join-Path $tempDirectory "fixtures" $bundleDirectory = Join-Path $tempDirectory "bundle" $prefixDirectory = Join-Path $tempDirectory "global prefix" -$ignoredPrefix = Join-Path $tempDirectory "ignored prefix" -$invalidPrefix = Join-Path $tempDirectory "invalid prefix" +$missingPrefix = Join-Path $tempDirectory "missing prefix" $processorArchitecture = if ([string]::IsNullOrWhiteSpace($env:PROCESSOR_ARCHITEW6432)) { $env:PROCESSOR_ARCHITECTURE } else { $env:PROCESSOR_ARCHITEW6432 } -$architecture = if ($processorArchitecture -match '^(ARM64|aarch64)$') { "arm64" } else { "amd64" } -$archiveName = "etherscan_${version}_windows_$architecture.zip" -$archivePath = Join-Path $fixtureDirectory $archiveName -$checksumPath = Join-Path $fixtureDirectory "checksums.txt" -$previousDownloadBase = $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL +$npmArchitecture = if ($processorArchitecture -match '^(ARM64|aarch64)$') { "arm64" } else { "x64" } +$platformName = "cli-win32-$npmArchitecture" +$platformStage = Join-Path $tempDirectory $platformName $previousNpmCache = $env:npm_config_cache $previousGoCache = $env:GOCACHE function Invoke-Checked { - param( - [string]$Command, - [string[]]$Arguments - ) + param([string]$Command, [string[]]$Arguments) & $Command @Arguments if ($LASTEXITCODE -ne 0) { throw "$Command failed with exit code $LASTEXITCODE" @@ -34,46 +27,40 @@ function Invoke-Checked { } try { - New-Item -ItemType Directory -Path $fixtureDirectory, $bundleDirectory -Force | Out-Null + New-Item -ItemType Directory -Path $bundleDirectory, $platformStage -Force | Out-Null $env:GOCACHE = Join-Path $tempDirectory "go-cache" $env:npm_config_cache = Join-Path $tempDirectory "npm-cache" - $fixtureExecutable = Join-Path $bundleDirectory "etherscan.exe" Push-Location $repositoryRoot try { Invoke-Checked npm.cmd @("run", "test:release") + $fixtureExecutable = Join-Path $bundleDirectory "etherscan.exe" Invoke-Checked go @("build", "-buildvcs=false", "-ldflags", "-X main.version=$version", "-o", $fixtureExecutable, "./cmd/etherscan") - Compress-Archive -LiteralPath $fixtureExecutable -DestinationPath $archivePath - $hash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() - Set-Content -LiteralPath $checksumPath -Value "$hash $archiveName" + Copy-Item -LiteralPath (Join-Path $repositoryRoot "npm\$platformName\package.json") -Destination $platformStage + Copy-Item -LiteralPath (Join-Path $repositoryRoot "LICENSE") -Destination $platformStage + Copy-Item -LiteralPath $fixtureExecutable -Destination (Join-Path $platformStage "etherscan.exe") - $packResult = (& npm.cmd pack --json --pack-destination $tempDirectory | ConvertFrom-Json) - if ($LASTEXITCODE -ne 0) { - throw "npm pack failed with exit code $LASTEXITCODE" - } - $tarball = Join-Path $tempDirectory $packResult.filename + $platformPack = (& npm.cmd pack $platformStage --json --pack-destination $tempDirectory | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { throw "platform npm pack failed" } + $umbrellaPack = (& npm.cmd pack --json --pack-destination $tempDirectory | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { throw "umbrella npm pack failed" } } finally { Pop-Location } - $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL = $fixtureDirectory - $savedPSModulePath = $env:PSModulePath - $env:PSModulePath = "" - try { - Invoke-Checked npm.cmd @("install", "--global", "--prefix", $prefixDirectory, $tarball) - } - finally { - $env:PSModulePath = $savedPSModulePath - } + $platformTarball = Join-Path $tempDirectory $platformPack.filename + $umbrellaTarball = Join-Path $tempDirectory $umbrellaPack.filename + Invoke-Checked npm.cmd @("install", "--global", "--ignore-scripts", "--prefix", $prefixDirectory, $platformTarball, $umbrellaTarball) + $globalOutput = & (Join-Path $prefixDirectory "etherscan.cmd") version if ($LASTEXITCODE -ne 0 -or ($globalOutput -join "`n").Trim() -ne $version) { - throw "global npm installation returned unexpected version: $globalOutput" + throw "global npm installation returned an unexpected version: $globalOutput" } - $npxOutput = & npx.cmd --yes --package $tarball etherscan version + $npxOutput = & npx.cmd --yes --package $platformTarball --package $umbrellaTarball etherscan version if ($LASTEXITCODE -ne 0 -or ($npxOutput -join "`n").Trim() -ne $version) { - throw "npx returned unexpected version: $npxOutput" + throw "npx returned an unexpected version: $npxOutput" } $savedErrorAction = $ErrorActionPreference @@ -81,53 +68,20 @@ try { & (Join-Path $prefixDirectory "etherscan.cmd") definitely-not-a-command *> $null $argumentExitCode = $LASTEXITCODE $ErrorActionPreference = $savedErrorAction - if ($argumentExitCode -eq 0) { - throw "npm launcher did not forward a failing command or its exit code" - } - - Invoke-Checked npm.cmd @("install", "--global", "--ignore-scripts", "--prefix", $ignoredPrefix, $tarball) - $ErrorActionPreference = "Continue" - & (Join-Path $ignoredPrefix "etherscan.cmd") version 2>$null - $ignoredExitCode = $LASTEXITCODE - $ErrorActionPreference = $savedErrorAction - if ($ignoredExitCode -eq 0) { - throw "launcher succeeded after lifecycle scripts were disabled" - } - - $savedProcessorArchitecture = $env:PROCESSOR_ARCHITECTURE - $savedProcessorArchitectureW6432 = $env:PROCESSOR_ARCHITEW6432 - $env:PROCESSOR_ARCHITECTURE = "x86" - $env:PROCESSOR_ARCHITEW6432 = $null - $ErrorActionPreference = "Continue" - Push-Location $repositoryRoot - try { - & node npm/postinstall.js *> $null - $unsupportedExitCode = $LASTEXITCODE - } - finally { - Pop-Location - $env:PROCESSOR_ARCHITECTURE = $savedProcessorArchitecture - $env:PROCESSOR_ARCHITEW6432 = $savedProcessorArchitectureW6432 - $ErrorActionPreference = $savedErrorAction - } - if ($unsupportedExitCode -eq 0) { - throw "npm postinstall accepted an unsupported architecture" - } + if ($argumentExitCode -eq 0) { throw "npm launcher did not forward a failing command" } - Set-Content -LiteralPath $checksumPath -Value "$('0' * 64) $archiveName" + Invoke-Checked npm.cmd @("install", "--global", "--ignore-scripts", "--omit=optional", "--prefix", $missingPrefix, $umbrellaTarball) $ErrorActionPreference = "Continue" - & npm.cmd install --global --prefix $invalidPrefix $tarball *> $null - $invalidExitCode = $LASTEXITCODE + $missingOutput = (& (Join-Path $missingPrefix "etherscan.cmd") version 2>&1) -join "`n" $ErrorActionPreference = $savedErrorAction - if ($invalidExitCode -eq 0) { - throw "npm installation accepted an invalid release checksum" + if ($missingOutput -notlike "*without --omit=optional*") { + throw "missing platform package did not produce an actionable error: $missingOutput" } $global:LASTEXITCODE = 0 Write-Host "npm package tests passed." } finally { - $env:ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL = $previousDownloadBase $env:npm_config_cache = $previousNpmCache $env:GOCACHE = $previousGoCache Remove-Item -LiteralPath $tempDirectory -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/test-npm.sh b/scripts/test-npm.sh index 605cb97..f7c1382 100644 --- a/scripts/test-npm.sh +++ b/scripts/test-npm.sh @@ -8,69 +8,53 @@ version=0.0.0-development temp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t etherscan-npm-test) trap 'rm -rf "$temp_dir"' EXIT HUP INT TERM -fixture_dir="$temp_dir/fixtures" -bundle_dir="$temp_dir/bundle" -prefix_dir="$temp_dir/global prefix" -ignored_prefix="$temp_dir/ignored prefix" -invalid_prefix="$temp_dir/invalid prefix" -mkdir -p "$fixture_dir" "$bundle_dir" - case "$(uname -s)" in - Linux) os=linux ;; - Darwin) os=darwin ;; + Linux) npm_os=linux ;; + Darwin) npm_os=darwin ;; *) printf 'unsupported test OS\n' >&2; exit 1 ;; esac case "$(uname -m)" in - x86_64|amd64) arch=amd64 ;; - arm64|aarch64) arch=arm64 ;; + x86_64|amd64) npm_arch=x64 ;; + arm64|aarch64) npm_arch=arm64 ;; *) printf 'unsupported test architecture\n' >&2; exit 1 ;; esac -archive_name="etherscan_${version}_${os}_${arch}.tar.gz" -archive_path="$fixture_dir/$archive_name" -checksum_path="$fixture_dir/checksums.txt" +platform_name="cli-${npm_os}-${npm_arch}" +platform_stage="$temp_dir/$platform_name" +bundle_dir="$temp_dir/bundle" +prefix_dir="$temp_dir/global prefix" +missing_prefix="$temp_dir/missing prefix" +mkdir -p "$platform_stage" "$bundle_dir" export GOCACHE="$temp_dir/go-cache" export npm_config_cache="$temp_dir/npm-cache" (cd "$repository_root" && npm run test:release) (cd "$repository_root" && go build -buildvcs=false -ldflags "-X main.version=$version" -o "$bundle_dir/etherscan" ./cmd/etherscan) -tar -czf "$archive_path" -C "$bundle_dir" etherscan -if command -v sha256sum >/dev/null 2>&1; then - hash=$(sha256sum "$archive_path" | awk '{ print tolower($1) }') -else - hash=$(shasum -a 256 "$archive_path" | awk '{ print tolower($1) }') -fi -printf '%s %s\n' "$hash" "$archive_name" >"$checksum_path" +cp "$repository_root/npm/$platform_name/package.json" "$platform_stage/package.json" +cp "$repository_root/LICENSE" "$platform_stage/LICENSE" +cp "$bundle_dir/etherscan" "$platform_stage/etherscan" +chmod 0755 "$platform_stage/etherscan" -tarball_name=$(cd "$repository_root" && npm pack --pack-destination "$temp_dir" | tail -n 1) -tarball="$temp_dir/$tarball_name" -export ETHERSCAN_INSTALL_TEST_DOWNLOAD_BASE_URL="$fixture_dir" +platform_tarball_name=$(npm pack "$platform_stage" --pack-destination "$temp_dir" | tail -n 1) +umbrella_tarball_name=$(cd "$repository_root" && npm pack --pack-destination "$temp_dir" | tail -n 1) +platform_tarball="$temp_dir/$platform_tarball_name" +umbrella_tarball="$temp_dir/$umbrella_tarball_name" -npm install --global --prefix "$prefix_dir" "$tarball" +npm install --global --ignore-scripts --prefix "$prefix_dir" "$platform_tarball" "$umbrella_tarball" [ "$("$prefix_dir/bin/etherscan" version)" = "$version" ] || { printf 'global npm installation returned an unexpected version\n' >&2; exit 1; } -[ "$(npx --yes --package "$tarball" etherscan version)" = "$version" ] || { printf 'npx returned an unexpected version\n' >&2; exit 1; } +[ "$(npx --yes --package "$platform_tarball" --package "$umbrella_tarball" etherscan version)" = "$version" ] || { printf 'npx returned an unexpected version\n' >&2; exit 1; } if "$prefix_dir/bin/etherscan" definitely-not-a-command >/dev/null 2>&1; then printf 'npm launcher did not forward a failing command or its exit code\n' >&2 exit 1 fi -npm install --global --ignore-scripts --prefix "$ignored_prefix" "$tarball" -if "$ignored_prefix/bin/etherscan" version >/dev/null 2>&1; then - printf 'launcher succeeded after lifecycle scripts were disabled\n' >&2 +npm install --global --ignore-scripts --omit=optional --prefix "$missing_prefix" "$umbrella_tarball" +missing_output=$("$missing_prefix/bin/etherscan" version 2>&1 || true) +printf '%s' "$missing_output" | grep -F -- 'without --omit=optional' >/dev/null || { + printf 'missing platform package did not produce an actionable error: %s\n' "$missing_output" >&2 exit 1 -fi - -if (cd "$repository_root" && ETHERSCAN_INSTALL_TEST_ARCH=unsupported node npm/postinstall.js) >/dev/null 2>&1; then - printf 'npm postinstall accepted an unsupported architecture\n' >&2 - exit 1 -fi - -printf '%064d %s\n' 0 "$archive_name" >"$checksum_path" -if npm install --global --prefix "$invalid_prefix" "$tarball" >/dev/null 2>&1; then - printf 'npm installation accepted an invalid release checksum\n' >&2 - exit 1 -fi +} printf 'npm package tests passed.\n' From 4746be2be0c871a9bbd4535edfb446c148b53355 Mon Sep 17 00:00:00 2001 From: kenny yong Date: Thu, 13 Aug 2026 18:38:50 +0800 Subject: [PATCH 2/2] Fix non-executable test-npm.sh invocation in release Test step scripts/test-npm.sh is committed mode 100644 (no execute bit), so running it bare aborts the release with exit 126. Invoke it via `sh ./` to match the test.yml caller. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b43800..1d86116 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - name: Test run: | go test ./... - scripts/test-npm.sh + sh ./scripts/test-npm.sh - name: Vulnerability scan run: |