@wpelevator/wp-release 0.9.0 → 0.11.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 (43) hide show
  1. package/README.md +101 -4
  2. package/dist/changelog.d.ts +45 -0
  3. package/dist/changelog.js +246 -0
  4. package/dist/changeset-config.d.ts +75 -0
  5. package/dist/changeset-config.js +48 -0
  6. package/dist/changeset-dependents.d.ts +132 -0
  7. package/dist/changeset-dependents.js +540 -0
  8. package/dist/changeset-files.d.ts +41 -0
  9. package/dist/changeset-files.js +100 -0
  10. package/dist/changeset-packages.d.ts +59 -0
  11. package/dist/changeset-packages.js +186 -0
  12. package/dist/changeset-release.d.ts +155 -18
  13. package/dist/changeset-release.js +366 -1090
  14. package/dist/changesets.d.ts +2 -0
  15. package/dist/commands/changeset-args.d.ts +5 -4
  16. package/dist/commands/changeset-args.js +2 -5
  17. package/dist/commands/changeset-status.d.ts +2 -0
  18. package/dist/commands/changeset-status.js +6 -2
  19. package/dist/commands/changeset-tag.d.ts +0 -3
  20. package/dist/commands/changeset-tag.js +8 -10
  21. package/dist/commands/changeset-version.d.ts +3 -0
  22. package/dist/commands/changeset-version.js +2 -1
  23. package/dist/file-journal.d.ts +22 -0
  24. package/dist/file-journal.js +56 -0
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +1 -1
  27. package/dist/json-files.d.ts +15 -0
  28. package/dist/json-files.js +35 -0
  29. package/dist/package-meta.js +1 -3
  30. package/dist/regexp.d.ts +7 -0
  31. package/dist/regexp.js +9 -0
  32. package/dist/release-repository.d.ts +102 -0
  33. package/dist/release-repository.js +159 -0
  34. package/dist/release-tags.d.ts +56 -0
  35. package/dist/release-tags.js +89 -0
  36. package/dist/tag-format.d.ts +20 -0
  37. package/dist/tag-format.js +51 -0
  38. package/dist/version-check.js +1 -3
  39. package/dist/version-files.d.ts +36 -0
  40. package/dist/version-files.js +195 -0
  41. package/dist/versions.d.ts +25 -0
  42. package/dist/versions.js +74 -0
  43. package/package.json +3 -1
package/README.md CHANGED
@@ -97,7 +97,7 @@ flowchart TD
97
97
  J -->|Yes| K["Workflow opens or updates the release pull request"]
98
98
  K --> L["Review and merge the release pull request"]
99
99
  L --> M["changeset-tag creates tags for packages changed by that release"]
100
- M --> N["GitHub Releases are created from changelog entries"]
100
+ M --> N["A workflow step creates GitHub Releases from the tag notes"]
101
101
  N --> O["Tag-triggered publish and split workflows run"]
102
102
  J -->|No| P["No package versions or release tags are created"]
103
103
  ```
@@ -142,17 +142,71 @@ changelogEntry: async ( { changeset, commit } ) => {
142
142
  },
143
143
  ```
144
144
 
145
+ Configure `dependents` to re-release packages that bundle a package when it releases. The dependents come from what the packages declare: a package is a dependent of a source when the spec that it declares in its npm `dependencies` or `optionalDependencies` (or the sections that the policy adds) resolves the current version of the source or points at the workspace, and it re-releases when the next version of the source falls outside the declared range, or always with `when: 'always'`. A pin to an older version selects a registry release instead and `changeset-status` warns about the drift until the pin resolves the package again. Versioning a dependent rewrites its declared specs of the source to the released version, keeping their range prefixes, so an exact pin matches the released workspace again and the built bundle matches the changelog. Wildcards and unpinned ranges accept the release without one, like the `devDependencies` of a package. The derived dependents appear in `changeset-status`, `changeset-version` and `changeset-tag` like any other release, and a dependent that releases for its own reasons keeps its own bump and appends the bundle note:
146
+
147
+ ```js
148
+ dependents: {
149
+ bump: 'patch', // Default
150
+ exclude: [ 'actions/wp-release-sign' ], // Package paths under root, or globs over them
151
+ },
152
+ ```
153
+
154
+ Excluded packages still release with their own changesets and stay out of the automatic re-releases.
155
+
156
+ The bundle changelog entry defaults to `Bundle <source> <version>.` and is formatted with `dependents.changelog( { package, source } )`:
157
+
158
+ ```js
159
+ dependents: {
160
+ changelog: ( { source } ) => `Bundle ${ source.package.name } ${ source.version }.`,
161
+ },
162
+ ```
163
+
164
+ `dependents.sections` lists the npm dependency sections that declare a source, defaulting to `dependencies` and `optionalDependencies`; add `devDependencies` for dependencies that end up in the dependent's build, or `peerDependencies` to scan them.
165
+
166
+ The `dependents.dependency` hooks extend the built-in npm scan instead of replacing it, so that both work behind the same range rule and rewrite:
167
+
168
+ ```js
169
+ dependents: {
170
+ dependency: {
171
+ // Specs that declare the source outside package.json, like a Composer
172
+ // require in a vendor-isolated directory.
173
+ find: async ( { package: dependent, source } ) => {
174
+ return [ { file: 'vendor-isolated/composer.json', name: '…', spec: '^0.3' } ];
175
+ },
176
+ // Update the declaration to the released version. Call `remember( file )`
177
+ // before writing a file, so failed versioning rolls it back.
178
+ update: async ( { package: dependent, source, specs, version, remember } ) => { /* … */ },
179
+ },
180
+ },
181
+ ```
182
+
183
+ After versioning, run `npm install --package-lock-only` so that the root lockfile resolves the rewritten pins to the workspace links again; the `Prepare Release` workflow (`prepare-release.yml` in this monorepo) does it with its release pull request.
184
+
145
185
  To release packages separately, for example with a release pull request for each, pass `--package <name>`, repeatable, to version only those packages. A changeset that also names other packages keeps only their entries and its summary, so it still applies to their releases with the same bump. Changesets without packages are left for a run without `--package`.
146
186
 
147
187
  The `changeset-version` result lists each release with its `package`, `bump`, `currentVersion`, `nextVersion` and `changelog` entries, a Markdown list like the one added to `CHANGELOG.md`. Use it to name and describe the release pull request in a repository script, or call `versionChangesets()` from the library and format the entries of each release with `formatChangelogEntries( release.changesets )`.
148
188
 
149
- After merging the release PR, `changeset-tag --since <merge-commit>^1 --push` pushes tags only for packages changed by that release. It pushes one tag at a time because GitHub doesn't trigger workflows for a push of more than three tags. Add `--github-release` to create missing GitHub Releases; it requires `--push`. For initial adoption, preview all missing tags with `changeset-tag --dry-run` (without `--since`), then push if needed. To release each package with a separate workflow instead, `changeset-tag --since <merge-commit>^1 --untagged --github-output` lists the releases that don't have a tag yet, without creating any. Tag pushes need Git write credentials; GitHub Releases need authenticated `gh`.
189
+ After merging the release PR, `changeset-tag --since <merge-commit>^1 --push` pushes tags only for packages changed by that release. It pushes one tag at a time because GitHub doesn't trigger workflows for a push of more than three tags. wp-release doesn't create GitHub Releases: with `--json` or `--github-output`, each entry of `tags` has the `tag`, a `title` like `plugins/alpha@1.2.0`, the release `notes` and a `prerelease` flag, for a workflow step like [`softprops/action-gh-release`](https://github.com/softprops/action-gh-release) to create the release (see the example below). For initial adoption, preview all missing tags with `changeset-tag --dry-run` (without `--since`), then push if needed. To release each package with a separate workflow instead, `changeset-tag --since <merge-commit>^1 --untagged --github-output` lists the releases that don't have a tag yet, without creating any. Tag pushes need Git write credentials.
190
+
191
+ To release a single package, for example from a manual workflow, pass `--package <name>`, repeatable, and with one package `--version <version>` to fail unless the package is at that version. Packages versioned by git tags have no version file, so for them `--version` is the version to tag. Before a tag is created, every `version.to` target must match the version, so a plugin `Version` header that disagrees with its readme `Stable tag` blocks the tag. Versions with a pre-release part, like `1.0.0-rc.1`, have `prerelease` set, so their GitHub Releases can be marked as pre-releases:
150
192
 
151
- To release a single package, for example from a manual workflow, pass `--package <name>`, repeatable, and with one package `--version <version>` to fail unless the package is at that version. Packages versioned by git tags have no version file, so for them `--version` is the version to tag. Before a tag is created, every `version.to` target must match the version, so a plugin `Version` header that disagrees with its readme `Stable tag` blocks the tag. GitHub Releases of versions with a pre-release part, like `1.0.0-rc.1`, are marked as pre-releases.
193
+ ```yaml
194
+ - name: Tag a release
195
+ id: tag
196
+ run: npx wp-release changeset-tag --package "$PACKAGE" --version "$VERSION" --push --github-output
197
+
198
+ # Creates the GitHub Release, or updates it on a rerun.
199
+ - uses: softprops/action-gh-release@v3
200
+ with:
201
+ tag_name: ${{ fromJSON( steps.tag.outputs.result ).tags[0].tag }}
202
+ name: ${{ fromJSON( steps.tag.outputs.result ).tags[0].title }}
203
+ body: ${{ fromJSON( steps.tag.outputs.result ).tags[0].notes }}
204
+ prerelease: ${{ fromJSON( steps.tag.outputs.result ).tags[0].prerelease }}
205
+ ```
152
206
 
153
207
  The release notes are the version's `CHANGELOG.md` section, or without one the commits that changed the package since its previous tag, the highest version below the released one. Configure `releaseNotes( { package, version, tag, previousTag, changelog, commits, notes } )` to format them, for example to add links; `notes` is the default text and `commits` lists `{ hash, shortHash, subject }`, newest first. `changeset-tag --package <name> --dry-run` prints them without creating the tag.
154
208
 
155
- For local development, run the TypeScript source with `npm run wp-release -- <command>` (for example, `npm run wp-release -- changeset-status --since origin/main`). GitHub workflows use the same CLI for versioning and tagging. `SUBTREE_DEPLOY_TOKEN` lets them update the release PR, push tags, and create GitHub Releases.
209
+ For local development, run the TypeScript source with `npm run wp-release -- <command>` (for example, `npm run wp-release -- changeset-status --since origin/main`). GitHub workflows use the same CLI for versioning and tagging. `SUBTREE_DEPLOY_TOKEN` lets them update the release PR and push tags, and the `Tag Release` workflow passes it to `softprops/action-gh-release` to create the GitHub Releases.
156
210
 
157
211
  ### Naming convention
158
212
 
@@ -453,6 +507,20 @@ const { revision, created, pushed } = await new GitDeploy(
453
507
 
454
508
  `resolveGitDeployOptions()` throws a `ValidationError` without a repository URL or with an invalid branch or tag. `GitDeploy` exposes `createTree()`, `fetchPrevious()`, `createRelease()`, `isOutdated()`, `checkTag()`, `getRefsToPush()` and `push()` separately, like `SvnDeploy`, and takes a git command runner from `createGitRunner()` as a dependency. The result has `outdated` when the branch wasn't moved for older source history, the refs to write in `planned` and the refs that were pushed in `pushed`, which is empty in a dry run. `redactUrls()` hides the credentials in URLs.
455
509
 
510
+ Changeset releases:
511
+
512
+ ```js
513
+ import { ChangesetRelease } from '@wpelevator/wp-release';
514
+
515
+ const release = new ChangesetRelease( { cwd: process.cwd() } );
516
+
517
+ const { missingChangesets } = await release.getStatus( { since: 'origin/main' } );
518
+ const { releases } = await release.version( { dryRun: true } );
519
+ const { tags } = await release.tag( { dryRun: true } );
520
+ ```
521
+
522
+ `ChangesetRelease` runs the `changeset-*` commands: `add()`, `getStatus()`, `version()` and `tag()`, plus `getPlan()` and `getPackages()`. Each call reads the configuration, packages and pending changesets again, so one instance can version and then tag. It takes a git runner from `createGitRunner()` as `git`, so that tests can replace it. The functions `addChangeset()`, `getChangesetStatus()`, `versionChangesets()`, `tagChangesets()`, `getChangesetReleasePlan()` and `getChangesetPackages()` do the same with the default runners, in one call.
523
+
456
524
  ### Logging
457
525
 
458
526
  `Logger` filters messages by level and passes them to a writer, like the transports of other Node.js loggers. The writer decides how messages look:
@@ -558,6 +626,35 @@ npm run lint --workspace @wpelevator/wp-release
558
626
 
559
627
  The `wpelevator-source` export condition points at `src/index.ts`. The tests and the `wp-release-deploy-svn` action build use it to work with the source instead of a `dist/` build. It's not set by other tools, so the published package always resolves to `dist/`.
560
628
 
629
+ ### Architecture
630
+
631
+ The package uses ES modules as its unit of encapsulation, like most Node.js CLI tools: a module exports a few functions and keeps its helpers private, so there's no class to wrap them in. A class is used only when it holds state across steps or binds dependencies, so that callers and tests can replace them:
632
+
633
+ - **Workflows with steps** are classes, so that their steps share state and dependencies on the instance: `SvnDeploy` shares the temporary working copy across `prepare()`, `checkout()`, `sync()` and `commit()`, `GitDeploy` binds the options and the git runner to its steps, and `ChangesetRelease` binds the repository and the runners to `add()`, `version()` and `tag()`.
634
+ - **External commands** run through injectable runners: `createSvnRunner()` and `createGitRunner()` return functions that spawn the binary without a shell. The classes take a runner as an option and create the default one when it's missing, so tests pass a fake runner, or one that wraps the real runner and records some commands, instead of putting fake binaries on the `PATH`. The `Svn` and `ReleaseRepository` classes wrap a runner with the commands of their domain.
635
+ - **Domain logic** is plain functions that take their dependencies as arguments, like `discoverPackages( repository, config )`, so that each one can be tested on its own.
636
+ - **Commands** in `src/commands/` parse the CLI options and call the library, and `cli.ts` handles the global options, logging and exit codes. Errors carry their exit code, like `ValidationError` with `2`.
637
+ - Constructors only store their options. Work starts in a method call.
638
+
639
+ The changeset release modules, from the entry point down:
640
+
641
+ | Module | Responsibility |
642
+ |---|---|
643
+ | `changeset-release.ts` | `ChangesetRelease`, which plans releases and runs `add()`, `getStatus()`, `version()` and `tag()`, and the function form of each operation. |
644
+ | `changeset-config.ts` | Loading and validating `wp-release.config.js`, and the version source and target helpers. |
645
+ | `changeset-packages.ts` | Discovering the packages of the configured globs and finding the changed ones. |
646
+ | `changeset-files.ts` | Reading, writing and consuming changeset files. |
647
+ | `changeset-dependents.ts` | The dependents policy: which packages re-release with their sources, and rewriting their dependency specs. |
648
+ | `changesets.ts` | Parsing and formatting changesets, and the release types. |
649
+ | `version-files.ts` | Reading and writing versions in manifests, headers, readmes and PHP constants. |
650
+ | `changelog.ts` | Changelog entries, and the `CHANGELOG.md` and readme changelog sections. |
651
+ | `release-tags.ts` | Choosing the version and previous tag of a release, and building its release notes. |
652
+ | `tag-format.ts` | Formatting tag names from the tag format. |
653
+ | `release-repository.ts` | `ReleaseRepository`, the git commands of a release: tags, commits and changed files. |
654
+ | `github.ts` | Finding the pull request of a commit with the GitHub CLI, for changelog formatters. |
655
+ | `file-journal.ts` | `FileJournal`, which restores every file a failed `changeset-version` run changed. |
656
+ | `versions.ts` | Version checks and SemVer precedence. |
657
+
561
658
  ## Comparison with existing tools
562
659
 
563
660
  | Tool | What it does | How `wp-release` differs |
@@ -0,0 +1,45 @@
1
+ import type { ChangesetConfig } from './changeset-config.ts';
2
+ import type { PackageRelease, VersionedRelease } from './changesets.ts';
3
+ import type { FileJournal } from './file-journal.ts';
4
+ import type { ReleaseRepository } from './release-repository.ts';
5
+ /**
6
+ * Format the changelog entries of each release, with the configured
7
+ * formatter and the commit that added each changeset.
8
+ *
9
+ * @param repository Repository, for the commit of each changeset.
10
+ * @param config Repository release configuration.
11
+ * @param releases Package releases.
12
+ * @param changesetDirectory Directory of the changeset files.
13
+ * @return Releases with their changelog entries.
14
+ */
15
+ export declare function getVersionedReleases(repository: ReleaseRepository, config: ChangesetConfig, releases: PackageRelease[], changesetDirectory: string): Promise<VersionedRelease[]>;
16
+ /**
17
+ * Add the changelog section of a release to the CHANGELOG.md of the package,
18
+ * and with readmeChangelog to the changelog of its plugin readme. A release
19
+ * that already has a section is left as it is.
20
+ *
21
+ * @param directory Package directory.
22
+ * @param release Versioned release.
23
+ * @param date Release date, as YYYY-MM-DD.
24
+ * @param readmeChangelog Whether to update the plugin readme too.
25
+ * @param changelogInsertAfter Marker to insert the section after, if configured.
26
+ * @param journal File journal for rolling back.
27
+ * @throws {ValidationError} If the configured marker isn't found.
28
+ */
29
+ export declare function prependChangelog(directory: string, release: VersionedRelease, date: string, readmeChangelog: boolean, changelogInsertAfter: string | undefined, journal: FileJournal): Promise<void>;
30
+ /**
31
+ * Read the CHANGELOG.md section of a version, without its heading.
32
+ *
33
+ * @param directory Package directory.
34
+ * @param version Released version.
35
+ * @return Section contents, empty without a section.
36
+ */
37
+ export declare function readChangelogSection(directory: string, version: string): Promise<string>;
38
+ /**
39
+ * Read the newest stable version with a CHANGELOG.md section.
40
+ *
41
+ * @param directory Package directory.
42
+ * @param fallback Version to return without a changelog or a version section.
43
+ * @return Latest changelog version.
44
+ */
45
+ export declare function getLatestChangelogVersion(directory: string, fallback: string): Promise<string>;
@@ -0,0 +1,246 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { formatChangelogList } from "./changesets.js";
4
+ import { ValidationError } from "./errors.js";
5
+ import { escapeRegExp } from "./regexp.js";
6
+ /**
7
+ * Format the changelog entries of each release, with the configured
8
+ * formatter and the commit that added each changeset.
9
+ *
10
+ * @param repository Repository, for the commit of each changeset.
11
+ * @param config Repository release configuration.
12
+ * @param releases Package releases.
13
+ * @param changesetDirectory Directory of the changeset files.
14
+ * @return Releases with their changelog entries.
15
+ */
16
+ export async function getVersionedReleases(repository, config, releases, changesetDirectory) {
17
+ const commits = new Map();
18
+ const getCommit = (changeset) => {
19
+ const cached = commits.get(changeset.fileName) ??
20
+ repository.getAddedCommit(path.join(changesetDirectory, changeset.fileName));
21
+ commits.set(changeset.fileName, cached);
22
+ return cached;
23
+ };
24
+ const releasesByName = new Map(releases.map((release) => [release.package.name, release]));
25
+ return Promise.all(releases.map(async (release) => {
26
+ const entries = [];
27
+ // Format one at a time, so that formatters calling an API aren't rate limited.
28
+ for (const changeset of release.changesets) {
29
+ entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
30
+ }
31
+ for (const sourceName of release.sources ?? []) {
32
+ const source = releasesByName.get(sourceName);
33
+ if (!source) {
34
+ throw new ValidationError(`The dependent package "${release.package.name}" bundles unreleased package "${sourceName}".`);
35
+ }
36
+ entries.push(await getDependentsChangelogEntry(config, release.package, source));
37
+ }
38
+ return { ...release, changelog: formatChangelogList(entries) };
39
+ }));
40
+ }
41
+ /**
42
+ * Format the bundle changelog entry of a dependent release.
43
+ *
44
+ * @param config Repository release configuration.
45
+ * @param pkg Dependent package being re-released.
46
+ * @param source Released source package bundled by the dependent.
47
+ * @return Changelog entry.
48
+ */
49
+ async function getDependentsChangelogEntry(config, pkg, source) {
50
+ if (!config.dependents?.changelog) {
51
+ return `Bundle ${source.package.name.split('/').pop()} ${source.nextVersion}.`;
52
+ }
53
+ const entry = await config.dependents.changelog({
54
+ package: pkg,
55
+ source: {
56
+ package: source.package,
57
+ version: source.nextVersion,
58
+ },
59
+ });
60
+ if ('string' !== typeof entry) {
61
+ throw new ValidationError(`The dependents changelog formatter didn't return a string for package "${pkg.name}".`);
62
+ }
63
+ return entry.trim();
64
+ }
65
+ /**
66
+ * Format the changelog entry of a changeset with the configured formatter.
67
+ *
68
+ * @param config Repository release configuration.
69
+ * @param pkg Released package.
70
+ * @param changeset Changeset of the release.
71
+ * @param getCommit Get the commit that added a changeset.
72
+ * @return Changelog entry, the plain summary without a formatter.
73
+ */
74
+ async function getChangelogEntry(config, pkg, changeset, getCommit) {
75
+ const summary = changeset.summary.trim();
76
+ if (!summary || !config.changelogEntry) {
77
+ return summary;
78
+ }
79
+ const commit = await getCommit(changeset);
80
+ const entry = await config.changelogEntry({
81
+ package: pkg,
82
+ changeset,
83
+ ...(commit ? { commit } : {}),
84
+ });
85
+ if ('string' !== typeof entry) {
86
+ throw new ValidationError(`The changelogEntry formatter didn't return a string for changeset ${changeset.fileName}.`);
87
+ }
88
+ return entry.trim();
89
+ }
90
+ /**
91
+ * Add the changelog section of a release to the CHANGELOG.md of the package,
92
+ * and with readmeChangelog to the changelog of its plugin readme. A release
93
+ * that already has a section is left as it is.
94
+ *
95
+ * @param directory Package directory.
96
+ * @param release Versioned release.
97
+ * @param date Release date, as YYYY-MM-DD.
98
+ * @param readmeChangelog Whether to update the plugin readme too.
99
+ * @param changelogInsertAfter Marker to insert the section after, if configured.
100
+ * @param journal File journal for rolling back.
101
+ * @throws {ValidationError} If the configured marker isn't found.
102
+ */
103
+ export async function prependChangelog(directory, release, date, readmeChangelog, changelogInsertAfter, journal) {
104
+ const summaries = release.changesets
105
+ .map((item) => item.summary.trim())
106
+ .filter(Boolean);
107
+ const section = `## ${release.nextVersion} (${date})\n\n${release.changelog}\n\n`;
108
+ const changelog = path.join(directory, 'CHANGELOG.md');
109
+ let previous = '';
110
+ try {
111
+ previous = await readFile(changelog, 'utf8');
112
+ }
113
+ catch (error) {
114
+ if ('ENOENT' !== error.code) {
115
+ throw error;
116
+ }
117
+ }
118
+ const releaseHeading = new RegExp(`^## ${escapeRegExp(release.nextVersion)} \\(`, 'm');
119
+ if (!releaseHeading.test(previous)) {
120
+ await journal.remember(changelog);
121
+ if (undefined !== changelogInsertAfter) {
122
+ await writeFile(changelog, insertAfterMarker(previous, changelogInsertAfter, section, changelog));
123
+ }
124
+ else {
125
+ const versionHeading = /^##[ \t]+v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?:[ \t]+.*)?$/m.exec(previous);
126
+ const heading = /^#[ \t]+[^\r\n]+[ \t]*$/m.exec(previous);
127
+ const insertion = versionHeading ?? heading;
128
+ if (insertion) {
129
+ const before = previous
130
+ .slice(0, insertion.index)
131
+ .replace(/(?:\r?\n)*$/, '');
132
+ const after = previous.slice(insertion.index);
133
+ const prefix = before ? `${before}\n\n` : '';
134
+ await writeFile(changelog, `${prefix}${section}${after}`);
135
+ }
136
+ else {
137
+ await writeFile(changelog, `${section}${previous}`);
138
+ }
139
+ }
140
+ }
141
+ if (readmeChangelog) {
142
+ const readme = ['readme.txt', 'readme.md'].map((name) => path.join(directory, name));
143
+ let readmeUpdated = false;
144
+ let readmeFound = false;
145
+ let readmeMarkerFound = false;
146
+ let readmeAlreadyVersioned = false;
147
+ for (const file of readme) {
148
+ try {
149
+ const contents = await readFile(file, 'utf8');
150
+ readmeFound = true;
151
+ const defaultHeading = undefined === changelogInsertAfter
152
+ ? /^==\s*Changelog\s*==\s*$/im.exec(contents)?.[0]
153
+ : undefined;
154
+ const insertionMarker = changelogInsertAfter ?? defaultHeading;
155
+ readmeMarkerFound ||=
156
+ undefined !== insertionMarker &&
157
+ contents.includes(insertionMarker);
158
+ const alreadyVersioned = new RegExp(`^=\\s*${escapeRegExp(release.nextVersion)}\\s*=$`, 'm').test(contents);
159
+ readmeAlreadyVersioned ||= alreadyVersioned;
160
+ if (insertionMarker &&
161
+ !alreadyVersioned &&
162
+ !readmeUpdated &&
163
+ contents.includes(insertionMarker)) {
164
+ const readmeSection = `\n\n= ${release.nextVersion} =\n\n${summaries
165
+ .map((item) => `* ${item.replaceAll(/\r?\n/g, '\n ')}`)
166
+ .join('\n')}\n\n`;
167
+ await journal.remember(file);
168
+ await writeFile(file, insertAfterMarker(contents, insertionMarker, readmeSection.trimStart(), file));
169
+ readmeUpdated = true;
170
+ }
171
+ }
172
+ catch (error) {
173
+ if ('ENOENT' !== error.code) {
174
+ throw error;
175
+ }
176
+ }
177
+ }
178
+ if (changelogInsertAfter &&
179
+ readmeFound &&
180
+ !readmeMarkerFound &&
181
+ !readmeAlreadyVersioned) {
182
+ throw new ValidationError(`Changelog insertion marker "${changelogInsertAfter}" was not found in a plugin readme in ${directory}.`);
183
+ }
184
+ }
185
+ }
186
+ /**
187
+ * Read the CHANGELOG.md section of a version, without its heading.
188
+ *
189
+ * @param directory Package directory.
190
+ * @param version Released version.
191
+ * @return Section contents, empty without a section.
192
+ */
193
+ export async function readChangelogSection(directory, version) {
194
+ let contents = '';
195
+ try {
196
+ contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
197
+ }
198
+ catch (error) {
199
+ if ('ENOENT' !== error.code) {
200
+ throw error;
201
+ }
202
+ }
203
+ const lines = contents.split(/\r?\n/);
204
+ const headingIndex = lines.findIndex((line) => new RegExp(`^##\\s+${escapeRegExp(version)}(?:\\s|$)`).test(line));
205
+ if (-1 === headingIndex) {
206
+ return '';
207
+ }
208
+ const followingHeading = lines.findIndex((line, index) => index > headingIndex && /^##\s/.test(line));
209
+ const end = -1 === followingHeading ? lines.length : followingHeading;
210
+ return lines
211
+ .slice(headingIndex + 1, end)
212
+ .join('\n')
213
+ .trim();
214
+ }
215
+ /**
216
+ * Read the newest stable version with a CHANGELOG.md section.
217
+ *
218
+ * @param directory Package directory.
219
+ * @param fallback Version to return without a changelog or a version section.
220
+ * @return Latest changelog version.
221
+ */
222
+ export async function getLatestChangelogVersion(directory, fallback) {
223
+ try {
224
+ const contents = await readFile(path.join(directory, 'CHANGELOG.md'), 'utf8');
225
+ const match = /^##\s+(\d+\.\d+\.\d+)(?:\s|$)/m.exec(contents);
226
+ return match?.[1] ?? fallback;
227
+ }
228
+ catch (error) {
229
+ if ('ENOENT' === error.code) {
230
+ return fallback;
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+ function insertAfterMarker(contents, marker, section, file) {
236
+ const index = contents.indexOf(marker);
237
+ if (-1 === index) {
238
+ throw new ValidationError(`Changelog insertion marker "${marker}" was not found in ${file}.`);
239
+ }
240
+ const markerEnd = index + marker.length;
241
+ const after = contents
242
+ .slice(markerEnd)
243
+ .replace(/^(?:[ \t]*\r?\n)+/, '');
244
+ const lineEnding = contents.includes('\r\n') ? '\r\n' : '\n';
245
+ return `${contents.slice(0, markerEnd)}${lineEnding}${lineEnding}${section.replaceAll('\n', lineEnding)}${after}`;
246
+ }
@@ -69,6 +69,79 @@ export interface ChangesetPackageGroup {
69
69
  /** Version source and files to update for every discovered package. */
70
70
  version: PackageVersionConfig;
71
71
  }
72
+ /** Inputs supplied to a dependents changelog formatter. */
73
+ export interface DependentsChangelogInput {
74
+ /** The dependent package being re-released. */
75
+ package: ChangesetPackage;
76
+ /** The released source package that the dependent bundles. */
77
+ source: {
78
+ package: ChangesetPackage;
79
+ /** Version of the source release. */
80
+ version: string;
81
+ };
82
+ }
83
+ /** Inputs supplied to a dependents dependency finder. */
84
+ export interface DependentsFindInput {
85
+ /** The dependent package. */
86
+ package: ChangesetPackage;
87
+ /** The source package. */
88
+ source: ChangesetPackage;
89
+ }
90
+ /** A dependency of a source that a package declares outside package.json. */
91
+ export interface DependentsSpec {
92
+ /** Manifest file that declares the dependency, relative to the dependent package. */
93
+ file: string;
94
+ /** Identifier that holds the spec, like a Composer package name. */
95
+ name: string;
96
+ /** Declared spec of the source. */
97
+ spec: string;
98
+ }
99
+ /** Inputs supplied to a dependents dependency update. */
100
+ export interface DependentsUpdateInput {
101
+ /** The dependent package whose declaration gets updated. */
102
+ package: ChangesetPackage;
103
+ /** The released source package. */
104
+ source: ChangesetPackage;
105
+ /** Released version of the source. */
106
+ version: string;
107
+ /** The specs to update, with their manifest files and identifiers. */
108
+ specs: DependentsSpec[];
109
+ /** Snapshot a file before writing it, so that failed versioning rolls it back. */
110
+ remember: (file: string) => Promise<void>;
111
+ }
112
+ /** How packages find and update their dependency of a source. */
113
+ export interface DependentsDependencyOptions {
114
+ /** Specs of the source that a package declares outside package.json, added to the built-in npm scan, so that the range rule and the rewrite apply to them the same way. */
115
+ find?: (input: DependentsFindInput) => Promise<DependentsSpec[]>;
116
+ /** Update a package's declared dependency of a released source to the released version; defaults to rewriting the found specs, keeping their range prefixes. Call `remember()` with every file path before writing it. */
117
+ update?: (input: DependentsUpdateInput) => Promise<void>;
118
+ }
119
+ /** npm dependency section that the dependents of a policy scan. */
120
+ export type DependentsSection = 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'devDependencies';
121
+ /**
122
+ * Packages that bundle another configured package, re-released whenever that
123
+ * source releases, so that their shipped build includes the new version. The
124
+ * dependents come from the dependencies that each package declares, like the
125
+ * npm packages depending on another through package.json: a package is a
126
+ * dependent of a source when its declared spec resolves the source's current
127
+ * version or points at the workspace, and it releases when the next version
128
+ * of the source falls outside its declared range, or always with
129
+ * `when: 'always'`.
130
+ */
131
+ export interface DependentsConfig {
132
+ /** Bump applied to dependents that release without their own changeset; defaults to patch. */
133
+ bump?: 'patch' | 'minor' | 'major';
134
+ /** Package paths, identifiers or globs never re-released as dependents; they still release with their own changesets. */
135
+ exclude?: string[];
136
+ /** Whether dependents release only when the next source version falls outside their declared ranges, or always; defaults to out-of-range. */
137
+ when?: 'out-of-range' | 'always';
138
+ /** npm dependency sections scanned for sources; defaults to dependencies and optionalDependencies, with devDependencies for dependencies that get bundled into the dependent's build. */
139
+ sections?: DependentsSection[];
140
+ /** Overrides of the built-in npm lookup and spec rewrite, for dependencies that other manifests declare. */
141
+ dependency?: DependentsDependencyOptions;
142
+ /** Changelog entry added to the dependent, for example a bundle note; defaults to "Bundle <source> <version>." */
143
+ changelog?: (input: DependentsChangelogInput) => string | Promise<string>;
144
+ }
72
145
  /** JavaScript configuration for changeset-aware releases. */
73
146
  export interface ChangesetConfig {
74
147
  /** Branch receiving ordinary changes. */
@@ -91,6 +164,8 @@ export interface ChangesetConfig {
91
164
  releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
92
165
  /** Format the CHANGELOG.md entry of a changeset, for example to credit its pull request. */
93
166
  changelogEntry?: (input: ChangelogEntryInput) => string | Promise<string>;
167
+ /** Groups of packages re-released whenever one of their source packages releases. */
168
+ dependents?: DependentsConfig;
94
169
  }
95
170
  /** Inputs supplied to a custom next-version strategy. */
96
171
  export interface NextVersionInput {
@@ -198,6 +198,54 @@ function validateChangesetConfig(config, configFile) {
198
198
  'function' !== typeof candidate.changelogEntry) {
199
199
  throw new ValidationError(`wp-release config ${configFile}.changelogEntry must be a function.`);
200
200
  }
201
+ if (undefined !== candidate.dependents &&
202
+ ('object' !== typeof candidate.dependents ||
203
+ Array.isArray(candidate.dependents) ||
204
+ null === candidate.dependents)) {
205
+ throw new ValidationError(`wp-release config ${configFile}.dependents must be an object.`);
206
+ }
207
+ const dependentsOptions = candidate.dependents;
208
+ if (undefined !== dependentsOptions?.bump &&
209
+ !['patch', 'minor', 'major'].includes(dependentsOptions.bump)) {
210
+ throw new ValidationError(`wp-release config ${configFile}.dependents.bump must be patch, minor or major.`);
211
+ }
212
+ if (undefined !== dependentsOptions?.exclude &&
213
+ (!Array.isArray(dependentsOptions.exclude) ||
214
+ !dependentsOptions.exclude.length ||
215
+ dependentsOptions.exclude.some((item) => 'string' !== typeof item || !item.trim()))) {
216
+ throw new ValidationError(`wp-release config ${configFile}.dependents.exclude must be an array of non-empty strings.`);
217
+ }
218
+ if (undefined !== dependentsOptions?.when &&
219
+ !['out-of-range', 'always'].includes(dependentsOptions.when)) {
220
+ throw new ValidationError(`wp-release config ${configFile}.dependents.when must be out-of-range or always.`);
221
+ }
222
+ if (undefined !== dependentsOptions?.sections &&
223
+ (!Array.isArray(dependentsOptions.sections) ||
224
+ !dependentsOptions.sections.length ||
225
+ dependentsOptions.sections.some((section) => ![
226
+ 'dependencies',
227
+ 'optionalDependencies',
228
+ 'peerDependencies',
229
+ 'devDependencies',
230
+ ].includes(section)))) {
231
+ throw new ValidationError(`wp-release config ${configFile}.dependents.sections must be a list of dependencies, optionalDependencies, peerDependencies or devDependencies.`);
232
+ }
233
+ if (undefined !== dependentsOptions?.changelog &&
234
+ 'function' !== typeof dependentsOptions.changelog) {
235
+ throw new ValidationError(`wp-release config ${configFile}.dependents.changelog must be a function.`);
236
+ }
237
+ if (undefined !== dependentsOptions?.dependency) {
238
+ if ('object' !== typeof dependentsOptions.dependency ||
239
+ null === dependentsOptions.dependency) {
240
+ throw new ValidationError(`wp-release config ${configFile}.dependents.dependency must be an object.`);
241
+ }
242
+ ['find', 'update'].forEach((key) => {
243
+ if (undefined !== dependentsOptions.dependency[key] &&
244
+ 'function' !== typeof dependentsOptions.dependency[key]) {
245
+ throw new ValidationError(`wp-release config ${configFile}.dependents.dependency.${key} must be a function.`);
246
+ }
247
+ });
248
+ }
201
249
  if (undefined !== candidate.releaseNotes &&
202
250
  'function' !== typeof candidate.releaseNotes) {
203
251
  throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);