Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b972ea2
fix(install): clear error when a component depends on an unpublished …
davidfirst Jun 15, 2026
16b5328
fix(install): refetch unbuilt update-dependents before failing; use s…
davidfirst Jun 16, 2026
7d5916c
fix(install): treat Skipped build status as built for update-dependen…
davidfirst Jun 16, 2026
7a54a97
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 16, 2026
8b4c264
fix(install): enrich the unpublished-dependency error only on package…
davidfirst Jun 16, 2026
704078f
docs(install): correct rationale comment for matching the package-man…
davidfirst Jun 16, 2026
dff5520
fix(install): don't assert 'update-dependent' in the unpublished-snap…
davidfirst Jun 16, 2026
b85689b
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 16, 2026
8949353
fix(install): only enrich the package-manager error when it's ERR_PNP…
davidfirst Jun 17, 2026
d768cb1
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 19, 2026
43a837a
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 19, 2026
f16463d
refactor(install): generalize unpublished-snap-dependency install error
davidfirst Jun 22, 2026
7157c75
test(e2e): scope verdaccio registry to workspace config (#10435)
davidfirst Jun 22, 2026
cf0add9
bump teambit version to 1.13.230 [skip ci]
Jun 22, 2026
0d90fea
fix(diff): avoid phantom peer-dependency change for component-level p…
davidfirst Jun 22, 2026
8a217c7
bump teambit version to 1.13.231 [skip ci]
Jun 22, 2026
fe8470e
refactor(install): simplify unpublished-snap culprit matching
davidfirst Jun 22, 2026
8c35cce
Merge remote-tracking branch 'origin/master' into fix-install-unpubli…
davidfirst Jun 22, 2026
d11f297
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 22, 2026
09fe14d
Merge branch 'master' into fix-install-unpublished-update-dependent
davidfirst Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions e2e/harmony/lanes/update-dependents-cascade.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1179,4 +1179,81 @@ describe('local snap cascades updateDependents on the lane', function () {
expect(bitMap).to.not.have.property('comp2');
});
});

// ---------------------------------------------------------------------------------------------
// Scenario 21: a visible workspace component depends on a hidden updateDependent whose build never
// succeeded (e.g. Ripple failed after "snap updates"), so it was never published to the registry.
// The updateDependent is not checked out, so the package manager must fetch it from the registry —
// but there is no package, so it fails with the cryptic "No matching version found". `bit install`
// should instead surface an actionable error pointing at the `bit import` remediation.
//
// Uses the npm CI registry so the unpublished `0.0.0-<hash>` genuinely 404s (with a local file
// remote the dep is filtered out of the install manifest and never fetched, so it can't reproduce).
// ---------------------------------------------------------------------------------------------
(supportNpmCiRegistryTesting ? describe : describe.skip)(
'scenario 21: install errors clearly when a workspace component depends on an unpublished updateDependent',
() => {
let npmCiRegistry: NpmCiRegistry;
before(async () => {
helper.scopeHelper.destroy();
helper = new Helper({ scopesOptions: { remoteScopeWithDot: true } });
helper.scopeHelper.setWorkspaceWithRemoteScope();
npmCiRegistry = new NpmCiRegistry(helper);
await npmCiRegistry.init();
npmCiRegistry.configureCiInPackageJsonHarmony();
helper.fixtures.populateComponents(2); // comp1 -> comp2
helper.command.tagAllComponents(); // tag + publish comp1@0.0.1, comp2@0.0.1 to the registry
helper.command.export();

// comp1 becomes a visible lane component (it depends on comp2).
helper.scopeHelper.reInitWorkspace();
helper.scopeHelper.addRemoteScope(helper.scopes.remotePath);
npmCiRegistry.setResolver();
helper.command.createLane();
helper.command.importComponent('comp1');
helper.command.snapAllComponentsWithoutBuild('--unmodified');
helper.command.export();

// "snap updates": comp2 enters lane.updateDependents via the bare-scope cascade. build:false
// means the cascade snap is never built/published — the state Ripple leaves on a build failure.
// The cascade also re-snaps comp1 so its comp2 dep points at the new (unpublished) updateDependent.
const bareSnap = helper.scopeHelper.getNewBareScope('-bare-snap-updates');
helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, bareSnap.scopePath);
await helper.snapping.snapFromScope(
bareSnap.scopePath,
[{ componentId: `${helper.scopes.remote}/comp2`, message: 'snap updates' }],
{ lane: `${helper.scopes.remote}/dev`, updateDependents: true, push: true }
);

// fresh workspace: importing the lane checks out the visible comp1 (comp2 stays hidden).
helper.scopeHelper.reInitWorkspace();
helper.scopeHelper.addRemoteScope(helper.scopes.remotePath);
npmCiRegistry.setResolver();
helper.command.importLane('dev', '-x');
});
after(() => {
npmCiRegistry.destroy();
helper.scopeHelper.destroy();
helper = new Helper();
});

it('comp2 stays a hidden updateDependent — only comp1 is in the bitmap', () => {
const bitMap = helper.bitMap.read();
expect(bitMap).to.have.property('comp1');
expect(bitMap).to.not.have.property('comp2');
});

it('bit install fails with an actionable error naming the component and the bit import remediation', () => {
let output = '';
try {
helper.command.install();
} catch (err: any) {
output = `${err.message || ''}${err.stdout?.toString() || ''}${err.stderr?.toString() || ''}`;
}
expect(output, 'bit install should have failed').to.have.string('never published');
expect(output, 'error should name the problematic component').to.have.string('comp2');
expect(output, 'error should suggest the bit import remediation').to.have.string('bit import');
});
}
);
});
2 changes: 2 additions & 0 deletions scopes/workspace/install/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { DependencyTypeNotSupportedInPolicy } from './dependency-type-not-supported-in-policy';
export { UnpublishedComponentDependency } from './unpublished-component-dependency';
export type { UnpublishedSnapDependency } from './unpublished-component-dependency';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { BitError } from '@teambit/bit-error';
import { errorSymbol, formatItem } from '@teambit/cli';

export type UnpublishedSnapDependency = {
/** the depended-on component id (without version) */
id: string;
/** the snap version (hash) that has no published package */
version: string;
/** workspace component ids that depend on it */
dependents: string[];
};

/**
* thrown during `bit install` when a checked-out workspace component depends on another component that
* isn't checked out and is pinned to a snap that was never published to the registry, so the package
* manager can't find it. the usual cause is a build that failed or hasn't completed yet (e.g. a hidden
* lane "update-dependent" re-snapped by "snap updates"), but the message stays generic on purpose since
* the snap may no longer be tracked as an update-dependent (e.g. after the lane was forked).
*/
export class UnpublishedComponentDependency extends BitError {
constructor(readonly unpublished: UnpublishedSnapDependency[]) {
super(UnpublishedComponentDependency.formatMessage(unpublished));
}

private static formatMessage(unpublished: UnpublishedSnapDependency[]): string {
const list = unpublished
.map(({ id, version, dependents }) => {
const shortVersion = version.substring(0, 9);
const requiredBy = dependents.join(', ');
return formatItem(`${id} (${shortVersion}) required by: ${requiredBy}`, errorSymbol);
})
.join('\n');
const importCommand = `bit import ${unpublished.map(({ id }) => id).join(' ')}`;
return `unable to install the following component(s) — they're not checked out in your workspace and are pinned to a snap that was never published to the registry, so there's no package to fetch and no source to link:

${list}

this usually means the component's build failed or hasn't completed yet.
to resolve, import it into your workspace so it's linked from source instead of fetched from the registry:

${importCommand}`;
}
}
87 changes: 74 additions & 13 deletions scopes/workspace/install/install.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { VariantsMain } from '@teambit/variants';
import { VariantsAspect } from '@teambit/variants';
import type { Component } from '@teambit/component';
import { ComponentID, ComponentMap } from '@teambit/component';
import { isSnap } from '@teambit/component-version';
import { PackageJsonFile } from '@teambit/component.sources';
import { updateJsoncPreservingFormatting } from '@teambit/toolbox.json.jsonc-utils';
import { createLinks } from '@teambit/dependencies.fs.linked-dependencies';
Expand Down Expand Up @@ -67,7 +68,8 @@ import { BundlerAspect } from '@teambit/bundler';
import type { UiMain } from '@teambit/ui';
import { UIAspect } from '@teambit/ui';
import { EXTERNAL_PM_POSTINSTALL_SCRIPT } from '@teambit/host-initializer';
import { DependencyTypeNotSupportedInPolicy } from './exceptions';
import { DependencyTypeNotSupportedInPolicy, UnpublishedComponentDependency } from './exceptions';
import type { UnpublishedSnapDependency } from './exceptions';
import { InstallAspect } from './install.aspect';
import { pickOutdatedPkgs } from './pick-outdated-pkgs';
import { LinkCommand } from './link';
Expand Down Expand Up @@ -430,18 +432,28 @@ export class InstallMain {
// are not added to the manifests.
// This is an issue when installation is done using root components.
hasMissingLocalComponents = hasRootComponents && hasComponentsFromWorkspaceInMissingDeps(current);
const { dependenciesChanged } = await installer.installComponents(
this.workspace.path,
current.manifests,
mergedRootPolicy,
current.componentDirectoryMap,
{
linkedDependencies,
installTeambitBit: false,
forcedHarmonyVersion,
},
pmInstallOptions
);
let installResult: { dependenciesChanged: boolean };
try {
installResult = await installer.installComponents(
this.workspace.path,
current.manifests,
mergedRootPolicy,
current.componentDirectoryMap,
{
linkedDependencies,
installTeambitBit: false,
forcedHarmonyVersion,
},
pmInstallOptions
);
} catch (err: any) {
// when the package manager can't find a version, the culprit is usually a component dependency that
// resolves to a snap which was never published (e.g. a hidden lane update-dependent whose Ripple build
// failed or hasn't completed). replace the cryptic "No matching version found" with an actionable
// message. re-throws the original error otherwise.
throw this.enrichUnpublishedSnapDepError(err, current.componentDirectoryMap.components);
}
const { dependenciesChanged } = installResult;
this.workspace.inInstallAfterPmContext = true;
let cacheCleared = false;
await this.linkCodemods(compDirMap);
Expand Down Expand Up @@ -516,6 +528,55 @@ export class InstallMain {
return nonLoadedEnvs.length > 0;
}

/**
* Called only when the package manager install failed. A "No matching version found" failure usually points
* at a component dependency that resolves to a snap which was never published — its build failed or hasn't
* completed yet (commonly a hidden lane "update-dependent" re-snapped by "snap updates" / Ripple CI, but not
* necessarily). Such a dep isn't checked out, so it can't be linked from source and there's no package to
* fetch. When the failing package matches one of those deps, return an actionable error (pointing at
* `bit import`); otherwise return the original error unchanged.
*
* We resolve the culprit by matching the package manager error against the resolved component dependencies
* rather than the lane's `updateDependents`, for two reasons: (1) the failing dep is not a workspace component,
* so its component-id (needed for the `bit import` remediation) is only recoverable from the resolved deps of
* the checked-out components — there's no package-name→id map for it; (2) `updateDependents` is unreliable —
* forking a lane drops it and `reset` moves entries out of it, so a never-published snap can still be pinned
* while no longer tracked there. The scan reads already-loaded in-memory dep data (no fetch).
*/
private enrichUnpublishedSnapDepError(err: Error, components: Component[]): Error {
// only act on the package manager's "no matching version" failure. other codes (auth, network, FETCH_404,
// registry outages) can mention the same package but signal a real problem we must not mask. the repo treats
// only ERR_PNPM_NO_MATCHING_VERSION as the "unpublished snap" signal (see lockfile-deps-graph-converter).
// pnpm errors are wrapped by pnpmErrorToBitError, which keeps the original error (with its code) on `cause`.
const pnpmCode = (err as any)?.cause?.code ?? (err as any)?.code;
const errMessage = err.message || '';
const isNoMatchingVersion =
pnpmCode === 'ERR_PNPM_NO_MATCHING_VERSION' || (!pnpmCode && errMessage.includes('No matching version found'));
if (!isNoMatchingVersion) return err;

// checked-out components are linked from source, so they're never fetched from the registry.
const workspaceIds = this.workspace.listIds();

const unpublished = new Map<string, UnpublishedSnapDependency>();
for (const component of components) {
for (const dep of this.dependencyResolver.getComponentDependencies(component)) {
const depId = dep.componentId;
if (!depId.version || !isSnap(depId.version)) continue;
if (workspaceIds.hasWithoutVersion(depId)) continue;
// pinpoint the exact dependency the package manager failed on: its snap hash (globally unique) appears
// verbatim in the error (the manifest pins `0.0.0-<hash>`, so the raw hash is a substring).
if (!errMessage.includes(depId.version)) continue;
const key = depId.toStringWithoutVersion();
Comment thread
davidfirst marked this conversation as resolved.
const dependent = component.id.toStringWithoutVersion();
const entry = unpublished.get(key);
if (entry) entry.dependents.push(dependent);
else unpublished.set(key, { id: key, version: depId.version, dependents: [dependent] });
}
}
if (!unpublished.size) return err;
return new UnpublishedComponentDependency([...unpublished.values()]);
}

/**
* This function is very important to fix some issues that might happen during the installation process.
* The case is the following:
Expand Down