From 8885c03a871bc4d6e983d3d160c8caed8fc0fa7e Mon Sep 17 00:00:00 2001 From: Mihir Seth Date: Thu, 9 Jul 2026 11:47:42 -0400 Subject: [PATCH 1/2] fix(bitgo): generate npm-shrinkwrap.json at pack time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm `overrides` only pins versions when bitgo is the root of an `npm install`; consumers installing bitgo as a dependency still resolve unpinned transitive versions. Ship npm-shrinkwrap.json inside the published package so those installs are pinned too. Wired as bitgo's `prepack` script (not a separate release-pipeline step) so it always runs after lerna bumps the version on disk but before the tarball is packed, keeping the shrinkwrap's top-level version in sync with whatever actually gets published. Generation happens in an isolated temp copy since `npm shrinkwrap` isn't workspace-aware and modules/bitgo/.npmrc disables package-lock. `@bitgo/*` siblings are excluded from resolution: `lerna publish` bumps and packs every package in the same operation, so a sibling's new version isn't guaranteed to be live on the registry yet when bitgo is packed — resolving it here would fail the release or pin a stale version. Consumers resolve `@bitgo/*` siblings normally at install time instead; the shrinkwrap still pins whatever those siblings pull in transitively. Generation only runs when BITGO_GENERATE_SHRINKWRAP=true (set by the release workflow), so a plain local `npm pack` doesn't force a network install of the full dependency tree. TICKET: WCN-604 --- .github/workflows/npmjs-release.yml | 16 +++++ modules/bitgo/package.json | 4 +- scripts/generate-bitgo-shrinkwrap.ts | 93 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-bitgo-shrinkwrap.ts diff --git a/.github/workflows/npmjs-release.yml b/.github/workflows/npmjs-release.yml index 29b18868a6..7c7a2fe6f3 100644 --- a/.github/workflows/npmjs-release.yml +++ b/.github/workflows/npmjs-release.yml @@ -325,6 +325,22 @@ jobs: fi echo "✅ All public package versions on rel/latest are present on npm." + # bitgo's `prepack` script generates npm-shrinkwrap.json at pack time (see + # scripts/generate-bitgo-shrinkwrap.ts). npm only honors a shipped shrinkwrap + # for installers when the registry's own metadata says hasShrinkwrap: true — + # confirm that here so a silent registry-side omission fails the release + # instead of shipping a package whose shrinkwrap consumers won't get. + - name: Verify bitgo package has shrinkwrap metadata + if: ${{ inputs.dry-run == false }} + run: | + version=$(jq -r '.version' modules/bitgo/package.json) + has_shrinkwrap=$(curl -sL "https://registry.npmjs.org/bitgo/${version}" | jq -r '.dist.hasShrinkwrap // false') + if [ "$has_shrinkwrap" != "true" ]; then + echo "::error::Published bitgo@${version} is missing hasShrinkwrap metadata on the npm registry." + exit 1 + fi + echo "✅ bitgo@${version} published with hasShrinkwrap: true." + - name: Generate version bump summary id: version-bump-summary if: inputs.dry-run == false diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 13bd25bf3b..d8c353c75e 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -36,6 +36,7 @@ "build": "yarn tsc --build --incremental --verbose .", "prepare": "npm run build", "prepublishOnly": "npm run compile", + "prepack": "tsx ../../scripts/generate-bitgo-shrinkwrap.ts", "upload-artifacts": "node scripts/upload-test-reports.js", "check-fmt": "prettier --check '**/*.{ts,js,json}'", "unprettied": "grep -R -L --include '*.ts' --include '*.js' --include '*.json' '@prettier' src test", @@ -181,6 +182,7 @@ }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", "files": [ - "dist" + "dist", + "npm-shrinkwrap.json" ] } diff --git a/scripts/generate-bitgo-shrinkwrap.ts b/scripts/generate-bitgo-shrinkwrap.ts new file mode 100644 index 0000000000..c69096478e --- /dev/null +++ b/scripts/generate-bitgo-shrinkwrap.ts @@ -0,0 +1,93 @@ +/** + * Generates modules/bitgo/npm-shrinkwrap.json so npm consumers who install `bitgo` + * as a dependency get the same pinned transitive versions that yarn `resolutions` + * (mirrored as npm `overrides` in the root package.json) already give internal builds. + * + * Runs as bitgo's `prepack` script, so it fires after lerna has bumped the version + * on disk but before the tarball is packed — the generated shrinkwrap's top-level + * name/version always matches what actually gets published. Only runs when + * BITGO_GENERATE_SHRINKWRAP=true (set by the release workflow) — otherwise a plain + * local/offline `npm pack` would force a network install of the full dependency tree. + * + * `@bitgo/*` siblings are deliberately excluded from the resolved tree: `lerna + * publish` bumps and packs every package in the same operation, so a sibling's + * newly-bumped version is not guaranteed to be live on the registry yet when bitgo + * is packed — resolving it here would either fail the release outright or silently + * pin a stale sibling version. Excluding them means consumers resolve `@bitgo/*` + * siblings normally (by then they are published); everything beneath those siblings + * still gets pinned via `overrides` for whichever version ends up installed. + * + * `npm shrinkwrap` isn't workspace-aware and modules/bitgo/.npmrc sets + * `package-lock=false`, so generation happens in an isolated temp copy outside the + * workspace, resolving against the real npm registry. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import execa from 'execa'; + +const rootDir = path.resolve(__dirname, '..'); +const bitgoDir = path.join(rootDir, 'modules/bitgo'); + +async function main() { + if (process.env.BITGO_GENERATE_SHRINKWRAP !== 'true') { + console.log('BITGO_GENERATE_SHRINKWRAP not set to "true" — skipping npm-shrinkwrap.json generation.'); + return; + } + + const rootPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8')); + const bitgoPackageJson = JSON.parse(fs.readFileSync(path.join(bitgoDir, 'package.json'), 'utf-8')); + + if (!rootPackageJson.overrides) { + throw new Error('Root package.json has no "overrides" block to propagate into the bitgo shrinkwrap.'); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitgo-shrinkwrap-')); + console.log(`Generating npm-shrinkwrap.json for bitgo@${bitgoPackageJson.version} in ${tempDir}`); + + try { + const siblingNames = Object.keys(bitgoPackageJson.dependencies ?? {}).filter((name) => name.startsWith('@bitgo/')); + if (siblingNames.length > 0) { + console.log(`Excluding ${siblingNames.length} @bitgo/* siblings from shrinkwrap resolution:`); + siblingNames.forEach((name) => console.log(` - ${name}`)); + } + + const isolatedPackageJson: Record = { ...bitgoPackageJson }; + delete isolatedPackageJson.devDependencies; + delete isolatedPackageJson.scripts; + isolatedPackageJson.dependencies = Object.fromEntries( + Object.entries(bitgoPackageJson.dependencies ?? {}).filter(([name]) => !name.startsWith('@bitgo/')) + ); + isolatedPackageJson.overrides = rootPackageJson.overrides; + + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(isolatedPackageJson, null, 2) + '\n'); + + await execa('npm', ['install', '--package-lock-only', '--ignore-scripts'], { cwd: tempDir, stdio: 'inherit' }); + await execa('npm', ['shrinkwrap'], { cwd: tempDir, stdio: 'inherit' }); + + const shrinkwrapPath = path.join(tempDir, 'npm-shrinkwrap.json'); + if (!fs.existsSync(shrinkwrapPath)) { + throw new Error(`npm shrinkwrap did not produce a file at ${shrinkwrapPath}`); + } + + // The shrinkwrap was generated against a package.json with @bitgo/* siblings + // removed, so its top-level `dependencies` no longer lists them. Restore the + // real dependency list so the shipped shrinkwrap matches what's published. + const shrinkwrap = JSON.parse(fs.readFileSync(shrinkwrapPath, 'utf-8')); + shrinkwrap.dependencies = bitgoPackageJson.dependencies; + if (shrinkwrap.packages?.['']) { + shrinkwrap.packages[''].dependencies = bitgoPackageJson.dependencies; + } + + fs.writeFileSync(path.join(bitgoDir, 'npm-shrinkwrap.json'), JSON.stringify(shrinkwrap, null, 2) + '\n'); + console.log(`Wrote ${path.join(bitgoDir, 'npm-shrinkwrap.json')}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 23ec271cccb7c1f0aa8f5cd696b6df39629ed106 Mon Sep 17 00:00:00 2001 From: Mihir Seth Date: Thu, 9 Jul 2026 11:50:20 -0400 Subject: [PATCH 2/2] ci(root): fail release if bitgo publishes without shrinkwrap metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm only honors a shipped npm-shrinkwrap.json for consumers when the registry's own metadata reports hasShrinkwrap: true — this was never confirmed against a real npm publish (only Verdaccio, which didn't set it). Add a post-publish check so a silent registry-side omission fails the release instead of shipping a bitgo package whose shrinkwrap consumers silently don't get. Set BITGO_GENERATE_SHRINKWRAP=true on the publish steps so bitgo's prepack script actually runs during release. Retry the registry check with the job's existing `retry` binary since dist metadata can lag briefly right after publish. TICKET: WCN-604 --- .github/workflows/npmjs-release.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npmjs-release.yml b/.github/workflows/npmjs-release.yml index 7c7a2fe6f3..9b6e8b7379 100644 --- a/.github/workflows/npmjs-release.yml +++ b/.github/workflows/npmjs-release.yml @@ -290,6 +290,10 @@ jobs: yarn lerna publish --sign-git-tag --sign-git-commit --include-merged-tags --conventional-commits --conventional-graduate --yes env: NPM_CONFIG_PROVENANCE: true + # Tells bitgo's `prepack` script (scripts/generate-bitgo-shrinkwrap.ts) to + # actually generate npm-shrinkwrap.json — kept opt-in so a plain local + # `npm pack` doesn't force a network install of the full dependency tree. + BITGO_GENERATE_SHRINKWRAP: true - name: Publish missing versions (recovery) if: ${{ inputs.dry-run == false && inputs.recovery-mode }} @@ -300,6 +304,7 @@ jobs: yarn lerna publish from-package --yes env: NPM_CONFIG_PROVENANCE: true + BITGO_GENERATE_SHRINKWRAP: true - name: Verify recovery published the missing versions if: ${{ inputs.dry-run == false && inputs.recovery-mode }} @@ -334,8 +339,12 @@ jobs: if: ${{ inputs.dry-run == false }} run: | version=$(jq -r '.version' modules/bitgo/package.json) - has_shrinkwrap=$(curl -sL "https://registry.npmjs.org/bitgo/${version}" | jq -r '.dist.hasShrinkwrap // false') - if [ "$has_shrinkwrap" != "true" ]; then + # Registry metadata can lag briefly right after publish, so retry before + # failing the release on what might just be propagation delay. + if ! retry --up-to 5x --every 5s -- bash -c " + has_shrinkwrap=\$(curl -sL 'https://registry.npmjs.org/bitgo/${version}' | jq -r '.dist.hasShrinkwrap // false') + [ \"\$has_shrinkwrap\" = 'true' ] + "; then echo "::error::Published bitgo@${version} is missing hasShrinkwrap metadata on the npm registry." exit 1 fi