googleapis / googleapis/release-please

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

Open
#2,876 0 comments 0 reactions 0 assignees View on GitHub
priority: p3 type: bug
Dominant language
TypeScript
Stars
7.5k
Forks
588
Avg merge
12h 16m
Merged PRs (30d)
7

Description

## 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:

```json
{
"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:

```json
{
"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 https://github.com/jaenyf/time-provider/commit/b5cc641901d0b9a8d7660b849d1026dd23991a87, 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](https://github.com/googleapis/release-please/blob/v17.11.1/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`](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/node-workspace.ts) are the interface field (L61), the private field (L73) and the assignment (L87):

```ts
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](https://github.com/googleapis/release-please/blob/v16.0.0/src/plugins/node-workspace.ts#L137-L141):

```ts
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](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/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](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/workspace.ts#L415)) inverts that graph into dependency name to dependents.
3. `visitPostOrder` ([workspace.ts L442](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/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](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/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](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/node-workspace.ts#L429)) always includes `devDependencies`:

```ts
private combineDeps(packageJson: Package): Record {
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](https://github.com/googleapis/release-please/blob/v17.11.1/src/plugins/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](https://github.com/googleapis/release-please-action/blob/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`](https://github.com/googleapis/release-please-action/blob/v5/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.

```bash
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`:

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

`packages/plugin/package.json`:

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

`.release-please-manifest.json`:

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

`release-please-config.json`:

```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= --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.

Contributor guide

Open the contributing guide

Research direction

Start with buildGraph and alwaysLinkLocal in src/plugins/node-workspace.ts, then follow buildGraphOrder, visitPostOrder, and buildUpdatedVersions in src/plugins/workspace.ts. Add regression coverage for covered and uncovered SemVer ranges with always-link-local false, plus true as a control; done means dependents are skipped only when their declared range covers the proposed version.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
release
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
64/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.