@williamthorsen/release-kit 13.1.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.meta/changelog.json +118 -9
  2. package/.readyup/kits/default.js +4 -4
  3. package/.readyup/kits/npm-auto-publish.js +81 -41
  4. package/.readyup/manifest.json +19 -9
  5. package/CHANGELOG.md +38 -6
  6. package/README.md +14 -6
  7. package/dist/esm/assertReleasesOnNpm.d.ts +7 -0
  8. package/dist/esm/assertReleasesOnNpm.js +56 -0
  9. package/dist/esm/bin/release-kit.js +5 -2
  10. package/dist/esm/deriveWorkspaceConfig.js +2 -2
  11. package/dist/esm/enumerateReleaseWindows.d.ts +1 -1
  12. package/dist/esm/enumerateReleaseWindows.js +1 -3
  13. package/dist/esm/findTransitiveDependents.d.ts +3 -0
  14. package/dist/esm/findTransitiveDependents.js +20 -0
  15. package/dist/esm/help/prepareHelp.d.ts +1 -1
  16. package/dist/esm/help/prepareHelp.js +4 -2
  17. package/dist/esm/init/hasPublishablePackage.d.ts +10 -0
  18. package/dist/esm/init/hasPublishablePackage.js +28 -0
  19. package/dist/esm/init/initCommand.js +23 -13
  20. package/dist/esm/init/scaffold.d.ts +6 -1
  21. package/dist/esm/init/scaffold.js +10 -7
  22. package/dist/esm/init/templates.js +1 -1
  23. package/dist/esm/init/updateTemplatesCommand.d.ts +2 -1
  24. package/dist/esm/init/updateTemplatesCommand.js +12 -6
  25. package/dist/esm/isPublishableManifest.d.ts +2 -0
  26. package/dist/esm/isPublishableManifest.js +5 -0
  27. package/dist/esm/matchesTagPrefix.d.ts +2 -0
  28. package/dist/esm/matchesTagPrefix.js +4 -0
  29. package/dist/esm/npmRegistry.d.ts +35 -0
  30. package/dist/esm/npmRegistry.js +58 -0
  31. package/dist/esm/prepareCommand.d.ts +1 -0
  32. package/dist/esm/prepareCommand.js +52 -61
  33. package/dist/esm/previewTagPrefixes.js +2 -1
  34. package/dist/esm/releasePrepare.d.ts +2 -16
  35. package/dist/esm/releasePrepareMono.d.ts +17 -2
  36. package/dist/esm/releasePrepareMono.js +23 -11
  37. package/dist/esm/sync-labels/templates.js +1 -1
  38. package/docs/configuration.md +1 -1
  39. package/docs/labels.md +5 -3
  40. package/docs/project-releases.md +2 -2
  41. package/docs/readiness-checks.md +2 -2
  42. package/docs/releasing.md +20 -10
  43. package/package.json +8 -8
  44. package/dist/esm/validateOnlyExcludesStrandedDependents.d.ts +0 -37
  45. package/dist/esm/validateOnlyExcludesStrandedDependents.js +0 -115
@@ -5,6 +5,7 @@ import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
6
6
  import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
7
7
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
8
+ import { matchesTagPrefix } from "./matchesTagPrefix.js";
8
9
  export function previewTagPrefixes(config) {
9
10
  const workspace = discoverWorkspaces();
10
11
  if (workspace.kind === 'empty') {
@@ -62,7 +63,7 @@ function countTagsMatching(prefix) {
62
63
  maxBuffer: GIT_OUTPUT_LIMIT,
63
64
  stdio: ['pipe', 'pipe', 'pipe'],
64
65
  });
65
- return output.split('\n').filter((line) => line.trim() !== '').length;
66
+ return output.split('\n').filter((line) => matchesTagPrefix(line.trim(), [prefix])).length;
66
67
  }
67
68
  catch {
68
69
  return 0;
@@ -10,24 +10,10 @@ export interface ReleasePrepareOptions {
10
10
  /** Level of the release, in place of the level that the changelog items call for; it doesn't trigger a release. */
11
11
  bumpOverride?: ReleaseType;
12
12
  /**
13
- * Explicit target version (canonical `N.N.N`) that bypasses commit-derived bump logic.
14
- * Mutually exclusive with `bumpOverride`. In monorepo mode the caller must narrow
15
- * `config.workspaces` to a single workspace before invoking.
13
+ * Explicit target version (canonical `N.N.N`) that bypasses commit-derived bump logic and takes precedence over
14
+ * `bumpOverride` and `force`.
16
15
  */
17
16
  setVersion?: string;
18
- /**
19
- * Workspace directories to which `--only` narrowed the run (monorepo only), with
20
- * `config.workspaces` already filtered to match. Present only for a narrowed run, which
21
- * skips the project release: The project tier rolls up every contributing workspace, and
22
- * the narrowing has changed which workspaces those are.
23
- */
24
- only?: string[];
25
- /**
26
- * Every configured workspace `dir`, taken before `--only` narrowed `config.workspaces`; the unrouted-scope report
27
- * does not report a scope naming one of them that the run did not read. Defaults to the `dir`s of
28
- * `config.workspaces`.
29
- */
30
- configuredWorkspaceDirs?: readonly string[];
31
17
  /**
32
18
  * If true, plan per-workspace release-notes previews under `{workspacePath}/docs/`
33
19
  * (`README.v{version}.md` and `RELEASE_NOTES.v{version}.md`) from each workspace's
@@ -1,14 +1,29 @@
1
1
  import type { ReleasePlan } from './releasePlan.js';
2
2
  import type { ReleasePrepareOptions } from './releasePrepare.js';
3
3
  import type { MonorepoPrepareConfig } from './types.js';
4
+ /** Options for monorepo release preparation. */
5
+ export interface MonorepoPrepareOptions extends Omit<ReleasePrepareOptions, 'setVersion'> {
6
+ /**
7
+ * Workspace `dir`s from which the release starts; the run plans them and their transitive dependents. Present only
8
+ * for a narrowed run, which skips the project release: The project tier rolls up every contributing workspace, and
9
+ * the narrowing has changed which workspaces those are.
10
+ */
11
+ only?: readonly string[];
12
+ /**
13
+ * Explicit target versions (canonical `N.N.N`), keyed by workspace `dir`. Each named workspace starts the release
14
+ * and releases at its version, which takes precedence over `bumpOverride` and `force`.
15
+ */
16
+ setVersions?: ReadonlyMap<string, string>;
17
+ }
4
18
  /**
5
19
  * Orchestrates release preparation for a monorepo with multiple workspaces.
6
20
  *
21
+ * Phase 0: Selects the planned workspaces: those from which the release starts and their transitive dependents.
7
22
  * Phase 1: Determines direct bumps from commits for each workspace.
8
- * Phase 2: Builds the dependency graph and propagates bumps to dependents.
23
+ * Phase 2: Propagates bumps to dependents.
9
24
  * Phase 2b: Sorts the full release set topologically.
10
25
  * Phase 3: Plans bumps and changelogs in dependency order.
11
26
  * Phase 3b: Plans the project release.
12
27
  * Phase 4: Renders the format command.
13
28
  */
14
- export declare function releasePrepareMono(config: MonorepoPrepareConfig, options: ReleasePrepareOptions): ReleasePlan;
29
+ export declare function releasePrepareMono(fullConfig: MonorepoPrepareConfig, options: MonorepoPrepareOptions): ReleasePlan;
@@ -11,6 +11,7 @@ import { applyWorkspaceOverrides, createOverrideContext, formatStaleOverrideKeyW
11
11
  import { decideRelease } from "./decideRelease.js";
12
12
  import { deriveDependenciesHeader, deriveSectionOrder } from "./deriveReleaseNotesConfig.js";
13
13
  import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
14
+ import { findTransitiveDependents } from "./findTransitiveDependents.js";
14
15
  import { findUnroutedEntryScopes } from "./findUnroutedEntryScopes.js";
15
16
  import { getAllTagPrefixes } from "./generateChangelogs.js";
16
17
  import { hasPrettierConfig } from "./hasPrettierConfig.js";
@@ -21,10 +22,17 @@ import { propagateBumps } from "./propagateBumps.js";
21
22
  import { planPreservedSections } from "./readChangelogSections.js";
22
23
  import { releasePrepareProject } from "./releasePrepareProject.js";
23
24
  import { renderChangelogMarkdown } from "./renderChangelogMarkdown.js";
24
- export function releasePrepareMono(config, options) {
25
+ export function releasePrepareMono(fullConfig, options) {
25
26
  const { only, withReleaseNotes } = options;
26
27
  const writes = [];
27
28
  const warnings = [];
29
+ const graph = buildDependencyGraph(fullConfig.workspaces);
30
+ const startDirs = selectStartDirs(fullConfig.workspaces, options);
31
+ const plannedDirs = findTransitiveDependents(graph, startDirs);
32
+ const config = {
33
+ ...fullConfig,
34
+ workspaces: fullConfig.workspaces.filter((workspace) => plannedDirs.has(workspace.dir)),
35
+ };
28
36
  if (withReleaseNotes === true && !config.changelogJson.enabled) {
29
37
  warnings.push('--with-release-notes requires changelogJson.enabled; skipping preview generation');
30
38
  }
@@ -32,10 +40,9 @@ export function releasePrepareMono(config, options) {
32
40
  const sectionOrder = deriveSectionOrder(workTypes);
33
41
  const dependenciesHeader = deriveDependenciesHeader(workTypes);
34
42
  const overrideContext = createOverrideContext(config.workspaces);
35
- const { directBumps, directResults, skippedResults, untaggedBaselines } = determineDirectBumps(config, options);
36
- reportUnroutedEntryScopes([...directResults.values(), ...skippedResults], config, options);
43
+ const { directBumps, directResults, skippedResults, untaggedBaselines } = determineDirectBumps(config, options, startDirs);
44
+ reportUnroutedEntryScopes([...directResults.values(), ...skippedResults], config, fullConfig.workspaces.map((workspace) => workspace.dir));
37
45
  const skippedHistories = new Map(skippedResults.map((skipped) => [skipped.workspace.dir, skipped.history]));
38
- const graph = buildDependencyGraph(config.workspaces);
39
46
  const fullReleaseSet = propagateBumps(directBumps, graph);
40
47
  assertTaggedBaseline([...untaggedBaselines]
41
48
  .filter(([dir]) => directBumps.has(dir) || !fullReleaseSet.has(dir))
@@ -107,11 +114,15 @@ export function releasePrepareMono(config, options) {
107
114
  ...(project !== undefined && { project }),
108
115
  };
109
116
  }
110
- function determineDirectBumps(config, options) {
111
- const { force, bumpOverride, setVersion } = options;
112
- if (setVersion !== undefined && config.workspaces.length !== 1) {
113
- throw new Error(`--set-version requires exactly one workspace; received ${config.workspaces.length}`);
117
+ function selectStartDirs(workspaces, options) {
118
+ const { only, setVersions } = options;
119
+ if (only === undefined) {
120
+ return new Set(workspaces.map((workspace) => workspace.dir));
114
121
  }
122
+ return new Set([...only, ...(setVersions?.keys() ?? [])]);
123
+ }
124
+ function determineDirectBumps(config, options, startDirs) {
125
+ const { force, bumpOverride, setVersions } = options;
115
126
  const directBumps = new Map();
116
127
  const directResults = new Map();
117
128
  const skippedResults = [];
@@ -138,6 +149,7 @@ function determineDirectBumps(config, options) {
138
149
  if (tag === undefined) {
139
150
  maybeEmitBaselineHint(workspace, knownPrefixes, hintState);
140
151
  }
152
+ const setVersion = setVersions?.get(workspace.dir);
141
153
  if (setVersion !== undefined) {
142
154
  directBumps.set(workspace.dir, { releaseType: 'patch', newVersionOverride: setVersion });
143
155
  directResults.set(workspace.dir, {
@@ -152,7 +164,7 @@ function determineDirectBumps(config, options) {
152
164
  const decision = decideRelease({
153
165
  naturalBump: history.unreleased.bump,
154
166
  commitCount: history.unreleased.commits.length,
155
- force,
167
+ force: force === true && startDirs.has(workspace.dir),
156
168
  bumpOverride,
157
169
  skipReasons: {
158
170
  noCommits: `No commits for ${name} ${since}. Pass --force to release at patch. Skipping.`,
@@ -173,8 +185,8 @@ function determineDirectBumps(config, options) {
173
185
  }
174
186
  return { directBumps, directResults, skippedResults, untaggedBaselines };
175
187
  }
176
- function reportUnroutedEntryScopes(results, config, options) {
177
- const findings = findUnroutedEntryScopes(results.map(({ workspace, history }) => ({ dir: workspace.dir, commits: history.unreleased.commits })), options.configuredWorkspaceDirs ?? config.workspaces.map((workspace) => workspace.dir), config.scopeAliases ?? {});
188
+ function reportUnroutedEntryScopes(results, config, configuredWorkspaceDirs) {
189
+ const findings = findUnroutedEntryScopes(results.map(({ workspace, history }) => ({ dir: workspace.dir, commits: history.unreleased.commits })), configuredWorkspaceDirs, config.scopeAliases ?? {});
178
190
  for (const { workspace, history } of results) {
179
191
  history.unreleased.diagnostics.unroutedEntryScopes = findings.get(workspace.dir) ?? [];
180
192
  }
@@ -11,7 +11,7 @@ on:
11
11
  paths:
12
12
  - .github/labels.yaml
13
13
 
14
- # Preview on review: The check's log has a full computed diff, including deletions of labels not declared by the file.
14
+ # Preview on review: The check's log has the computed diff, and the check warns about each label not declared by the file.
15
15
  pull_request:
16
16
  paths:
17
17
  - .github/labels.yaml
@@ -90,7 +90,7 @@ Tag prefixes from distinct owners must not be identical or be a strict prefix of
90
90
  - A `retiredPackages[]` entry (one owner per entry).
91
91
  - The `project` block, when configured.
92
92
 
93
- Because release-kit matches a tag to a prefix when the tag name starts with the prefix and continues with a digit, a strict-prefix overlap between distinct owners can match the wrong owner's tags. For example, a project prefix of `v` collides with a workspace prefix of `v11y-check-v`, since `v11y-check-v1.0.0` starts with `v` followed by a digit. The rule rejects every strict-prefix overlap, including one such as `v` and `vue-helpers-v`, whose shorter prefix can't match any tag of the longer one.
93
+ Because release-kit matches a tag to a prefix when the tag name is the prefix followed by a complete SemVer version, a strict-prefix overlap between distinct owners can match the wrong owner's tags. For example, a retired prefix of `pkg-` collides with a workspace prefix of `pkg-1.0.0-v`, since `pkg-1.0.0-v1.2.3` is `pkg-` followed by the pre-release version `1.0.0-v1.2.3`. The rule rejects every strict-prefix overlap, including one such as `v` and `vue-helpers-v`, whose shorter prefix can't match any tag of the longer one.
94
94
 
95
95
  The rule is enforced at config load; the resulting error identifies both colliding declarations.
96
96
 
package/docs/labels.md CHANGED
@@ -52,7 +52,7 @@ labels: {
52
52
  },
53
53
  ```
54
54
 
55
- The sync workflow archives a marked label rather than deleting it. An archived label stays on the issues and pull requests that have it, but it cannot be added to new ones and does not appear in the label picker. Removing the mark unarchives the label on the next sync. Removing the entry instead deletes the label, as the sync deletes every label that `.github/labels.yaml` does not declare, and the label disappears from every issue and pull request that had it.
55
+ The sync workflow archives a marked label rather than deleting it. An archived label stays on the issues and pull requests that have it, but it cannot be added to new ones and does not appear in the label picker. Removing the mark unarchives the label on the next sync. Removing the entry leaves the label in place: The sync does not delete a label that `.github/labels.yaml` does not declare, and it reports each such label as a warning. To delete a label, run `gh label delete <name>`; the label disappears from every issue and pull request that had it.
56
56
 
57
57
  The mark is set only in the `labels` record, not in a preset. To retire a preset's label, replace it with an entry that restates its color and adds the mark.
58
58
 
@@ -68,15 +68,17 @@ The scaffolded workflow declares three triggers:
68
68
 
69
69
  Applying on merge closes the window in which a regenerated `.github/labels.yaml` remains unapplied. In that window, a later manual dispatch would apply an unreviewed label set.
70
70
 
71
- The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, rename, and deletion that the sync would perform, including deletions of labels that the file does not declare: the destructive edits that a diff of `.github/labels.yaml` alone cannot show, because a label created by hand or by another workflow is absent from the file both before and after. Read that log; the check reports success whether or not the diff is destructive, so a green check is not evidence that nothing will be deleted.
71
+ The `check` job runs the same sync in dry-run. Its **Label diff** log group lists every create, edit, and rename that the sync would perform.
72
72
 
73
73
  Its **Archive state** log group lists each label that the sync would archive or unarchive. Archiving runs after the rest of the sync, so a label that the file both adds and marks is created and then archived.
74
74
 
75
+ Both jobs end with an **Undeclared labels** log group, and they report each repository label that the file does not declare as a warning annotation, which appears on the run and on the pull request's check. A label created by hand, for example to put it on the ticket that will declare it, therefore survives every sync until the file declares it or someone deletes it.
76
+
75
77
  The two jobs are split because a job's permissions are fixed when the run is created and cannot vary by trigger. The split keeps a write-scoped token out of pull-request runs.
76
78
 
77
79
  The push trigger filters on the path alone; the `sync` job compares `github.ref_name` against the repository's default branch. The workflow therefore doesn't need a per-repo edit, whatever that branch is named, and a push touching `.github/labels.yaml` on any other branch produces a run whose jobs all skip.
78
80
 
79
- Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels, deletions included; only the pull-request path runs in dry-run.
81
+ Manual dispatch is not a preview. It matches the `sync` job, so it applies the labels; only the pull-request path runs in dry-run.
80
82
 
81
83
  ### Dry-run checks on fork pull requests
82
84
 
@@ -68,7 +68,7 @@ CLI flag interactions:
68
68
  - `--dry-run` previews project artifacts alongside workspace artifacts; it doesn't write any files.
69
69
  - `--bump=major|minor|patch` propagates to the project release as a level chooser. It does not trigger a release on its own when the window doesn't have any commits or any bump-worthy commits.
70
70
  - `--force` runs the project release even when the history since the last project tag doesn't have any commits or any bump-worthy commits. Defaults to patch when `--bump` is not given; combine with `--bump=X` to release at a different level.
71
- - `--only` narrows the run to the named workspaces and skips the project release, which is reported as a warning. The project release rolls up every contributing workspace, and `--only` changes which workspaces those are, so rolling up a narrowed set would release a project version covering work that the run excluded. Run a full `prepare` (without `--only`) to include the project release.
72
- - `--set-version` is rejected with an error when `project` is configured. `--set-version` operates on a single workspace, but a project release rolls up every contributing workspace; the two semantics don't compose. To use `--set-version`, run on a config without a `project` block.
71
+ - `--only` narrows the run to the named workspaces and their dependents and skips the project release, which is reported as a warning. The project release rolls up every contributing workspace, and `--only` changes which workspaces those are, so rolling up a narrowed set would release a project version covering work that the run excluded. Run a full `prepare` (without `--only`) to include the project release.
72
+ - `--set-version` is rejected with an error when `project` is configured. `--set-version` sets individual workspaces' versions, but a project release rolls up every contributing workspace; the two semantics don't compose. To use `--set-version`, run on a config without a `project` block.
73
73
 
74
74
  How `--bump` and `--force` combine for every target is in [Releasing](releasing.md#release-kit-prepare).
@@ -7,12 +7,12 @@ release-kit publishes two [readyup](https://www.npmjs.com/package/readyup) kits
7
7
  The [README](../README.md#readiness-checks) shows how to name release-kit in the readyup config.
8
8
 
9
9
  ```bash
10
- rdy run --packages # every kit each listed package publishes
10
+ rdy run --sources # every kit each listed package publishes
11
11
  rdy run --from npm:@williamthorsen/release-kit # the default kit alone
12
12
  rdy run --from npm:@williamthorsen/release-kit npm-auto-publish # the npm-auto-publish kit
13
13
  rdy list --from npm:@williamthorsen/release-kit # what release-kit publishes
14
14
  ```
15
15
 
16
- `--packages` is the form that keeps working when release-kit publishes further kits.
16
+ `--sources` is the form that keeps working when release-kit publishes further kits.
17
17
 
18
18
  Because `npm-auto-publish` queries the npm registry for each package's trusted publisher, it needs an npm session elevated by two-factor authentication, and is meant to be invoked deliberately rather than included in an unattended run. A session that cannot answer those queries, whether it is missing a login, missing the elevation, or facing an unreachable registry, is reported once by the `npm session can answer trust queries` gate. The rows that would repeat the failed query are then skipped: the trusted-publisher rows in every case, and `published to npm` when the registry itself is unreachable. Each package's `package.json` checks report throughout, since they don't read a registry.
package/docs/releasing.md CHANGED
@@ -15,13 +15,15 @@ Scaffolded files:
15
15
  - `.github/workflows/release.yaml`: Workflow that delegates to a reusable release workflow
16
16
  - `.config/release-kit.config.ts`: Starter config with commented-out customization examples (with `--with-config`)
17
17
 
18
+ When every workspace, or the single package, sets `private: true`, `init` skips `create-github-release.yaml` and `publish.yaml`, `--force` included, and reports each skip: `publish` and `create-github-release` skip private packages, so neither workflow would do anything. Its next steps then leave out the provenance and trusted-publisher steps.
19
+
18
20
  ## `release-kit update-templates`
19
21
 
20
22
  Updates the scaffolded workflows to the templates of the installed release-kit version, and prints what changed in each file. Run it after upgrading release-kit; `--dry-run` previews the changes without writing them.
21
23
 
22
24
  The workflows are managed files: release-kit renders them, and a change made by hand is overwritten on the next update. The config files are the repository's own, and the command never reads or writes them.
23
25
 
24
- - It creates a missing workflow that `init` would create.
26
+ - It creates a missing workflow that `init` would create. In a repo whose packages are all private, `init` does not create `create-github-release.yaml` or `publish.yaml`, so the command does not create either one when it is missing and reports the skip; it still refreshes either one that exists.
25
27
  - It refreshes `.github/workflows/sync-labels.yaml` only when the file exists, because `sync-labels init` scaffolds it on request.
26
28
  - It keeps `publish.yaml`'s provenance setting: When the file does not set `provenance: true`, as in a private repository, the updated file does not either.
27
29
 
@@ -29,7 +31,7 @@ The workflows are managed files: release-kit renders them, and a change made by
29
31
 
30
32
  Runs release preparation with automatic workspace discovery.
31
33
 
32
- Workspace names for `--only` match the package directory name (e.g., `arrays`, `release-kit`).
34
+ `--only` names the workspaces from which the release starts, by package directory name (e.g., `arrays`, `release-kit`). The run also releases every workspace that depends on one of them, directly or transitively: A dependent with commits of its own releases at its own bump, and one without releases at a propagated patch bump, so that it publishes against the new version. Workspaces outside that set are left out of the run.
33
35
 
34
36
  `prepare` reads each target's history once and builds the changelog items of its unreleased commits. The bump is the highest level that those items call for, under [`versionPatterns`](configuration.md#versionpatterns), so the bump and the changelog describe the same release: A `feat` entry in a [change-record block](changelogs.md#change-record-blocks) bumps minor under a `docs:` title, and a breaking item bumps major.
35
37
 
@@ -57,6 +59,10 @@ release-kit prepare --force --bump=minor
57
59
  release-kit prepare --bump=minor
58
60
  ```
59
61
 
62
+ ### Packages that npm doesn't have yet
63
+
64
+ Before it writes any file, and in a dry run as well, `prepare` asks npm about each publishable package that the release includes, querying the registry that the package's `publishConfig.registry` names when it sets one. OIDC trusted publishing can't publish a package's first version, so `prepare` refuses to release a package that npm doesn't list, and names each one with its fix: Run `npm publish --access public` from the package's directory once, then register the package as a trusted publisher. The `npm-auto-publish` [readiness kit](readiness-checks.md) prints the `npm trust` command. `prepare` also refuses when it can't look a package up, such as when the registry is unreachable, and reports npm's error for that package.
65
+
60
66
  ### Previewing release notes with `--with-release-notes`
61
67
 
62
68
  `--with-release-notes` writes two versioned files per workspace after each workspace's `changelog.json` is produced:
@@ -82,18 +88,20 @@ docs/*.v*.md
82
88
 
83
89
  The `--set-version` flag is a first-class escape hatch for the cases in which the derived bump produces the wrong version, most notably when promoting a pre-1.0 package to 1.0.0. Pre-1.0 packages collapse a `feat!` breaking change to a minor bump (matching semantic-release's `initialMajor: false` and release-please's `bump-minor-pre-major`), so a deliberate promotion to 1.0.0 must be requested explicitly.
84
90
 
91
+ In a single-package repo, the value is the version: `--set-version 1.0.0`. In a monorepo, it names the workspace with each version, as comma-separated `<workspace>@N.N.N` entries: `--set-version arrays@1.0.0,strings@2.0.0`. Each named workspace releases at its version whether or not it has commits, and starts the release like a workspace named by `--only`.
92
+
85
93
  The flag validates that:
86
94
 
87
- - The value is canonical `N.N.N` semver (pre-release suffixes are rejected).
88
- - The target is strictly greater than the current version (numeric comparison on each component).
89
- - In monorepo mode, `--only` is set and resolves to exactly one workspace.
95
+ - Each version is canonical `N.N.N` semver (pre-release suffixes are rejected).
96
+ - In a monorepo, each entry names a known workspace, and none names it twice.
97
+ - Each version is strictly greater than the workspace's current version (numeric comparison on each component).
90
98
 
91
- `--set-version` is mutually exclusive with `--bump` and `--force`. The rest of the pipeline (changelog generation, tag creation, commit summary, propagation to dependents) runs unchanged, so dependents receive a propagated patch bump triggered by the overridden version.
99
+ The rest of the pipeline (changelog generation, tag creation, commit summary, propagation to dependents) runs unchanged, so each dependent receives a propagated patch bump that records the explicit version. Without `--only`, the run is a full run with the explicit versions applied. `--bump` and `--force` combine with `--set-version` and govern every other workspace.
92
100
 
93
- Promoting a pre-1.0 package to 1.0.0 in a monorepo:
101
+ Promoting a pre-1.0 package to 1.0.0 in a monorepo, which also releases its dependents:
94
102
 
95
103
  ```sh
96
- release-kit prepare --only arrays --set-version 1.0.0
104
+ release-kit prepare --set-version arrays@1.0.0 --only arrays
97
105
  ```
98
106
 
99
107
  An empty changelog section is expected for a bare promotion, because the changelog is generated from commits since the last tag. To include a narrative entry, merge a descriptive release commit (e.g., a `feat!` describing the stable API) before running `prepare`.
@@ -118,19 +126,21 @@ The `init` command scaffolds a release workflow at `.github/workflows/release.ya
118
126
 
119
127
  | Input | Type | Description |
120
128
  | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
121
- | `only` | string | Workspaces to release (comma-separated, leave empty for all) |
129
+ | `only` | string | Workspaces to release, with their dependents (comma-separated, leave empty for all) |
122
130
  | `bump` | choice | Bump type: `auto` (default, detects it from commits), `patch`, `minor`, or `major` |
123
131
  | `force` | boolean | Release even when the window doesn't have any commits or any bump-worthy commits (defaults to patch; combine with `bump` for a different level) |
124
132
 
125
133
  For repos that need a self-contained workflow instead of the reusable one, the scaffolded file can be expanded. The key steps are: checkout with full history (`fetch-depth: 0`), run `release-kit prepare` with optional `--only`, `--bump`, and `--force` flags, check for changes, read tags from `tmp/.release-tags`, then commit, tag, and push.
126
134
 
135
+ The reusable `release`, `create-github-release`, and `sync-labels` workflows run on `ubuntu-latest` unless the repository's `CI_RUNS_ON` variable names another runner; `publish` always runs on `ubuntu-latest`. See [Runner selection](https://github.com/williamthorsen/node-monorepo-tools/blob/main/.github/workflows/README.md#runner-selection).
136
+
127
137
  ### Triggering a release
128
138
 
129
139
  ```sh
130
140
  # All workspaces
131
141
  gh workflow run release.yaml
132
142
 
133
- # Specific workspace(s)
143
+ # Specific workspace(s), with their dependents
134
144
  gh workflow run release.yaml -f only=arrays
135
145
  gh workflow run release.yaml -f only=arrays,strings -f bump=minor
136
146
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/release-kit",
3
- "version": "13.1.0",
3
+ "version": "14.1.0",
4
4
  "description": "Version-bumping and changelog-generation toolkit for release workflows",
5
5
  "keywords": [
6
6
  "changelog",
@@ -50,9 +50,9 @@
50
50
  "schemas/**"
51
51
  ],
52
52
  "dependencies": {
53
- "@williamthorsen/change-grammar": "0.1.0",
54
- "@williamthorsen/nmr-core": "0.13.1",
55
- "@williamthorsen/toolbelt.errors": "0.8.1",
53
+ "@williamthorsen/change-grammar": "0.2.0",
54
+ "@williamthorsen/nmr-core": "0.13.2",
55
+ "@williamthorsen/toolbelt.errors": "0.8.3",
56
56
  "json-stringify-pretty-compact": "4.0.0",
57
57
  "semver": "7.8.5",
58
58
  "yaml": "2.9.1",
@@ -60,12 +60,12 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/semver": "7.8.0",
63
- "@williamthorsen/toolbelt.filesystem": "0.9.1",
64
- "@williamthorsen/toolbelt.testing": "0.6.1",
65
- "@williamthorsen/toolbelt.vitest": "0.10.1",
63
+ "@williamthorsen/toolbelt.filesystem": "0.9.3",
64
+ "@williamthorsen/toolbelt.testing": "0.6.3",
65
+ "@williamthorsen/toolbelt.vitest": "0.10.3",
66
66
  "actionlint": "2.0.6",
67
67
  "esbuild": "0.28.2",
68
- "readyup": "0.38.0",
68
+ "readyup": "0.40.0",
69
69
  "smol-toml": "1.9.0",
70
70
  "vitest": "4.1.11"
71
71
  },
@@ -1,37 +0,0 @@
1
- import type { DependencyGraph } from './buildDependencyGraph.js';
2
- import type { WorkspaceConfig } from './types.js';
3
- /** Result of probing a workspace's commits since its last tag. */
4
- export interface CommitsProbeResult {
5
- /** Whether the workspace has any commits since its last tag (excluding `release:` commits). */
6
- has: boolean;
7
- /**
8
- * The baseline tag from which the commits were measured; `undefined` when the workspace does not have a prior tag.
9
- */
10
- tag: string | undefined;
11
- }
12
- /** A single excluded workspace whose changes would be stranded by the `--only` invocation. */
13
- export interface StrandedDependentViolation {
14
- /** The excluded workspace's `dir`. */
15
- dir: string;
16
- /** The `dir` of the released (or anticipated-release) dependency whose release republishes this workspace. */
17
- downstreamOf: string;
18
- /**
19
- * The baseline tag from which this workspace's commits were counted; `undefined` if the workspace does not have a
20
- * prior tag.
21
- */
22
- tag: string | undefined;
23
- }
24
- /** Probe callback that returns whether a workspace has commits since its last tag. */
25
- type CommitsProbe = (workspace: WorkspaceConfig) => CommitsProbeResult;
26
- /**
27
- * Detects `--only` invocations that would silently strand changes in excluded internal dependents.
28
- *
29
- * First computes R, the `--only` workspaces that will release, then walks the reverse-dependency graph breadth-first
30
- * from R and records each excluded workspace with its own commits as a violation. The walk continues through a
31
- * violation, as though the user had added it to `--only`, which reports deeper violations in the same pass. It stops
32
- * at an excluded workspace without commits, which does not republish, so its dependents are unaffected by R.
33
- *
34
- * Returns `undefined` when the walk does not find any violation, or the violations sorted by `dir`.
35
- */
36
- export declare function validateOnlyExcludesStrandedDependents(workspaces: readonly WorkspaceConfig[], only: readonly string[], graph: DependencyGraph, hasCommits: CommitsProbe): StrandedDependentViolation[] | undefined;
37
- export {};
@@ -1,115 +0,0 @@
1
- export function validateOnlyExcludesStrandedDependents(workspaces, only, graph, hasCommits) {
2
- const probeCommits = memoizeCommitsProbe(hasCommits);
3
- const workspaceByDir = new Map(workspaces.map((w) => [w.dir, w]));
4
- const released = computeReleasedSet(only, workspaceByDir, graph, probeCommits);
5
- const violations = collectStrandedViolations(released, new Set(only), graph, probeCommits);
6
- if (violations.length === 0)
7
- return undefined;
8
- violations.sort((a, b) => a.dir.localeCompare(b.dir));
9
- return violations;
10
- }
11
- function memoizeCommitsProbe(probe) {
12
- const cache = new Map();
13
- return (workspace) => {
14
- const cached = cache.get(workspace.dir);
15
- if (cached !== undefined)
16
- return cached;
17
- const result = probe(workspace);
18
- cache.set(workspace.dir, result);
19
- return result;
20
- };
21
- }
22
- function computeReleasedSet(only, workspaceByDir, graph, probeCommits) {
23
- const released = new Set();
24
- for (const dir of only) {
25
- const workspace = workspaceByDir.get(dir);
26
- if (workspace !== undefined && probeCommits(workspace).has) {
27
- released.add(dir);
28
- }
29
- }
30
- let changed = true;
31
- while (changed) {
32
- changed = false;
33
- for (const dir of only) {
34
- if (released.has(dir))
35
- continue;
36
- if (hasDependencyIn(dir, graph, released)) {
37
- released.add(dir);
38
- changed = true;
39
- }
40
- }
41
- }
42
- return released;
43
- }
44
- function hasDependencyIn(dir, graph, released) {
45
- const forwardDeps = graph.dependenciesOf.get(dir);
46
- if (forwardDeps === undefined)
47
- return false;
48
- for (const depPackageName of forwardDeps) {
49
- const depDir = graph.packageNameToDir.get(depPackageName);
50
- if (depDir !== undefined && released.has(depDir))
51
- return true;
52
- }
53
- return false;
54
- }
55
- function collectStrandedViolations(released, onlySet, graph, probeCommits) {
56
- const violations = [];
57
- const violationDirs = new Set();
58
- const visited = new Set();
59
- const queue = [];
60
- for (const dir of released) {
61
- const packageName = graph.dirToPackageName.get(dir);
62
- if (packageName !== undefined) {
63
- queue.push({ packageName, attributionRoot: dir });
64
- }
65
- }
66
- while (queue.length > 0) {
67
- const item = queue.shift();
68
- if (item === undefined)
69
- break;
70
- if (visited.has(item.packageName))
71
- continue;
72
- visited.add(item.packageName);
73
- const dependents = graph.dependentsOf.get(item.packageName);
74
- if (dependents === undefined)
75
- continue;
76
- for (const dependent of dependents) {
77
- visitDependent(dependent, item.attributionRoot, {
78
- released,
79
- onlySet,
80
- graph,
81
- probeCommits,
82
- queue,
83
- violations,
84
- violationDirs,
85
- });
86
- }
87
- }
88
- return violations;
89
- }
90
- function visitDependent(dependent, attributionRoot, ctx) {
91
- if (ctx.onlySet.has(dependent.dir)) {
92
- if (ctx.released.has(dependent.dir)) {
93
- enqueueDependent(dependent, ctx);
94
- }
95
- return;
96
- }
97
- const probe = ctx.probeCommits(dependent);
98
- if (!probe.has)
99
- return;
100
- if (!ctx.violationDirs.has(dependent.dir)) {
101
- ctx.violationDirs.add(dependent.dir);
102
- ctx.violations.push({
103
- dir: dependent.dir,
104
- downstreamOf: attributionRoot,
105
- tag: probe.tag,
106
- });
107
- }
108
- enqueueDependent(dependent, ctx);
109
- }
110
- function enqueueDependent(dependent, ctx) {
111
- const packageName = ctx.graph.dirToPackageName.get(dependent.dir);
112
- if (packageName !== undefined) {
113
- ctx.queue.push({ packageName, attributionRoot: dependent.dir });
114
- }
115
- }