diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39b27ae..1a0da4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,13 @@ jobs: - name: Show status run: bit status + - name: Run tests + # Includes the hoisted-layout-integrity spec, which runs the + # bundled @pnpm/napi engine for real and asserts the layout it + # produces resolves version-conflicted dependencies correctly + # (the bug class that shipped broken bit installs before 3.1.2). + run: bit test + - name: Tag components (${{ inputs.release-type }}) run: bit tag --build --${{ inputs.release-type }} --message "bump ${{ inputs.release-type }} version" @@ -84,6 +91,26 @@ jobs: git push origin HEAD:${{ github.ref_name }} git push origin "v${{ steps.version.outputs.version }}" + - name: Verify the published package ships the tested engine + # bvm is published as a bundle whose only runtime dependency is + # @pnpm/napi. 3.1.1 shipped depending on an older engine than the + # workspace was built and tested with (the bundle env hardcoded + # the version), so compare the published stamp against the + # version installed in this workspace and fail loudly on drift. + run: | + EXPECTED=$(node -p "require('@pnpm/napi/package.json').version") + for i in $(seq 1 30); do + PUBLISHED=$(npm view "@teambit/bvm@${{ steps.version.outputs.version }}" dependencies.@pnpm/napi 2>/dev/null || true) + [ -n "$PUBLISHED" ] && break + echo "waiting for @teambit/bvm@${{ steps.version.outputs.version }} to appear on the registry ($i)" + sleep 10 + done + echo "workspace engine: $EXPECTED, published dependency: $PUBLISHED" + if [ "$PUBLISHED" != "$EXPECTED" ]; then + echo "Published @teambit/bvm depends on @pnpm/napi@$PUBLISHED but was built and tested with $EXPECTED" >&2 + exit 1 + fi + - name: Create GitHub release with generated notes env: GH_TOKEN: ${{ github.token }} diff --git a/teambit.bvm/install/hoisted-layout-integrity.spec.ts b/teambit.bvm/install/hoisted-layout-integrity.spec.ts new file mode 100644 index 0000000..f95e39a --- /dev/null +++ b/teambit.bvm/install/hoisted-layout-integrity.spec.ts @@ -0,0 +1,107 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// This spec deliberately does NOT mock @pnpm/napi: it runs the real +// engine — the same one bvm ships — and asserts the hoisted layout it +// produces resolves version-conflicted dependencies correctly. +// +// Regression context: the engine bundled up to bvm 3.1.1 nested a +// version-conflicted package's transitive dependencies under only one +// of its dependents, so requires through any other dependent resolved +// the wrong (root-hoisted) version. In bit installs that surfaced as +// `ERR_IMPORT_ATTRIBUTE_MISSING` at startup — an ESM `execa` loading +// the root's CommonJS `npm-run-path`. This fixture reproduces the +// same shape with `parse-entities`: the root holds v2 (whose +// `character-entities-legacy` is v1) while two local packages both +// depend on v4 (which needs `character-entities-legacy` v3). +const nodeApi = require('@pnpm/napi') as typeof import('@pnpm/napi'); + +/** + * Resolve `depName` from `fromDir` the way Node.js does: walk up the + * directory tree probing each `node_modules/`. A plain + * directory walk sidesteps `exports`-map restrictions that make + * `require.resolve('/package.json')` fail for ESM-only packages. + */ +function resolveDepVersion(rootDir: string, fromDir: string, depName: string): string | undefined { + let current = fromDir; + for (;;) { + const candidate = path.join(current, 'node_modules', depName, 'package.json'); + if (fs.existsSync(candidate)) { + return JSON.parse(fs.readFileSync(candidate, 'utf8')).version; + } + if (current === rootDir) return undefined; + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +describe('hoisted layout integrity', () => { + let tempDir: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bvm-hoist-integrity-')); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('keeps a conflicting transitive dep reachable from every dependent', async () => { + const manifest = { + name: 'hoist-integrity-fixture', + version: '0.0.0', + dependencies: { + 'parse-entities': '2.0.0', + 'pkg-a': 'file:./packages/pkg-a', + 'pkg-b': 'file:./packages/pkg-b', + }, + }; + for (const pkg of ['pkg-a', 'pkg-b']) { + const pkgDir = path.join(tempDir, 'packages', pkg); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: pkg, version: '1.0.0', dependencies: { 'parse-entities': '4.0.2' } }), + ); + } + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(manifest, null, 2)); + + await nodeApi.install( + { + dir: tempDir, + projects: [{ rootDir: tempDir, manifest }], + registries: { default: 'https://registry.npmjs.org/' }, + storeDir: path.join(tempDir, 'store'), + cacheDir: path.join(tempDir, 'cache'), + nodeLinker: 'hoisted', + enableGlobalVirtualStore: false, + minimumReleaseAge: 0, + ignoreScripts: true, + }, + () => {}, + ); + + // The root's parse-entities@2 wins the root slot, and its own + // character-entities-legacy v1 hoists next to it. + const rootParseEntities = resolveDepVersion(tempDir, tempDir, 'parse-entities'); + expect(rootParseEntities).toMatch(/^2\./); + + // Every dependent of the conflict-nested parse-entities@4 must + // resolve v4's own transitive deps — not the root-hoisted v1 + // copies. Before the fix only one of pkg-a / pkg-b got them. + for (const pkg of ['pkg-a', 'pkg-b']) { + const pkgDir = path.join(tempDir, 'node_modules', pkg); + const nested = resolveDepVersion(tempDir, pkgDir, 'parse-entities'); + expect(`${pkg}: parse-entities@${nested}`).toBe(`${pkg}: parse-entities@4.0.2`); + + const nestedDir = path.join(pkgDir, 'node_modules', 'parse-entities'); + const legacyBase = fs.existsSync(nestedDir) ? nestedDir : pkgDir; + const legacy = resolveDepVersion(tempDir, legacyBase, 'character-entities-legacy'); + expect(`${pkg}: character-entities-legacy@${legacy}`).toMatch( + new RegExp(`^${pkg}: character-entities-legacy@3\\.`), + ); + } + }, 180_000); +});