@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.
- package/README.md +101 -4
- package/dist/changelog.d.ts +45 -0
- package/dist/changelog.js +246 -0
- package/dist/changeset-config.d.ts +75 -0
- package/dist/changeset-config.js +48 -0
- package/dist/changeset-dependents.d.ts +132 -0
- package/dist/changeset-dependents.js +540 -0
- package/dist/changeset-files.d.ts +41 -0
- package/dist/changeset-files.js +100 -0
- package/dist/changeset-packages.d.ts +59 -0
- package/dist/changeset-packages.js +186 -0
- package/dist/changeset-release.d.ts +155 -18
- package/dist/changeset-release.js +366 -1090
- package/dist/changesets.d.ts +2 -0
- package/dist/commands/changeset-args.d.ts +5 -4
- package/dist/commands/changeset-args.js +2 -5
- package/dist/commands/changeset-status.d.ts +2 -0
- package/dist/commands/changeset-status.js +6 -2
- package/dist/commands/changeset-tag.d.ts +0 -3
- package/dist/commands/changeset-tag.js +8 -10
- package/dist/commands/changeset-version.d.ts +3 -0
- package/dist/commands/changeset-version.js +2 -1
- package/dist/file-journal.d.ts +22 -0
- package/dist/file-journal.js +56 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/json-files.d.ts +15 -0
- package/dist/json-files.js +35 -0
- package/dist/package-meta.js +1 -3
- package/dist/regexp.d.ts +7 -0
- package/dist/regexp.js +9 -0
- package/dist/release-repository.d.ts +102 -0
- package/dist/release-repository.js +159 -0
- package/dist/release-tags.d.ts +56 -0
- package/dist/release-tags.js +89 -0
- package/dist/tag-format.d.ts +20 -0
- package/dist/tag-format.js +51 -0
- package/dist/version-check.js +1 -3
- package/dist/version-files.d.ts +36 -0
- package/dist/version-files.js +195 -0
- package/dist/versions.d.ts +25 -0
- package/dist/versions.js +74 -0
- 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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
package/dist/changeset-config.js
CHANGED
|
@@ -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.`);
|