Skip to content

node-workspace: always-link-local: false is ignored, so dependents are pulled into a release even when their declared range already covers the new version #2876

Description

@jaenyf

Summary

always-link-local is documented as limiting local dependency bumps to the SemVer range. The option has had no effect since v17. It is parsed from the manifest, carried through ManifestOptions, assigned in the NodeWorkspace constructor, and never read again.

The result is that node-workspace has no way to express "leave this package alone, its declared range already covers the new version", which is what keeps me from enabling the plugin at all.

Environment

  • release-please 17.11.1, reached through googleapis/release-please-action@v5, which pins release-please: ^17.6.0
  • npm workspaces monorepo, bun as the package manager
  • Repository: https://github.com/jaenyf/time-provider

Configuration

One core package and nine plugins and addons, all released from a single manifest release PR.

release-please-config.json, shown as it stands today. There is no plugins entry, because I have not enabled node-workspace, for the reason set out below:

{
  "changelog-path": "CHANGELOG.md",
  "separate-pull-requests": false,
  "include-component-in-tag": true,
  "bump-minor-pre-major": true,
  "packages": {
    "packages/addon-animation-frame": { "release-type": "node", "component": "addon-animation-frame" },
    "packages/addon-cron":            { "release-type": "node", "component": "addon-cron" },
    "packages/addon-eta":             { "release-type": "node", "component": "addon-eta" },
    "packages/core":                  { "release-type": "node", "component": "core" },
    "packages/plugin-dayjs":          { "release-type": "node", "component": "plugin-dayjs" },
    "packages/plugin-luxon":          { "release-type": "node", "component": "plugin-luxon" },
    "packages/plugin-moment":         { "release-type": "node", "component": "plugin-moment" },
    "packages/plugin-moment-timezone":{ "release-type": "node", "component": "plugin-moment-timezone" },
    "packages/plugin-native":         { "release-type": "node", "component": "plugin-native" },
    "packages/plugin-temporal":       { "release-type": "node", "component": "plugin-temporal" }
  }
}

Every plugin and addon depends on core the same way. Taking packages/plugin-native/package.json as the representative case:

{
  "name": "@time-provider/plugin-native",
  "version": "0.4.1",
  "devDependencies": { "@time-provider/core": "workspace:*" },
  "peerDependencies": { "@time-provider/core": "^1.3.0" }
}

Current state of the workspace, with core at 1.4.0:

package version peer range on core satisfied by core 1.4.0
@time-provider/core 1.4.0
@time-provider/addon-animation-frame 0.2.0 ^1.3.0 yes
@time-provider/addon-cron 0.1.0 ^1.4.0 yes
@time-provider/addon-eta 0.1.0 ^1.3.0 yes
@time-provider/plugin-dayjs 0.4.1 ^1.3.0 yes
@time-provider/plugin-luxon 0.4.1 ^1.3.0 yes
@time-provider/plugin-moment 0.4.1 ^1.3.0 yes
@time-provider/plugin-moment-timezone 0.3.0 ^1.3.0 yes
@time-provider/plugin-native 0.4.1 ^1.3.0 yes
@time-provider/plugin-temporal 0.4.1 ^1.3.0 yes

Those ranges are maintained by hand today, which is the toil I was hoping the plugin would remove. The last sweep was jaenyf/time-provider@b5cc641, and addon-cron sitting at ^1.4.0 while the rest sit at ^1.3.0 is the drift that comes with doing it manually.

What the docs promise

docs/manifest-releaser.md#L537:

When using the node-workspace tool, breaking versions bumps will be included in your update pull request. If you don't agree with this behavior and would only like your local dependencies bumped if they are within the SemVer range, you can set the "always-link-local" option to false in your manifest config.

That is close to the rule I want. When core releases 1.4.0, a package declaring ^1.3.0 is already covered and has no reason to be touched. A package whose range no longer covers the new version does need a bump, and its range needs updating.

The option has no consumer

At v17.11.1 the only three occurrences of alwaysLinkLocal in src/plugins/node-workspace.ts are the interface field (L61), the private field (L73) and the assignment (L87):

this.alwaysLinkLocal = options.alwaysLinkLocal === false ? false : true;

Nothing reads the field afterwards.

It had a consumer until v16. node-workspace.ts#L137-L141 at v16.0.0:

this.packageGraph = new PackageGraph(
  allPackages,
  'allDependencies',
  this.alwaysLinkLocal
);

The v17 rewrite replaced Lerna's PackageGraph with the plugin's own DependencyGraph, and the option lost its only consumer along with it.

How a dependent enters the release

I read the code rather than enabling the plugin, so the following is traced from source rather than observed from a run. Line references are v17.11.1.

  1. buildGraph (node-workspace.ts L396-L415) creates an edge from each plugin to core, because the edge names come from combineDeps.
  2. buildGraphOrder (workspace.ts L415) inverts that graph into dependency name to dependents.
  3. visitPostOrder (workspace.ts L442) follows every dependent edge unconditionally. It takes (graph, name, visited, path), consults no version and no range, and never mentions alwaysLinkLocal.
  4. The dependent therefore lands in orderedPackages.
  5. buildUpdatedVersions (workspace.ts L114) then patch-bumps it, because it has no existing candidate of its own. For NodeWorkspace that fallback is new PatchVersionUpdate().bump(version).

Step 1 is what makes this unavoidable in my setup. combineDeps (L429) always includes devDependencies:

private combineDeps(packageJson: Package): Record<string, string> {
  return {
    ...(packageJson.dependencies ?? {}),
    ...(packageJson.devDependencies ?? {}),
    ...(packageJson.optionalDependencies ?? {}),
    ...(this.updatePeerDependencies ? packageJson.peerDependencies ?? {} : {}),
  };
}

The "@time-provider/core": "workspace:*" devDependency is enough to establish the edge on its own, whether or not updatePeerDependencies is set. So a core-only release would include all nine packages as forced patch bumps, and always-link-local: false would not change that. With updatePeerDependencies: true their peer ranges would additionally be rewritten to ^1.4.0, including for the eight that ^1.3.0 already covered.

Expected behaviour

With "always-link-local": false:

  • a dependent with no independent release candidate, whose declared range already satisfies the new dependency version, is not pulled into the release, and nothing downstream of it is pulled in through it
  • a dependent whose declared range no longer satisfies the new dependency version is bumped, and its range is updated

A package that has its own release candidate is unaffected either way. The option should decide whether a dependency bump drags a package in, not whether that package may release on its own.

With the default true, behaviour stays exactly as it is today.

Applied to the table above, a core 1.4.0 release touches none of the nine. A core 2.0.0 release touches all nine, because ^1.3.0 stops covering it.

Where a fix would have to sit

The SemVer decision belongs where dependent edges are chosen for traversal, but the traversal currently runs before any proposed version exists. buildGraphOrder is called at workspace.ts L111 and buildUpdatedVersions only at L114, and visitPostOrder has no version parameter to test against.

A fix therefore needs either to make the proposed dependency versions available to graph traversal, or to carry enough dependency and range information in the graph to filter edges before visiting them. Skipping a dependent has to skip its subtree as well, otherwise transitive dependents come back in through it.

One thing that may make the first option cheaper than it looks: for packages that already have a release candidate, the proposed version is known before traversal, since candidatesByPackage is built at L107 and each entry carries pullRequest.version. In the scenario above, core is exactly such a package.

Suggested regression tests

  • core 1.3.0 to 1.4.0, dependent declares ^1.3.0, always-link-local: false, dependent has no independent candidate. Expected: the dependent is not returned by the workspace plugin.
  • core 1.3.0 to 2.0.0, dependent declares ^1.3.0, always-link-local: false. Expected: the dependent is included and its declared range is updated.
  • the same two with always-link-local: true, as a control that the default does not regress.

Either way

If the semver-aware behaviour is not wanted, always-link-local should be dropped from the config schema and the docs rather than left in place, since it currently reads as a supported option that silently does nothing.

Environment details

  • OS: ubuntu-latest, GitHub-hosted runner. The reproduction below is OS-independent.
  • Node.js version: Node 24, the node24 runtime declared by googleapis/release-please-action@v5 (action.yml L78)
  • npm version: not involved. The action ships its dependencies bundled in dist/index.js, and the workspace itself uses bun 1.3.14.
  • release-please version: 17.6.0, the version release-please-action@v5 resolves for its ^17.6.0 dependency in its package-lock.json. Source references in the report above are v17.11.1, where the relevant lines are unchanged.

Steps to reproduce

1. The option has no consumer. No setup, and this is the part I verified directly.

git clone --depth 1 --branch v17.11.1 https://github.com/googleapis/release-please
cd release-please
grep -rn "alwaysLinkLocal\|always-link-local" src/

Output at v17.11.1, and identical at v17.6.0 apart from manifest.ts:1469 in place of 1471:

src/manifest.ts:202:  alwaysLinkLocal?: boolean;
src/manifest.ts:270:  'always-link-local'?: boolean;
src/manifest.ts:351:   * @param {boolean} manifestOptions.alwaysLinkLocal Option for the node-workspace
src/manifest.ts:474:   * @param {boolean} manifestOptions.alwaysLinkLocal Option for the node-workspace
src/manifest.ts:1471:    alwaysLinkLocal: config['always-link-local'],
src/factories/plugin-factory.ts:44:  alwaysLinkLocal?: boolean;
src/plugins/node-workspace.ts:61:  alwaysLinkLocal?: boolean;
src/plugins/node-workspace.ts:73:  private alwaysLinkLocal: boolean;
src/plugins/node-workspace.ts:87:    this.alwaysLinkLocal = options.alwaysLinkLocal === false ? false : true;

Nine hits: two interface fields, one config key, two JSDoc lines, one config mapping, one private field and one assignment. The value is never read.

The same grep at v16.0.0 additionally returns src/plugins/node-workspace.ts:140, the this.alwaysLinkLocal argument to new PackageGraph(allPackages, 'allDependencies', this.alwaysLinkLocal), which is the consumer the v17 rewrite removed.

2. The behaviour the option was meant to control.

A manifest monorepo with two packages.

packages/core/package.json:

{ "name": "@scope/core", "version": "1.3.0" }

packages/plugin/package.json:

{
  "name": "@scope/plugin",
  "version": "0.4.1",
  "devDependencies": { "@scope/core": "workspace:*" },
  "peerDependencies": { "@scope/core": "^1.3.0" }
}

.release-please-manifest.json:

{ "packages/core": "1.3.0", "packages/plugin": "0.4.1" }

release-please-config.json:

{
  "always-link-local": false,
  "plugins": [{ "type": "node-workspace" }],
  "packages": {
    "packages/core":   { "release-type": "node", "component": "core" },
    "packages/plugin": { "release-type": "node", "component": "plugin" }
  }
}
  1. Land a single feat(core): ... commit touching only packages/core.
  2. Run release-please release-pr --repo-url=<repo> --config-file=release-please-config.json --manifest-file=.release-please-manifest.json.
  3. Core is proposed as 1.4.0, and @scope/plugin is pulled in alongside it and bumped 0.4.1 to 0.4.2 with a dependency changelog entry. Its declared ^1.3.0 already satisfies 1.4.0, and always-link-local is false.

To be straight about what I ran: step 1 is verified output, step 2 is traced from buildGraph, buildGraphOrder, visitPostOrder and buildUpdatedVersions rather than executed end to end, since I never enabled the plugin on my repository for exactly this reason.

If a failing unit test is more useful than a repo recipe, the natural home looks like test/plugins/node-workspace.ts with fixtures under test/fixtures/plugins/node-workspace/, and I am happy to open a PR with the three cases listed above.

Issue note

This issue was identified and partially written with AI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority: p3Desirable enhancement or fix. May not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions