diff --git a/.github/workflows/npmjs-release.yml b/.github/workflows/npmjs-release.yml index 29b18868a6..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 }} @@ -325,6 +330,26 @@ 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) + # 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 + 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); +});