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.
buildGraph (node-workspace.ts L396-L415) creates an edge from each plugin to core, because the edge names come from combineDeps.
buildGraphOrder (workspace.ts L415) inverts that graph into dependency name to dependents.
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.
- The dependent therefore lands in
orderedPackages.
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" }
}
}
- Land a single
feat(core): ... commit touching only packages/core.
- Run
release-please release-pr --repo-url=<repo> --config-file=release-please-config.json --manifest-file=.release-please-manifest.json.
- 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.
Summary
always-link-localis 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 throughManifestOptions, assigned in theNodeWorkspaceconstructor, and never read again.The result is that
node-workspacehas 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
googleapis/release-please-action@v5, which pinsrelease-please: ^17.6.0Configuration
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 nopluginsentry, because I have not enablednode-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.jsonas 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:
@time-provider/core@time-provider/addon-animation-frame^1.3.0@time-provider/addon-cron^1.4.0@time-provider/addon-eta^1.3.0@time-provider/plugin-dayjs^1.3.0@time-provider/plugin-luxon^1.3.0@time-provider/plugin-moment^1.3.0@time-provider/plugin-moment-timezone^1.3.0@time-provider/plugin-native^1.3.0@time-provider/plugin-temporal^1.3.0Those 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-cronsitting at^1.4.0while the rest sit at^1.3.0is the drift that comes with doing it manually.What the docs promise
docs/manifest-releaser.md#L537:
That is close to the rule I want. When core releases 1.4.0, a package declaring
^1.3.0is 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
alwaysLinkLocalinsrc/plugins/node-workspace.tsare the interface field (L61), the private field (L73) and the assignment (L87):Nothing reads the field afterwards.
It had a consumer until v16. node-workspace.ts#L137-L141 at v16.0.0:
The v17 rewrite replaced Lerna's
PackageGraphwith the plugin's ownDependencyGraph, 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.
buildGraph(node-workspace.ts L396-L415) creates an edge from each plugin to core, because the edge names come fromcombineDeps.buildGraphOrder(workspace.ts L415) inverts that graph into dependency name to dependents.visitPostOrder(workspace.ts L442) follows every dependent edge unconditionally. It takes(graph, name, visited, path), consults no version and no range, and never mentionsalwaysLinkLocal.orderedPackages.buildUpdatedVersions(workspace.ts L114) then patch-bumps it, because it has no existing candidate of its own. ForNodeWorkspacethat fallback isnew PatchVersionUpdate().bump(version).Step 1 is what makes this unavoidable in my setup.
combineDeps(L429) always includesdevDependencies:The
"@time-provider/core": "workspace:*"devDependency is enough to establish the edge on its own, whether or notupdatePeerDependenciesis set. So a core-only release would include all nine packages as forced patch bumps, andalways-link-local: falsewould not change that. WithupdatePeerDependencies: truetheir peer ranges would additionally be rewritten to^1.4.0, including for the eight that^1.3.0already covered.Expected behaviour
With
"always-link-local": false: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.0stops 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.
buildGraphOrderis called at workspace.ts L111 andbuildUpdatedVersionsonly at L114, andvisitPostOrderhas 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
candidatesByPackageis built at L107 and each entry carriespullRequest.version. In the scenario above, core is exactly such a package.Suggested regression tests
1.3.0to1.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.1.3.0to2.0.0, dependent declares^1.3.0,always-link-local: false. Expected: the dependent is included and its declared range is updated.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-localshould 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
ubuntu-latest, GitHub-hosted runner. The reproduction below is OS-independent.node24runtime declared bygoogleapis/release-please-action@v5(action.yml L78)dist/index.js, and the workspace itself uses bun 1.3.14.release-pleaseversion: 17.6.0, the versionrelease-please-action@v5resolves for its^17.6.0dependency in itspackage-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.
Output at v17.11.1, and identical at v17.6.0 apart from
manifest.ts:1469in place of1471: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, thethis.alwaysLinkLocalargument tonew 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" } } }feat(core): ...commit touching onlypackages/core.release-please release-pr --repo-url=<repo> --config-file=release-please-config.json --manifest-file=.release-please-manifest.json.@scope/pluginis pulled in alongside it and bumped 0.4.1 to 0.4.2 with a dependency changelog entry. Its declared^1.3.0already satisfies 1.4.0, andalways-link-localisfalse.To be straight about what I ran: step 1 is verified output, step 2 is traced from
buildGraph,buildGraphOrder,visitPostOrderandbuildUpdatedVersionsrather 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.tswith fixtures undertest/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.