diff --git a/docs/beta-release.md b/docs/beta-release.md new file mode 100644 index 0000000000..1f7f6c253e --- /dev/null +++ b/docs/beta-release.md @@ -0,0 +1,102 @@ +# Beta Release Process (`@bitgo-beta/*`) + +## Overview + +BitGoJS publishes beta releases under the `@bitgo-beta` scope on npm. The `scripts/prepare-release.ts` +script transforms the entire monorepo — re-scoping all `@bitgo/*` packages to `@bitgo-beta/*`, computing +prerelease versions, and pinning all inter-module dependency versions. This enables publishing beta/alpha +releases without conflicting with stable releases. + +## How `prepare-release.ts` Works + +``` +CLI: npx tsx scripts/prepare-release.ts [preid] --scope [scope] --root-dir [dir] + Defaults: preid=beta, scope=@bitgo-beta, root-dir= +``` + +### Step 1: Scope Replacement + +The script walks all `.ts`, `.tsx`, `.js`, `.json` files in `modules/` and `webpack/` (skipping +`node_modules/`), performing a global regex replacement of every `@bitgo/X` reference with +`@bitgo-beta/X`. This covers: + +- `package.json` dependency entries +- TypeScript/JavaScript import statements +- Any other string references to `@bitgo/` scoped packages + +**Special case**: The `modules/bitgo` package is the only one published without an `@bitgo/` prefix. +Its `package.json` name is explicitly set to `@bitgo-beta/bitgo`. + +Implementation: `scripts/prepareRelease/changeScopeInFile.ts` + +### Step 2: Version Computation + +For each module, the script: + +1. Fetches dist-tags from npm (`https://registry.npmjs.org/-/package//dist-tags`) +2. Determines the previous prerelease version: + - If a beta tag exists and its base version >= latest, use the beta tag as base + - If the beta tag's base version < latest, start a new prerelease from latest + - If no beta tag exists, create one from latest +3. Computes the next version via `semver.inc(prevTag, 'prerelease', preid)` + +Example: `8.2.1-beta.1009` → `8.2.1-beta.1010` + +The dist-tag fetch can be cached via `BITGO_PREPARE_RELEASE_CACHE_DIST_TAGS` env var pointing to a +JSON file, avoiding repeated npm registry calls. + +Implementation: `scripts/prepareRelease/distTags.ts` + +### Step 3: Cross-Module Version Pinning + +After each module's version is bumped, all other modules that depend on it have their dependency +version updated to the exact new version. This **removes semver ranges** (`^`, `~`), resulting in +pinned versions: + +``` +Before: "@bitgo/sdk-core": "^31.2.1" +After: "@bitgo-beta/sdk-core": "8.2.1-beta.1010" +``` + +Implementation: `scripts/prepareRelease/changePackageJson.ts` + +## Side Effects for Consumers + +Because all dependency versions are pinned (no ranges), consumers of `@bitgo-beta/*` packages must +**explicitly bump** to new versions when they are published. The `@bitgo/beta-tools` package provides +a canonical CLI and library for this — see its README for usage. + +Key behaviors to understand: + +- Each `@bitgo-beta` package has its own **independent prerelease counter** (e.g., + `sdk-core@8.2.1-beta.788`, `statics@15.1.1-beta.791`). There is no shared suffix — + what ties a release together is the CI publish run. +- The `beta` dist-tag on npm always points to the latest published prerelease for each package. +- Fetching dist-tags individually during a multi-package publish can yield inconsistent versions + (a race condition): some packages may have the new version while others still show the old one. + Use `--versions-file` with a CI-generated manifest to avoid this. + +## Helper Modules (`scripts/prepareRelease/`) + +| File | Purpose | +|------|---------| +| `changeScopeInFile.ts` | Regex replacement of `@bitgo/*` → target scope in file contents | +| `changePackageJson.ts` | Updates dependency versions in `package.json` objects | +| `distTags.ts` | Fetches/caches npm dist-tags for all modules | +| `getLernaModules.ts` | Runs `lerna list --json --all` to discover all modules | +| `walk.ts` | Recursively walks directories, filtering by file extension | +| `index.ts` | Barrel export | + +## Known Limitations + +- `setDependencyVersion` in `changePackageJson.ts` only updates `dependencies` and `devDependencies`. + It does **not** update `peerDependencies` or `buildDependencies` (marked with a FIXME). +- Three packages are skipped during dist-tag fetch: `@bitgo-beta/express`, `@bitgo-beta/web-demo`, + `@bitgo-beta/sdk-test` (not published to npm). + +## Related Scripts + +| Script | Purpose | +|--------|---------| +| `scripts/prepare-release.ts` | Main transformation script (this document) | +| `@bitgo/beta-tools` (`modules/beta-tools`) | Canonical tool for consumers to bump `@bitgo-beta/*` versions | diff --git a/modules/beta-tools/.eslintignore b/modules/beta-tools/.eslintignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/modules/beta-tools/.eslintignore @@ -0,0 +1 @@ +dist/ diff --git a/modules/beta-tools/.eslintrc.json b/modules/beta-tools/.eslintrc.json new file mode 100644 index 0000000000..be97c53fbb --- /dev/null +++ b/modules/beta-tools/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/modules/beta-tools/.mocharc.js b/modules/beta-tools/.mocharc.js new file mode 100644 index 0000000000..007ef573be --- /dev/null +++ b/modules/beta-tools/.mocharc.js @@ -0,0 +1,4 @@ +module.exports = { + require: 'tsx', + extension: ['.js', '.ts'], +}; diff --git a/modules/beta-tools/README.md b/modules/beta-tools/README.md new file mode 100644 index 0000000000..c09629e71b --- /dev/null +++ b/modules/beta-tools/README.md @@ -0,0 +1,101 @@ +# @bitgo/beta-tools + +CLI and library for bumping `@bitgo-beta/*` dependency versions in consumer projects. + +## Problem + +BitGoJS publishes beta packages under the `@bitgo-beta` scope with pinned (non-range) inter-module +dependency versions. Consumer repos must explicitly bump these versions. This tool replaces the +divergent bespoke scripts that existed in individual consumer repos. + +## How version resolution works + +Strategies are tried in order — the first available one wins: + +1. **Manifest file (`--versions-file`):** Reads an explicit JSON map of package→version. Most + deterministic — useful for CI pipelines that generate a manifest during publish. + +2. **GitHub Actions API** (when `GITHUB_TOKEN` is set): Fetches the logs of the latest successful + "Publish @bitgo-beta" workflow run and parses the verify-release output. This gives the complete + version set (~106 packages) from a single CI run — race-free, no gaps. + +3. **npm registry (default fallback):** Fetches `@bitgo-beta/bitgo` at the requested dist-tag. Its + `dependencies` field contains pinned versions for ~92 packages from the same CI publish run. + Packages not covered by the megapackage (typically 5-6 per consumer) fall back to individual + dist-tag lookups — these are racy during active publishes. + +## CLI usage + +```bash +# Via npx +npx @bitgo-beta/beta-tools --tag beta + +# Or install as devDependency +npm install -D @bitgo-beta/beta-tools +bump-bitgo-beta --tag beta +``` + +### Options + +| Option | Default | Description | +|---|---|---| +| `--tag ` | `beta` | Dist tag to resolve | +| `--versions-file ` | — | JSON manifest of package→version mappings | +| `--scope ` | `@bitgo-beta` | Package scope to match in `package.json` | +| `--pm ` | auto-detected | Package manager to use | +| `--ignore ` | `[]` | Packages to skip | +| `--only-utxo` | `false` | Only bump UTXO-related packages | +| `--ignore-utxo` | `true` (when `--only-utxo` not set) | Skip UTXO-related packages | +| `--utxo-patterns ` | `utxo, unspents, abstract-lightning, babylonlabs-io-btc-staking-ts` | Patterns for UTXO package detection | +| `--check-duplicates` | `true` | Check lockfile for duplicate versions after install (npm only) | +| `--check-duplicate-packages ` | `@bitgo-beta/utxo-lib, @bitgo/wasm-utxo` | Packages to check for duplicates | +| `--dry-run` | `false` | Show what would be installed without installing | + +### Examples + +```bash +# Bump all non-UTXO beta deps (default behavior) +bump-bitgo-beta + +# Bump only UTXO packages +bump-bitgo-beta --only-utxo + +# Bump everything, skip duplicate check +bump-bitgo-beta --ignore-utxo=false --check-duplicates=false + +# Use a CI-generated manifest +bump-bitgo-beta --versions-file ./beta-versions.json + +# Preview without installing +bump-bitgo-beta --dry-run +``` + +## Library API + +```typescript +import { + resolveVersions, + filterDependencies, + detectPackageManager, + createPackageManager, + checkDuplicates, +} from '@bitgo-beta/beta-tools'; + +const resolved = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/statics'], + tag: 'beta', + scope: '@bitgo-beta', +}); + +for (const [pkg, version] of resolved.versions) { + console.log(`${pkg}@${version}`); +} +``` + +## Development + +```bash +yarn build +yarn lint +yarn unit-test +``` diff --git a/modules/beta-tools/package.json b/modules/beta-tools/package.json new file mode 100644 index 0000000000..277c0c74e2 --- /dev/null +++ b/modules/beta-tools/package.json @@ -0,0 +1,43 @@ +{ + "name": "@bitgo/beta-tools", + "version": "1.0.0", + "description": "CLI and library for bumping @bitgo-beta dependency versions", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "bin": { + "bump-bitgo-beta": "./dist/src/cli.js" + }, + "files": [ + "dist/src/**/*" + ], + "scripts": { + "prepare": "npm run build", + "build": "yarn tsc --build --incremental --verbose .", + "lint": "eslint --quiet .", + "unit-test": "mocha --recursive test", + "fmt": "prettier --write '{src,test}/**/*.{ts,js}'" + }, + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/beta-tools" + }, + "dependencies": { + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/yargs": "^17.0.0", + "sinon": "^13.0.1", + "@types/sinon": "^10.0.0" + }, + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/modules/beta-tools/src/cli.ts b/modules/beta-tools/src/cli.ts new file mode 100644 index 0000000000..3e44b7f790 --- /dev/null +++ b/modules/beta-tools/src/cli.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import { readFile } from 'fs/promises'; +import path from 'path'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { checkDuplicates, DEFAULT_DUPLICATE_CHECK_PACKAGES } from './duplicateCheck'; +import { DEFAULT_UTXO_PATTERNS, filterDependencies } from './filterDependencies'; +import { createPackageManager, detectPackageManager } from './packageManager'; +import { resolveVersions } from './resolveVersions'; + +async function getBitgoBetaDeps(scope: string, cwd: string): Promise { + const packageJsonPath = path.resolve(cwd, 'package.json'); + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')); + return Object.keys(packageJson.dependencies || {}).filter((pkg) => pkg.startsWith(`${scope}/`)); +} + +yargs(hideBin(process.argv)) + .command({ + command: '$0', + describe: 'Bump @bitgo-beta dependencies to their latest tagged versions', + builder: (y) => + y + .option('tag', { + type: 'string', + description: 'Dist tag to use (e.g., beta, latest)', + default: 'beta', + }) + .option('versions-file', { + type: 'string', + description: + 'Path to JSON manifest of package->version mappings. ' + + 'Avoids the race condition of fetching dist-tags during a publish.', + }) + .option('scope', { + type: 'string', + description: 'Package scope to match', + default: '@bitgo-beta', + }) + .option('pm', { + type: 'string', + choices: ['npm', 'yarn', 'pnpm'] as const, + description: 'Package manager (auto-detected from lockfile if not specified)', + }) + .option('ignore', { + type: 'array', + string: true, + description: 'Packages to skip', + default: [] as string[], + }) + .option('only-utxo', { + type: 'boolean', + description: 'Only bump UTXO packages', + default: false, + }) + .option('ignore-utxo', { + type: 'boolean', + description: 'Skip UTXO packages (default when --only-utxo not set)', + }) + .option('utxo-patterns', { + type: 'array', + string: true, + description: 'Override UTXO detection patterns', + default: DEFAULT_UTXO_PATTERNS, + }) + .option('check-duplicates', { + type: 'boolean', + description: 'Check lockfile for duplicate versions after install', + default: true, + }) + .option('check-duplicate-packages', { + type: 'array', + string: true, + description: 'Packages to check for duplicates', + default: DEFAULT_DUPLICATE_CHECK_PACKAGES, + }) + .option('dry-run', { + type: 'boolean', + description: 'Show what would be installed without installing', + default: false, + }) + .check((argv) => { + if (argv.onlyUtxo && argv.ignoreUtxo) { + throw new Error('Cannot use both --only-utxo and --ignore-utxo'); + } + return true; + }), + async handler(argv) { + const cwd = process.cwd(); + + const allDeps = await getBitgoBetaDeps(argv.scope, cwd); + if (allDeps.length === 0) { + console.log(`No ${argv.scope} dependencies found in package.json`); + return; + } + + const deps = filterDependencies(allDeps, { + ignore: argv.ignore, + onlyUtxo: argv.onlyUtxo, + ignoreUtxo: argv.ignoreUtxo ?? !argv.onlyUtxo, + utxoPatterns: argv.utxoPatterns, + }); + + if (deps.length === 0) { + console.log('No matching dependencies after filtering'); + return; + } + + console.log(`Resolving versions for ${deps.length} packages...`); + + const resolved = await resolveVersions({ + packages: deps, + tag: argv.tag, + scope: argv.scope, + manifestPath: argv.versionsFile, + githubToken: process.env.GITHUB_TOKEN, + }); + + if (resolved.versions.size === 0) { + console.log('No versions resolved'); + return; + } + + const installSpecs = [...resolved.versions.entries()].map(([pkg, version]) => `${pkg}@${version}`); + + const pmType = argv.pm ?? detectPackageManager(cwd); + const pm = createPackageManager(pmType); + + pm.installExact(installSpecs, argv.dryRun); + + if (!argv.dryRun) { + console.log(`Successfully updated ${installSpecs.length} packages`); + } + + if (argv.checkDuplicates && pmType === 'npm' && !argv.dryRun) { + const report = await checkDuplicates(argv.checkDuplicatePackages, cwd); + if (report.hasDuplicates) { + console.error('\nDuplicate package versions detected!'); + process.exit(1); + } + console.log('\nNo duplicate package versions found.'); + } + }, + }) + .help() + .parse(); diff --git a/modules/beta-tools/src/duplicateCheck.ts b/modules/beta-tools/src/duplicateCheck.ts new file mode 100644 index 0000000000..f8abb71433 --- /dev/null +++ b/modules/beta-tools/src/duplicateCheck.ts @@ -0,0 +1,62 @@ +import { readFile } from 'fs/promises'; +import path from 'path'; + +export const DEFAULT_DUPLICATE_CHECK_PACKAGES = ['@bitgo-beta/utxo-lib', '@bitgo/wasm-utxo']; + +export interface DuplicateEntry { + version: string; + path: string; +} + +export interface DuplicateReport { + hasDuplicates: boolean; + details: Map; +} + +/** + * Check for duplicate package versions in package-lock.json (npm v2/v3 format). + * Returns a report indicating whether any duplicates were found. + */ +export async function checkDuplicates( + packagesToCheck: string[], + cwd: string = process.cwd() +): Promise { + const lockfilePath = path.resolve(cwd, 'package-lock.json'); + const packageLock = JSON.parse(await readFile(lockfilePath, 'utf8')); + + let hasDuplicates = false; + const details = new Map(); + + for (const packageName of packagesToCheck) { + const locations: DuplicateEntry[] = []; + + // npm v2/v3 lockfile format uses "packages" key + if (packageLock.packages) { + for (const [pkgPath, pkgInfo] of Object.entries(packageLock.packages)) { + if (pkgPath.endsWith(`node_modules/${packageName}`)) { + const version = (pkgInfo as { version?: string }).version; + if (version) { + locations.push({ path: pkgPath, version }); + } + } + } + } + + details.set(packageName, locations); + + const versions = new Set(locations.map((l) => l.version)); + if (versions.size > 1) { + hasDuplicates = true; + console.error(`Duplicate versions found for ${packageName}:`); + for (const loc of locations) { + console.error(` ${loc.version} at ${loc.path}`); + } + } else if (versions.size === 1) { + console.log(`${packageName}: single version ${[...versions][0]}`); + } else { + console.log(`${packageName}: not found in package-lock.json`); + } + } + + return { hasDuplicates, details }; +} diff --git a/modules/beta-tools/src/filterDependencies.ts b/modules/beta-tools/src/filterDependencies.ts new file mode 100644 index 0000000000..4e86cd56bd --- /dev/null +++ b/modules/beta-tools/src/filterDependencies.ts @@ -0,0 +1,28 @@ +export const DEFAULT_UTXO_PATTERNS = ['utxo', 'unspents', 'abstract-lightning', 'babylonlabs-io-btc-staking-ts']; + +export interface FilterOptions { + ignore: string[]; + onlyUtxo: boolean; + ignoreUtxo: boolean; + utxoPatterns: string[]; +} + +export function isUtxoPackage(packageName: string, patterns: string[] = DEFAULT_UTXO_PATTERNS): boolean { + return patterns.some((p) => packageName.includes(p)); +} + +export function filterDependencies(deps: string[], options: FilterOptions): string[] { + return deps.filter((d) => { + if (options.ignore.includes(d)) { + return false; + } + const utxo = isUtxoPackage(d, options.utxoPatterns); + if (options.onlyUtxo) { + return utxo; + } + if (options.ignoreUtxo) { + return !utxo; + } + return true; + }); +} diff --git a/modules/beta-tools/src/github.ts b/modules/beta-tools/src/github.ts new file mode 100644 index 0000000000..20e856c319 --- /dev/null +++ b/modules/beta-tools/src/github.ts @@ -0,0 +1,92 @@ +const GITHUB_API = 'https://api.github.com'; + +function headers(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; +} + +interface WorkflowRun { + id: number; + created_at: string; + head_sha: string; +} + +interface WorkflowRunsResponse { + workflow_runs: WorkflowRun[]; +} + +interface Job { + id: number; + name: string; +} + +interface JobsResponse { + jobs: Job[]; +} + +export async function getLatestPublishRunId( + token: string, + owner: string, + repo: string, + branch = 'master' +): Promise { + const params = new URLSearchParams({ + status: 'success', + branch, + per_page: '1', + }); + const url = `${GITHUB_API}/repos/${owner}/${repo}/actions/workflows/publish.yml/runs?${params}`; + const response = await fetch(url, { headers: headers(token) }); + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + const data = (await response.json()) as WorkflowRunsResponse; + const run = data.workflow_runs[0]; + if (!run) { + throw new Error(`No successful publish workflow runs found on branch ${branch}`); + } + console.log(`Found publish run ${run.id} from ${run.created_at} (${run.head_sha.slice(0, 8)})`); + return run.id; +} + +export async function getPublishJobLogs(token: string, owner: string, repo: string, runId: number): Promise { + const jobsUrl = `${GITHUB_API}/repos/${owner}/${repo}/actions/runs/${runId}/jobs`; + const jobsResponse = await fetch(jobsUrl, { headers: headers(token) }); + if (!jobsResponse.ok) { + throw new Error(`GitHub API error fetching jobs: ${jobsResponse.status} ${jobsResponse.statusText}`); + } + const jobsData = (await jobsResponse.json()) as JobsResponse; + const publishJob = jobsData.jobs.find((j) => j.name === 'Publish Release'); + if (!publishJob) { + throw new Error(`No "Publish Release" job found in run ${runId}`); + } + + const logsUrl = `${GITHUB_API}/repos/${owner}/${repo}/actions/jobs/${publishJob.id}/logs`; + const logsResponse = await fetch(logsUrl, { + headers: { ...headers(token), Accept: 'application/vnd.github.v3.raw' }, + }); + if (!logsResponse.ok) { + throw new Error(`GitHub API error fetching logs: ${logsResponse.status} ${logsResponse.statusText}`); + } + return logsResponse.text(); +} + +/** + * Parse verify-release output from GitHub Actions job logs. + * + * Log lines look like: + * 2026-02-25T13:05:23.1416944Z @bitgo-beta/sdk-core matches expected version 8.2.1-beta.1613 + */ +export function parseVersionsFromLogs(logs: string, scope: string): Map { + const escaped = scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(${escaped}/[\\w-]+) matches expected version (\\S+)`, 'gm'); + const versions = new Map(); + let match; + while ((match = pattern.exec(logs)) !== null) { + versions.set(match[1], match[2]); + } + return versions; +} diff --git a/modules/beta-tools/src/index.ts b/modules/beta-tools/src/index.ts new file mode 100644 index 0000000000..351433ca3c --- /dev/null +++ b/modules/beta-tools/src/index.ts @@ -0,0 +1,16 @@ +export { getDistTags, getAllDistTags, getPackageVersion } from './registry'; +export type { DistTags } from './registry'; + +export { parseVersionsFromLogs } from './github'; + +export { filterDependencies, isUtxoPackage, DEFAULT_UTXO_PATTERNS } from './filterDependencies'; +export type { FilterOptions } from './filterDependencies'; + +export { resolveVersions } from './resolveVersions'; +export type { ResolveOptions, ResolvedVersions } from './resolveVersions'; + +export { detectPackageManager, createPackageManager } from './packageManager'; +export type { PackageManagerType, PackageManager } from './packageManager'; + +export { checkDuplicates, DEFAULT_DUPLICATE_CHECK_PACKAGES } from './duplicateCheck'; +export type { DuplicateReport, DuplicateEntry } from './duplicateCheck'; diff --git a/modules/beta-tools/src/packageManager.ts b/modules/beta-tools/src/packageManager.ts new file mode 100644 index 0000000000..b019a0274b --- /dev/null +++ b/modules/beta-tools/src/packageManager.ts @@ -0,0 +1,70 @@ +import { execFileSync } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; + +export type PackageManagerType = 'npm' | 'yarn' | 'pnpm'; + +export interface PackageManager { + type: PackageManagerType; + installExact(packages: string[], dryRun?: boolean): void; +} + +function npmManager(): PackageManager { + return { + type: 'npm', + installExact(packages, dryRun) { + const args = ['install', '--save-exact', ...packages]; + if (dryRun) { + args.push('--dry-run'); + } + console.log(`Executing: npm ${args.join(' ')}`); + if (!dryRun) { + execFileSync('npm', args, { stdio: 'inherit' }); + } + }, + }; +} + +function yarnManager(): PackageManager { + return { + type: 'yarn', + installExact(packages, dryRun) { + const args = ['add', '--exact', ...packages]; + console.log(`Executing: yarn ${args.join(' ')}`); + if (!dryRun) { + execFileSync('yarn', args, { stdio: 'inherit' }); + } + }, + }; +} + +function pnpmManager(): PackageManager { + return { + type: 'pnpm', + installExact(packages, dryRun) { + const args = ['add', '--save-exact', ...packages]; + console.log(`Executing: pnpm ${args.join(' ')}`); + if (!dryRun) { + execFileSync('pnpm', args, { stdio: 'inherit' }); + } + }, + }; +} + +export function detectPackageManager(cwd: string = process.cwd()): PackageManagerType { + if (existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if (existsSync(path.join(cwd, 'package-lock.json'))) return 'npm'; + throw new Error('Could not detect package manager: no pnpm-lock.yaml, yarn.lock, or package-lock.json found'); +} + +export function createPackageManager(type: PackageManagerType): PackageManager { + switch (type) { + case 'npm': + return npmManager(); + case 'yarn': + return yarnManager(); + case 'pnpm': + return pnpmManager(); + } +} diff --git a/modules/beta-tools/src/registry.ts b/modules/beta-tools/src/registry.ts new file mode 100644 index 0000000000..383d283e20 --- /dev/null +++ b/modules/beta-tools/src/registry.ts @@ -0,0 +1,43 @@ +const NPM_REGISTRY = 'https://registry.npmjs.org'; + +export type DistTags = Record; + +export async function getDistTags(packageName: string): Promise { + const url = `${NPM_REGISTRY}/-/package/${packageName}/dist-tags`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch dist-tags for ${packageName}: ${response.status} ${response.statusText}`); + } + return response.json() as Promise; +} + +export async function getAllDistTags(packageNames: string[]): Promise> { + const entries = await Promise.all( + packageNames.map(async (name): Promise<[string, DistTags] | undefined> => { + try { + return [name, await getDistTags(name)]; + } catch (e) { + console.warn(`Failed to fetch dist-tags for ${name}:`, e); + return undefined; + } + }) + ); + return new Map(entries.filter((e): e is [string, DistTags] => e !== undefined)); +} + +/** + * Fetch a specific version of a package from the npm registry. + * If version is a dist-tag name (e.g., "beta"), npm resolves it. + */ +export async function getPackageVersion( + packageName: string, + version: string +): Promise<{ version: string; dependencies: Record }> { + const url = `${NPM_REGISTRY}/${packageName}/${version}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${packageName}@${version}: ${response.status} ${response.statusText}`); + } + const data = (await response.json()) as { version: string; dependencies?: Record }; + return { version: data.version, dependencies: data.dependencies ?? {} }; +} diff --git a/modules/beta-tools/src/resolveVersions.ts b/modules/beta-tools/src/resolveVersions.ts new file mode 100644 index 0000000000..caeeca50ce --- /dev/null +++ b/modules/beta-tools/src/resolveVersions.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'fs'; + +import * as github from './github'; +import * as registry from './registry'; + +export interface ResolveOptions { + /** Package names to resolve (e.g., ["@bitgo-beta/sdk-core", ...]) */ + packages: string[]; + /** Dist tag to resolve (e.g., "beta") */ + tag: string; + /** Package scope (e.g., "@bitgo-beta") */ + scope: string; + /** + * Path to JSON manifest mapping package names to exact versions. + * Bypasses all registry fetches. + */ + manifestPath?: string; + /** + * Override the megapackage used as a version reference. + * Default: "/bitgo" + */ + referencePackage?: string; + /** + * GitHub API token. When set, versions are resolved from the latest + * successful publish workflow run logs (complete, race-free). + * Falls back to registry strategy when not set. + */ + githubToken?: string; +} + +export interface ResolvedVersions { + /** Map from package name to exact version string */ + versions: Map; +} + +function resolveFromManifest(manifestPath: string, packages: string[]): ResolvedVersions { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record; + const versions = new Map(); + for (const pkg of packages) { + const version = manifest[pkg]; + if (version) { + versions.set(pkg, version); + } else { + console.warn(`Package ${pkg} not found in manifest ${manifestPath}`); + } + } + return { versions }; +} + +/** + * Default resolution strategy: + * + * 1. Fetch the megapackage (@bitgo-beta/bitgo) at the given dist tag. + * Its dependencies are pinned to exact versions from the same CI + * publish run — one atomic fetch gives us a release snapshot. + * + * 2. For any requested packages NOT covered by the megapackage's deps, + * fall back to individual dist-tag fetches. + */ +async function resolveFromRegistry( + packages: string[], + tag: string, + referencePackage: string +): Promise { + const versions = new Map(); + const uncovered: string[] = []; + + // Step 1: fetch the megapackage's published dependencies + let megaDeps: Record = {}; + try { + const mega = await registry.getPackageVersion(referencePackage, tag); + console.log(`Using ${referencePackage}@${mega.version} as version reference`); + megaDeps = mega.dependencies; + } catch (e) { + console.warn(`Failed to fetch ${referencePackage}@${tag}, falling back to individual dist-tag fetches:`, e); + } + + for (const pkg of packages) { + const version = megaDeps[pkg]; + if (version) { + versions.set(pkg, version); + } else { + uncovered.push(pkg); + } + } + + // Step 2: individual dist-tag fetch for uncovered packages + if (uncovered.length > 0) { + console.log( + `${uncovered.length} package(s) not in ${referencePackage}, fetching dist-tags individually: ${uncovered.join( + ', ' + )}` + ); + const allTags = await registry.getAllDistTags(uncovered); + for (const pkg of uncovered) { + const tags = allTags.get(pkg); + if (!tags) { + console.warn(`No dist-tags found for ${pkg}, skipping`); + continue; + } + const version = tags[tag]; + if (!version) { + console.warn(`No '${tag}' dist-tag for ${pkg}, skipping`); + continue; + } + versions.set(pkg, version); + } + } + + return { versions }; +} + +async function resolveFromGitHub(packages: string[], scope: string, token: string): Promise { + const runId = await github.getLatestPublishRunId(token, 'BitGo', 'BitGoJS'); + const logs = await github.getPublishJobLogs(token, 'BitGo', 'BitGoJS', runId); + const allVersions = github.parseVersionsFromLogs(logs, scope); + console.log(`Resolved ${allVersions.size} package versions from publish run ${runId}`); + + const versions = new Map(); + for (const pkg of packages) { + const version = allVersions.get(pkg); + if (version) { + versions.set(pkg, version); + } else { + console.warn(`Package ${pkg} not found in publish run ${runId} logs`); + } + } + return { versions }; +} + +export async function resolveVersions(options: ResolveOptions): Promise { + if (options.manifestPath) { + return resolveFromManifest(options.manifestPath, options.packages); + } + if (options.githubToken) { + return resolveFromGitHub(options.packages, options.scope, options.githubToken); + } + const referencePackage = options.referencePackage ?? `${options.scope}/bitgo`; + return resolveFromRegistry(options.packages, options.tag, referencePackage); +} diff --git a/modules/beta-tools/test/duplicateCheck.test.ts b/modules/beta-tools/test/duplicateCheck.test.ts new file mode 100644 index 0000000000..cad814f6bd --- /dev/null +++ b/modules/beta-tools/test/duplicateCheck.test.ts @@ -0,0 +1,63 @@ +import assert from 'assert'; +import { writeFileSync, mkdtempSync } from 'fs'; +import path from 'path'; +import os from 'os'; + +import { checkDuplicates } from '../src/duplicateCheck'; + +function writeLockfile(dir: string, packages: Record): void { + writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ lockfileVersion: 3, packages })); +} + +describe('checkDuplicates', function () { + it('returns no duplicates when single version exists', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeLockfile(tmpDir, { + 'node_modules/@bitgo-beta/utxo-lib': { version: '11.0.0-beta.1010' }, + }); + + const report = await checkDuplicates(['@bitgo-beta/utxo-lib'], tmpDir); + assert.strictEqual(report.hasDuplicates, false); + }); + + it('detects duplicates when multiple versions exist', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeLockfile(tmpDir, { + 'node_modules/@bitgo-beta/utxo-lib': { version: '11.0.0-beta.1010' }, + 'node_modules/@bitgo-beta/sdk-core/node_modules/@bitgo-beta/utxo-lib': { + version: '11.0.0-beta.1009', + }, + }); + + const report = await checkDuplicates(['@bitgo-beta/utxo-lib'], tmpDir); + assert.strictEqual(report.hasDuplicates, true); + const entries = report.details.get('@bitgo-beta/utxo-lib'); + assert.ok(entries); + assert.strictEqual(entries.length, 2); + }); + + it('reports package not found when absent', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeLockfile(tmpDir, {}); + + const report = await checkDuplicates(['@bitgo-beta/utxo-lib'], tmpDir); + assert.strictEqual(report.hasDuplicates, false); + const entries = report.details.get('@bitgo-beta/utxo-lib'); + assert.ok(entries); + assert.strictEqual(entries.length, 0); + }); + + it('handles multiple packages to check', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeLockfile(tmpDir, { + 'node_modules/@bitgo-beta/utxo-lib': { version: '11.0.0-beta.1010' }, + 'node_modules/@bitgo/wasm-utxo': { version: '2.0.0' }, + 'node_modules/some-pkg/node_modules/@bitgo/wasm-utxo': { version: '1.9.0' }, + }); + + const report = await checkDuplicates(['@bitgo-beta/utxo-lib', '@bitgo/wasm-utxo'], tmpDir); + assert.strictEqual(report.hasDuplicates, true); + assert.strictEqual(report.details.get('@bitgo-beta/utxo-lib')!.length, 1); + assert.strictEqual(report.details.get('@bitgo/wasm-utxo')!.length, 2); + }); +}); diff --git a/modules/beta-tools/test/filterDependencies.test.ts b/modules/beta-tools/test/filterDependencies.test.ts new file mode 100644 index 0000000000..8ae0b5fa0a --- /dev/null +++ b/modules/beta-tools/test/filterDependencies.test.ts @@ -0,0 +1,95 @@ +import assert from 'assert'; + +import { DEFAULT_UTXO_PATTERNS, filterDependencies, isUtxoPackage } from '../src/filterDependencies'; + +describe('isUtxoPackage', function () { + it('matches packages containing "utxo"', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/utxo-lib'), true); + assert.strictEqual(isUtxoPackage('@bitgo-beta/abstract-utxo'), true); + }); + + it('matches packages containing "unspents"', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/unspents'), true); + }); + + it('matches packages containing "abstract-lightning"', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/abstract-lightning'), true); + }); + + it('matches babylonlabs-io-btc-staking-ts', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/babylonlabs-io-btc-staking-ts'), true); + }); + + it('does not match non-utxo packages', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/sdk-core'), false); + assert.strictEqual(isUtxoPackage('@bitgo-beta/statics'), false); + assert.strictEqual(isUtxoPackage('@bitgo-beta/sdk-coin-eth'), false); + }); + + it('uses custom patterns when provided', function () { + assert.strictEqual(isUtxoPackage('@bitgo-beta/sdk-coin-ada', ['ada']), true); + assert.strictEqual(isUtxoPackage('@bitgo-beta/utxo-lib', ['ada']), false); + }); +}); + +describe('filterDependencies', function () { + const deps = [ + '@bitgo-beta/sdk-core', + '@bitgo-beta/utxo-lib', + '@bitgo-beta/statics', + '@bitgo-beta/abstract-lightning', + '@bitgo-beta/sdk-coin-eth', + '@bitgo-beta/unspents', + ]; + + it('returns all deps with no filters', function () { + const result = filterDependencies(deps, { + ignore: [], + onlyUtxo: false, + ignoreUtxo: false, + utxoPatterns: DEFAULT_UTXO_PATTERNS, + }); + assert.deepStrictEqual(result, deps); + }); + + it('ignores specified packages', function () { + const result = filterDependencies(deps, { + ignore: ['@bitgo-beta/statics'], + onlyUtxo: false, + ignoreUtxo: false, + utxoPatterns: DEFAULT_UTXO_PATTERNS, + }); + assert.ok(!result.includes('@bitgo-beta/statics')); + assert.strictEqual(result.length, 5); + }); + + it('filters to only UTXO packages', function () { + const result = filterDependencies(deps, { + ignore: [], + onlyUtxo: true, + ignoreUtxo: false, + utxoPatterns: DEFAULT_UTXO_PATTERNS, + }); + assert.deepStrictEqual(result, ['@bitgo-beta/utxo-lib', '@bitgo-beta/abstract-lightning', '@bitgo-beta/unspents']); + }); + + it('filters out UTXO packages', function () { + const result = filterDependencies(deps, { + ignore: [], + onlyUtxo: false, + ignoreUtxo: true, + utxoPatterns: DEFAULT_UTXO_PATTERNS, + }); + assert.deepStrictEqual(result, ['@bitgo-beta/sdk-core', '@bitgo-beta/statics', '@bitgo-beta/sdk-coin-eth']); + }); + + it('combines ignore with onlyUtxo', function () { + const result = filterDependencies(deps, { + ignore: ['@bitgo-beta/unspents'], + onlyUtxo: true, + ignoreUtxo: false, + utxoPatterns: DEFAULT_UTXO_PATTERNS, + }); + assert.deepStrictEqual(result, ['@bitgo-beta/utxo-lib', '@bitgo-beta/abstract-lightning']); + }); +}); diff --git a/modules/beta-tools/test/github.test.ts b/modules/beta-tools/test/github.test.ts new file mode 100644 index 0000000000..c868870605 --- /dev/null +++ b/modules/beta-tools/test/github.test.ts @@ -0,0 +1,125 @@ +import assert from 'assert'; +import sinon from 'sinon'; + +import { getLatestPublishRunId, getPublishJobLogs, parseVersionsFromLogs } from '../src/github'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } }); +} + +describe('github', function () { + let fetchStub: sinon.SinonStub; + + beforeEach(function () { + fetchStub = sinon.stub(global, 'fetch'); + fetchStub.rejects(new Error('Unexpected fetch call')); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('parseVersionsFromLogs', function () { + it('parses versions from GitHub Actions log lines', function () { + const logs = [ + '2026-02-25T13:05:23.1416944Z @bitgo-beta/sdk-core matches expected version 8.2.1-beta.1613', + '2026-02-25T13:05:23.3282652Z @bitgo-beta/statics matches expected version 15.1.1-beta.1616', + '2026-02-25T13:05:23.6045279Z @bitgo-beta/utxo-lib matches expected version 8.0.3-beta.1615', + ].join('\n'); + + const versions = parseVersionsFromLogs(logs, '@bitgo-beta'); + assert.strictEqual(versions.size, 3); + assert.strictEqual(versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.1613'); + assert.strictEqual(versions.get('@bitgo-beta/statics'), '15.1.1-beta.1616'); + assert.strictEqual(versions.get('@bitgo-beta/utxo-lib'), '8.0.3-beta.1615'); + }); + + it('ignores non-matching lines', function () { + const logs = [ + '2026-02-25T13:04:00.0000000Z Verifying packages...', + '2026-02-25T13:05:23.1416944Z @bitgo-beta/sdk-core matches expected version 8.2.1-beta.1613', + '2026-02-25T13:05:24.0000000Z Done.', + '2026-02-25T13:05:25.0000000Z @bitgo-beta/missing-pkg missing. Expected 1.0.0-beta.1, latest is 1.0.0-beta.0', + ].join('\n'); + + const versions = parseVersionsFromLogs(logs, '@bitgo-beta'); + assert.strictEqual(versions.size, 1); + assert.strictEqual(versions.has('@bitgo-beta/missing-pkg'), false); + }); + + it('only matches the specified scope', function () { + const logs = [ + '2026-02-25T13:05:23.0000000Z @bitgo-beta/sdk-core matches expected version 1.0.0', + '2026-02-25T13:05:23.0000000Z @other-scope/sdk-core matches expected version 2.0.0', + ].join('\n'); + + const versions = parseVersionsFromLogs(logs, '@bitgo-beta'); + assert.strictEqual(versions.size, 1); + assert.strictEqual(versions.get('@bitgo-beta/sdk-core'), '1.0.0'); + }); + + it('returns empty map for empty logs', function () { + assert.strictEqual(parseVersionsFromLogs('', '@bitgo-beta').size, 0); + }); + }); + + describe('getLatestPublishRunId', function () { + it('returns the run ID from the latest successful workflow run', async function () { + fetchStub.withArgs(sinon.match(/repos\/BitGo\/BitGoJS\/actions\/workflows\/publish.yml\/runs/)).resolves( + jsonResponse({ + workflow_runs: [{ id: 12345, created_at: '2026-01-01T00:00:00Z', head_sha: 'abc123' }], + }) + ); + + const runId = await getLatestPublishRunId('test-token', 'BitGo', 'BitGoJS'); + assert.strictEqual(runId, 12345); + + const [url, opts] = fetchStub.firstCall.args; + assert.ok(url.includes('status=success')); + assert.ok(url.includes('branch=master')); + const hdrs = (opts as RequestInit).headers as Record | undefined; + assert.strictEqual(hdrs?.['Authorization'], 'Bearer test-token'); + }); + + it('throws when no runs found', async function () { + fetchStub.withArgs(sinon.match(/workflows\/publish.yml\/runs/)).resolves(jsonResponse({ workflow_runs: [] })); + + await assert.rejects(() => getLatestPublishRunId('token', 'BitGo', 'BitGoJS'), /No successful publish/); + }); + + it('throws on API error', async function () { + fetchStub + .withArgs(sinon.match(/workflows\/publish.yml\/runs/)) + .resolves(new Response('Forbidden', { status: 403, statusText: 'Forbidden' })); + + await assert.rejects(() => getLatestPublishRunId('token', 'BitGo', 'BitGoJS'), /403 Forbidden/); + }); + }); + + describe('getPublishJobLogs', function () { + it('fetches logs for the Publish Release job', async function () { + fetchStub + .withArgs(sinon.match(/actions\/runs\/999\/jobs/)) + .resolves(jsonResponse({ jobs: [{ id: 555, name: 'Publish Release' }] })); + fetchStub.withArgs(sinon.match(/actions\/jobs\/555\/logs/)).resolves(textResponse('log line 1\nlog line 2\n')); + + const logs = await getPublishJobLogs('token', 'BitGo', 'BitGoJS', 999); + assert.strictEqual(logs, 'log line 1\nlog line 2\n'); + }); + + it('throws when Publish Release job not found', async function () { + fetchStub + .withArgs(sinon.match(/actions\/runs\/999\/jobs/)) + .resolves(jsonResponse({ jobs: [{ id: 1, name: 'Other Job' }] })); + + await assert.rejects(() => getPublishJobLogs('token', 'BitGo', 'BitGoJS', 999), /No "Publish Release" job/); + }); + }); +}); diff --git a/modules/beta-tools/test/packageManager.test.ts b/modules/beta-tools/test/packageManager.test.ts new file mode 100644 index 0000000000..fbaf52e1aa --- /dev/null +++ b/modules/beta-tools/test/packageManager.test.ts @@ -0,0 +1,39 @@ +import assert from 'assert'; +import { writeFileSync, mkdtempSync } from 'fs'; +import path from 'path'; +import os from 'os'; + +import { detectPackageManager } from '../src/packageManager'; + +describe('detectPackageManager', function () { + it('detects npm from package-lock.json', function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}'); + assert.strictEqual(detectPackageManager(tmpDir), 'npm'); + }); + + it('detects yarn from yarn.lock', function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeFileSync(path.join(tmpDir, 'yarn.lock'), ''); + assert.strictEqual(detectPackageManager(tmpDir), 'yarn'); + }); + + it('detects pnpm from pnpm-lock.yaml', function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), ''); + assert.strictEqual(detectPackageManager(tmpDir), 'pnpm'); + }); + + it('prefers pnpm when multiple lockfiles exist', function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), ''); + writeFileSync(path.join(tmpDir, 'yarn.lock'), ''); + writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}'); + assert.strictEqual(detectPackageManager(tmpDir), 'pnpm'); + }); + + it('throws when no lockfile found', function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + assert.throws(() => detectPackageManager(tmpDir), /Could not detect package manager/); + }); +}); diff --git a/modules/beta-tools/test/resolveVersions.test.ts b/modules/beta-tools/test/resolveVersions.test.ts new file mode 100644 index 0000000000..1a39cfc722 --- /dev/null +++ b/modules/beta-tools/test/resolveVersions.test.ts @@ -0,0 +1,226 @@ +import assert from 'assert'; +import { writeFileSync, mkdtempSync } from 'fs'; +import path from 'path'; +import os from 'os'; +import sinon from 'sinon'; + +import { resolveVersions } from '../src/resolveVersions'; + +const SCOPE = '@bitgo-beta'; +const REGISTRY = 'https://registry.npmjs.org'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function failResponse(status = 404): Response { + return new Response('Not Found', { status, statusText: 'Not Found' }); +} + +describe('resolveVersions', function () { + let fetchStub: sinon.SinonStub; + + beforeEach(function () { + fetchStub = sinon.stub(global, 'fetch'); + // Default: reject unknown requests + fetchStub.rejects(new Error('Unexpected fetch call')); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('megapackage strategy (default)', function () { + it('resolves versions from the megapackage dependencies', async function () { + fetchStub.withArgs(`${REGISTRY}/@bitgo-beta/bitgo/beta`).resolves( + jsonResponse({ + version: '14.2.1-beta.1823', + dependencies: { + '@bitgo-beta/sdk-core': '8.2.1-beta.1613', + '@bitgo-beta/statics': '15.1.1-beta.1616', + '@bitgo-beta/utxo-lib': '8.0.3-beta.1615', + }, + }) + ); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/statics'], + tag: 'beta', + scope: SCOPE, + }); + + assert.strictEqual(result.versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.1613'); + assert.strictEqual(result.versions.get('@bitgo-beta/statics'), '15.1.1-beta.1616'); + assert.strictEqual(result.versions.size, 2); + }); + + it('falls back to dist-tags for packages not in the megapackage', async function () { + fetchStub.withArgs(`${REGISTRY}/@bitgo-beta/bitgo/beta`).resolves( + jsonResponse({ + version: '14.2.1-beta.1823', + dependencies: { + '@bitgo-beta/sdk-core': '8.2.1-beta.1613', + }, + }) + ); + fetchStub + .withArgs(`${REGISTRY}/-/package/@bitgo-beta/abstract-cosmos/dist-tags`) + .resolves(jsonResponse({ beta: '1.0.1-beta.500', latest: '1.0.0' })); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/abstract-cosmos'], + tag: 'beta', + scope: SCOPE, + }); + + assert.strictEqual(result.versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.1613'); + assert.strictEqual(result.versions.get('@bitgo-beta/abstract-cosmos'), '1.0.1-beta.500'); + }); + + it('falls back entirely to dist-tags if megapackage fetch fails', async function () { + fetchStub.withArgs(`${REGISTRY}/@bitgo-beta/bitgo/beta`).resolves(failResponse()); + fetchStub + .withArgs(`${REGISTRY}/-/package/@bitgo-beta/sdk-core/dist-tags`) + .resolves(jsonResponse({ beta: '8.2.1-beta.1613', latest: '8.2.0' })); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core'], + tag: 'beta', + scope: SCOPE, + }); + + assert.strictEqual(result.versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.1613'); + }); + + it('skips packages with no dist-tag and not in megapackage', async function () { + fetchStub.withArgs(`${REGISTRY}/@bitgo-beta/bitgo/beta`).resolves( + jsonResponse({ + version: '14.2.1-beta.1823', + dependencies: { '@bitgo-beta/sdk-core': '8.2.1-beta.1613' }, + }) + ); + fetchStub.withArgs(`${REGISTRY}/-/package/@bitgo-beta/missing/dist-tags`).resolves(failResponse()); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/missing'], + tag: 'beta', + scope: SCOPE, + }); + + assert.strictEqual(result.versions.size, 1); + assert.strictEqual(result.versions.has('@bitgo-beta/missing'), false); + }); + }); + + describe('GitHub Actions strategy', function () { + const VERIFY_LOGS = [ + '2026-02-25T13:05:23.0000000Z @bitgo-beta/sdk-core matches expected version 8.2.1-beta.1613', + '2026-02-25T13:05:23.1000000Z @bitgo-beta/statics matches expected version 15.1.1-beta.1616', + '2026-02-25T13:05:23.2000000Z @bitgo-beta/utxo-lib matches expected version 8.0.3-beta.1615', + ].join('\n'); + + function stubGitHubApi(): void { + fetchStub.withArgs(sinon.match(/workflows\/publish.yml\/runs/)).resolves( + jsonResponse({ + workflow_runs: [{ id: 99, created_at: '2026-02-25T12:47:06Z', head_sha: 'abc123' }], + }) + ); + fetchStub + .withArgs(sinon.match(/actions\/runs\/99\/jobs/)) + .resolves(jsonResponse({ jobs: [{ id: 555, name: 'Publish Release' }] })); + fetchStub.withArgs(sinon.match(/actions\/jobs\/555\/logs/)).resolves(new Response(VERIFY_LOGS, { status: 200 })); + } + + it('resolves versions from publish run logs when githubToken is set', async function () { + stubGitHubApi(); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/statics'], + tag: 'beta', + scope: SCOPE, + githubToken: 'ghp_test', + }); + + assert.strictEqual(result.versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.1613'); + assert.strictEqual(result.versions.get('@bitgo-beta/statics'), '15.1.1-beta.1616'); + assert.strictEqual(result.versions.size, 2); + // Verify it called GitHub, not npm registry + assert.ok(fetchStub.calledWith(sinon.match(/^https:\/\/api\.github\.com\//))); + assert.ok(!fetchStub.calledWith(sinon.match(/^https:\/\/registry\.npmjs\.org\//))); + }); + + it('warns about packages not in the publish run', async function () { + stubGitHubApi(); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/nonexistent'], + tag: 'beta', + scope: SCOPE, + githubToken: 'ghp_test', + }); + + assert.strictEqual(result.versions.size, 1); + assert.strictEqual(result.versions.has('@bitgo-beta/nonexistent'), false); + }); + + it('throws when GitHub API fails (does not fall back to registry)', async function () { + fetchStub + .withArgs(sinon.match(/workflows\/publish.yml\/runs/)) + .resolves(new Response('Forbidden', { status: 403, statusText: 'Forbidden' })); + + await assert.rejects( + () => + resolveVersions({ + packages: ['@bitgo-beta/sdk-core'], + tag: 'beta', + scope: SCOPE, + githubToken: 'ghp_expired', + }), + /403 Forbidden/ + ); + }); + }); + + describe('from manifest', function () { + it('reads versions from JSON file', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + const manifestPath = path.join(tmpDir, 'versions.json'); + writeFileSync( + manifestPath, + JSON.stringify({ + '@bitgo-beta/sdk-core': '8.2.1-beta.788', + '@bitgo-beta/statics': '15.1.1-beta.791', + }) + ); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/statics'], + tag: 'beta', + scope: SCOPE, + manifestPath, + }); + + assert.strictEqual(result.versions.get('@bitgo-beta/sdk-core'), '8.2.1-beta.788'); + assert.strictEqual(result.versions.get('@bitgo-beta/statics'), '15.1.1-beta.791'); + }); + + it('warns about missing packages in manifest', async function () { + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'beta-tools-test-')); + const manifestPath = path.join(tmpDir, 'versions.json'); + writeFileSync(manifestPath, JSON.stringify({ '@bitgo-beta/sdk-core': '8.2.1-beta.788' })); + + const result = await resolveVersions({ + packages: ['@bitgo-beta/sdk-core', '@bitgo-beta/statics'], + tag: 'beta', + scope: SCOPE, + manifestPath, + }); + + assert.strictEqual(result.versions.size, 1); + assert.strictEqual(result.versions.has('@bitgo-beta/statics'), false); + }); + }); +}); diff --git a/modules/beta-tools/tsconfig.json b/modules/beta-tools/tsconfig.json new file mode 100644 index 0000000000..6375849b54 --- /dev/null +++ b/modules/beta-tools/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"], + "strict": true + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 235ca086b9..20352f2ce2 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -1,3 +1,15 @@ +/** + * Prepare packages for beta/alpha release under an alternate npm scope. + * + * This script transforms the entire monorepo: + * 1. Re-scopes all `@bitgo/*` packages to `@bitgo-beta/*` (or the target scope) + * 2. Fetches npm dist-tags and computes next prerelease versions + * 3. Pins all inter-module dependency versions to exact values (removes ranges) + * + * See docs/beta-release.md for detailed documentation. + * + * Usage: npx tsx scripts/prepare-release.ts [preid] --scope [scope] --root-dir [dir] + */ import assert from 'node:assert'; import { readFileSync, writeFileSync } from 'fs'; import path from 'path'; diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 968aa19595..63617d8625 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -19,6 +19,9 @@ { "path": "./modules/account-lib" }, + { + "path": "./modules/beta-tools" + }, { "path": "./modules/bitgo" }, diff --git a/yarn.lock b/yarn.lock index e027ccdae7..52139a9314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6423,7 +6423,7 @@ dependencies: "@types/node" "*" -"@types/sinon@^10.0.11": +"@types/sinon@^10.0.0", "@types/sinon@^10.0.11": version "10.0.20" resolved "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.20.tgz" integrity sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg== @@ -6591,6 +6591,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.0": + version "17.0.35" + resolved "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + "@types/yauzl@^2.9.1": version "2.10.3" resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz"