@wpelevator/wp-release 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # WP Release
2
2
 
3
- Package, checksum, verify and version-check WordPress plugins and themes, and deploy plugins to the WordPress.org plugin SVN repository. Usable as a CLI, as a library, and through the [`wp-release-zip`](https://github.com/wpelevator/wp-release-zip), [`wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums), [`wp-release-sign`](https://github.com/wpelevator/wp-release-sign) and [`wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) GitHub Actions.
3
+ Package, checksum, verify and version-check WordPress plugins and themes, deploy plugins to the WordPress.org plugin SVN repository, and deploy plugins, themes and packages to Git repositories. Usable as a CLI, as a library, and through the [`wp-release-zip`](https://github.com/wpelevator/wp-release-zip), [`wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums), [`wp-release-sign`](https://github.com/wpelevator/wp-release-sign) and [`wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) GitHub Actions.
4
4
 
5
5
  Themes are released by uploading a ZIP on WordPress.org, so `deploy-svn` is for plugins only; use `zip` to build the theme upload.
6
6
 
@@ -32,6 +32,7 @@ npx wp-release --help
32
32
  | `wp-release verify [<source-dir>] --checksums <file> [options]` | Check a plugin or theme directory, or a ZIP, against a checksums manifest. |
33
33
  | `wp-release version-check [<source-dir>] [options]` | Check that the `Version` header, readme `Stable tag`, PHP constants and git tag agree. |
34
34
  | `wp-release deploy-svn [<source-dir>] [options]` | Deploy a plugin to the WordPress.org plugin SVN repository. |
35
+ | `wp-release deploy-git [<source-dir>] --git-url <url> [options]` | Deploy the files of a plugin, theme or package to a Git repository as a release commit. |
35
36
  | `wp-release changeset-add [options]` | Record the release impact of a pull request. |
36
37
  | `wp-release changeset-status [options]` | List pending releases and check changed packages for changesets. |
37
38
  | `wp-release changeset-version [options]` | Apply version bumps and changelog entries. |
@@ -60,11 +61,14 @@ Each command also adds a section to the [job summary](https://docs.github.com/en
60
61
  | Command | Summary |
61
62
  |---|---|
62
63
  | `deploy-svn` | Plugin, linked to its WordPress.org page, version, written SVN paths, the commit linked to its WordPress.org changeset (or the dry run or no-op result), and the SVN changes in a collapsed table. |
64
+ | `deploy-git` | Repository, release commit and the pushed branch and tag. |
63
65
  | `zip` | ZIP, number of files and checksums manifest. |
64
66
  | `checksums` | Manifest and number of files. |
65
67
  | `sign` | Signature files and the public key. |
66
68
  | `verify` | That all files match, or the counts and a table of the changed, missing and extra files. |
67
69
  | `version-check` | The matching version, or a table of the mismatches with their files and lines. |
70
+ | `changeset-version` | A table of the package releases with their version changes and bumps. |
71
+ | `changeset-tag` | Each tag with its previous tag and status, and its release notes in a collapsed section. |
68
72
 
69
73
  A failed command adds its error to the summary, or a table of its problems with their files and lines, listing up to 100 and counting the rest. The Markdown of the summaries isn't part of the library API. File paths are relative to `GITHUB_WORKSPACE`, or the current directory without it. Files outside it, like those of an extracted ZIP, get an annotation without a file. Multi-line messages stay one annotation.
70
74
 
@@ -89,8 +93,8 @@ flowchart TD
89
93
  F -->|Pass| H["Merge the feature pull request to main"]
90
94
  H --> I["changeset-version updates configured version files and changelogs"]
91
95
  I --> J{"Are there package releases to review?"}
92
- J -->|Yes| K["Workflow opens or updates the Version Packages pull request"]
93
- K --> L["Review and merge the Version Packages pull request"]
96
+ J -->|Yes| K["Workflow opens or updates the release pull request"]
97
+ K --> L["Review and merge the release pull request"]
94
98
  L --> M["changeset-tag creates tags for packages changed by that release"]
95
99
  M --> N["GitHub Releases are created from changelog entries"]
96
100
  N --> O["Tag-triggered publish and split workflows run"]
@@ -127,9 +131,17 @@ Add release notes in a feature PR with `npx wp-release changeset-add --package @
127
131
 
128
132
  On the release branch, preview with `changeset-version --dry-run`, then run `changeset-version` to update version targets and changelogs. By default, new Markdown entries go before the first version heading; `changelogInsertAfter` sets a literal insertion marker for `CHANGELOG.md` and, with `readmeChangelog`, the plugin readme. Put the marker in both files; a missing marker is an error. Without a marker, plugin readmes use the `== Changelog ==` heading. Failed updates are rolled back for safe retries.
129
133
 
134
+ 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`.
135
+
136
+ 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 )`.
137
+
130
138
  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`.
131
139
 
132
- 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 Version Packages PR, push tags, and create GitHub Releases.
140
+ 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.
141
+
142
+ 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.
143
+
144
+ 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.
133
145
 
134
146
  ### Naming convention
135
147
 
@@ -143,6 +155,7 @@ For local development, run the TypeScript source with `npm run wp-release -- <co
143
155
  | Verify files against a manifest | `wp-release verify` | |
144
156
  | Check that the versions agree | `wp-release version-check` | |
145
157
  | Deploy to WordPress.org SVN | `wp-release deploy-svn` | [`wpelevator/wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) |
158
+ | Deploy to a Git repository | `wp-release deploy-git` | |
146
159
 
147
160
  Each concept has one name, written in the syntax of each interface:
148
161
 
@@ -154,11 +167,14 @@ Each concept has one name, written in the syntax of each interface:
154
167
  | Git tag to compare | `--git-tag <tag>` | | `gitTag` | Git tag checked by `version-check`. |
155
168
  | SVN tag to create | `--svn-tag <version>` | `svn-tag` | `svnTag` | Tag written by `deploy-svn`, distinct from a git tag. |
156
169
  | SVN repository | `--svn-url <url>` | `svn-url` | `svnUrl` | Deploy destination. |
170
+ | Git repository | `--git-url <url>` | | `gitUrl` | Destination of `deploy-git`. Credentials come from git, like a token in the URL or an SSH key, and are never printed. |
171
+ | Branch and tag to release to | `--branch <name>`, `--tag <name>` | | `branch`, `tag` | Refs written by `deploy-git` in the Git repository, distinct from the git tag that `version-check` compares. |
172
+ | Source history | `--split-prefix <dir>` | | `splitPrefix` | Directory of a monorepo package whose history, from `git subtree split`, becomes the source history of `deploy-git` releases. |
157
173
  | SVN credentials | `--svn-username <name>`, `SVN_USERNAME` and `SVN_PASSWORD` env vars | `svn-username`, `svn-password` | `svnUsername`, `svnPassword` | Needed only to commit. There's no password flag, since it would show in the process list; the action inputs and library options fall back to the env vars. |
158
174
  | Log format | `--log-format <text\|github>`, `--ci` for `github` | always `github` | `writer: new TextWriter()` or `new GitHubActionsWriter()` | How the log is printed: plain text, or GitHub Actions annotations and job summaries. Each name matches the start of its writer class. |
159
175
  | Assets sync | `--no-assets` to skip | `assets: false` to skip | `assets` | Booleans are positive in the library and the action; `--no-assets` is only the CLI negation, never `noAssets` or a `no-assets` input. |
160
176
 
161
- CLI JSON results use camelCase keys like `zipPath`, and GitHub Action outputs use kebab-case like `zip-path`. Library classes are named after the capability (`SvnDeploy`, `Svn`) without repeating "release".
177
+ CLI JSON results use camelCase keys like `zipPath`, and GitHub Action outputs use kebab-case like `zip-path`. Library classes are named after the capability (`SvnDeploy`, `Svn`, `GitDeploy`) without repeating "release".
162
178
 
163
179
  ### File selection
164
180
 
@@ -347,13 +363,43 @@ Validation:
347
363
  - `--trunk` without `--svn-tag` works but warns, because trunk then differs from the stable tag that WordPress.org serves downloads from.
348
364
  - A version mismatch throws a `VersionMismatchError`, a `ValidationError` with the `mismatches` and a problem for each, with its file and line. See [Logging](#logging).
349
365
 
366
+ ### Deploy to Git
367
+
368
+ `deploy-git` deploys the selected files of a plugin, theme or package to a Git repository, like a distribution repository that ships the built files and `vendor/` for Composer installs or GitHub-hosted updates, or the release repository of a monorepo package. It selects files like `zip`, with `.distignore` and `--exclude`, so built files that git ignores in the source repository, like `build/` or `vendor/`, are released too. The source directory must be inside a Git repository, which provides the source history and stores the release objects before they're pushed.
369
+
370
+ | Flag | Default | Writes |
371
+ |---|---|---|
372
+ | `--branch <name>` | off | Push the release to this branch |
373
+ | `--tag <name>` | off | Create this tag for the release |
374
+
375
+ ```bash
376
+ # Release a build to a distribution repository and tag it
377
+ wp-release deploy-git build --git-url git@github.com:me/my-plugin-dist.git --branch main --tag 1.2.0
378
+
379
+ # Release a monorepo package with its own history
380
+ wp-release deploy-git packages/my-plugin --split-prefix packages/my-plugin --git-url … --branch main
381
+ ```
382
+
383
+ How a deploy works:
384
+
385
+ 1. The selected files are written to the object database of the source repository and become a tree, with a temporary index, so the working tree and the index of the repository are left alone. Executable files stay executable.
386
+ 2. The previous release is fetched: the tip of `--branch`, or for a tag-only deploy the tip of the repository's default branch (its `HEAD`), whatever that branch holds. Release a tag together with `--branch` to choose the branch it builds on.
387
+ 3. When the files are the same as in the previous release, that release is reused. Otherwise a release commit is created with the files, whose parents are the previous release and the source history: the source commit (`--source-ref`, default `HEAD`), or with `--split-prefix` the history of that directory from `git subtree split`. The repository keeps the source history, and `git log --first-parent` lists the releases.
388
+ 4. When the previous release on `--branch` already has newer source history, the branch isn't moved, so running an older build again, like a re-run of an earlier CI job, can't release older files. The source history is outdated when it's an older commit of the source history that the previous release was made from, its last parent. `git subtree split` makes this reliable for `--split-prefix` too, since it creates the same commits for the same history. A revert is a new commit, so it's released, and with `--tag` the tag of an older build is still released, without moving the branch.
389
+ 5. The tag is checked in the repository first. A tag that already has the same files is the release: it's reused and nothing is pushed, so running a tag release again is a no-op. A tag with other files fails with exit code `2` before anything is pushed.
390
+ 6. The release is pushed to the branch, unless it didn't change or is outdated, and to the tag, in one atomic push. Pushes are never forced, so the branch only moves forward and a tag is never replaced, also when it was created in the meantime.
391
+
392
+ To release a tag again, for example after a release with the wrong files, delete the tag in the repository (`git push <url> :refs/tags/<tag>`) and run the deploy again. There's no flag for replacing a tag, since installs, Composer and GitHub Actions pins trust that a published tag doesn't change.
393
+
394
+ `--dry-run` runs everything except the push. `--message` sets the commit message, which defaults to `Release <tag or branch> from <source commit>`. Credentials come from git, like a token in the URL or an SSH key, and credentials in URLs are never printed. The commit needs a git identity (`user.name` and `user.email`).
395
+
350
396
  ### Exit codes
351
397
 
352
398
  | Code | Meaning |
353
399
  |---|---|
354
400
  | `0` | Success, dry run, no changes, or help |
355
- | `1` | Unexpected failure, such as a failed svn command |
356
- | `2` | Invalid options, failed release checks, an existing SVN tag, or files that don't match the checksums |
401
+ | `1` | Unexpected failure, such as a failed svn or git command |
402
+ | `2` | Invalid options, failed release checks, an existing SVN tag, a Git tag that exists with another release, or files that don't match the checksums |
357
403
  | `3` | Missing SVN credentials, or credentials that SVN rejected |
358
404
 
359
405
  ## Library
@@ -377,6 +423,24 @@ const { committed, revision, version, changes } = await new SvnDeploy(
377
423
 
378
424
  `resolveDeployOptions()` applies the defaults and throws a `ValidationError` (with `exitCode` `2`) for invalid combinations. `SvnDeploy` exposes each step (`prepare()`, `checkVersions()`, `checkout()`, `sync()`, `commit()` and `cleanup()`) separately so CI wrappers can run and report them one by one; `run()` runs all of them, or nothing when there's nothing to write. `getPlan()` lists the planned SVN changes. The `Svn` class wraps the svn command line, and takes the command runner as a dependency so that tests can replace it.
379
425
 
426
+ Git deploys:
427
+
428
+ ```js
429
+ import { GitDeploy, resolveGitDeployOptions } from '@wpelevator/wp-release';
430
+
431
+ const { revision, created, pushed } = await new GitDeploy(
432
+ resolveGitDeployOptions( {
433
+ sourceDir: 'packages/my-plugin',
434
+ gitUrl: 'git@github.com:me/my-plugin.git',
435
+ branch: 'main',
436
+ tag: '1.2.0',
437
+ splitPrefix: 'packages/my-plugin',
438
+ } )
439
+ ).run();
440
+ ```
441
+
442
+ `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.
443
+
380
444
  ### Logging
381
445
 
382
446
  `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:
@@ -87,6 +87,8 @@ export interface ChangesetConfig {
87
87
  changelogInsertAfter?: string;
88
88
  /** Calculate a package's next version instead of using stable SemVer bumps. */
89
89
  nextVersion?: (input: NextVersionInput) => string;
90
+ /** Format the release notes of a tag, for example to add links. */
91
+ releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
90
92
  }
91
93
  /** Inputs supplied to a custom next-version strategy. */
92
94
  export interface NextVersionInput {
@@ -95,6 +97,26 @@ export interface NextVersionInput {
95
97
  currentVersion: string;
96
98
  changesets: Changeset[];
97
99
  }
100
+ /** A commit of a package release. */
101
+ export interface ReleaseCommit {
102
+ hash: string;
103
+ shortHash: string;
104
+ subject: string;
105
+ }
106
+ /** Inputs supplied to a custom release notes formatter. */
107
+ export interface ReleaseNotesInput {
108
+ package: ChangesetPackage;
109
+ version: string;
110
+ tag: string;
111
+ /** Tag of the previous release of the package, without one for the first release. */
112
+ previousTag?: string;
113
+ /** The version's CHANGELOG.md section, or an empty string without one. */
114
+ changelog: string;
115
+ /** Commits that changed the package since the previous release, newest first. */
116
+ commits: ReleaseCommit[];
117
+ /** The default release notes: the changelog section, or the commit list without one. */
118
+ notes: string;
119
+ }
98
120
  export type WpReleaseConfig = ChangesetConfig;
99
121
  export interface PackageJsonVersionOptions {
100
122
  file?: string;
@@ -194,6 +194,10 @@ function validateChangesetConfig(config, configFile) {
194
194
  'function' !== typeof candidate.nextVersion) {
195
195
  throw new ValidationError(`wp-release config ${configFile}.nextVersion must be a function.`);
196
196
  }
197
+ if (undefined !== candidate.releaseNotes &&
198
+ 'function' !== typeof candidate.releaseNotes) {
199
+ throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);
200
+ }
197
201
  candidate.packages.forEach((group, index) => {
198
202
  if ('object' !== typeof group ||
199
203
  null === group ||
@@ -1,4 +1,4 @@
1
- import type { Changeset, ChangesetReleasePlan, PackageRelease } from './changesets.ts';
1
+ import type { Changeset, ChangesetPackage, ChangesetReleasePlan, PackageRelease } from './changesets.ts';
2
2
  export interface ChangesetRepository {
3
3
  cwd: string;
4
4
  configFile?: string;
@@ -6,6 +6,8 @@ export interface ChangesetRepository {
6
6
  export interface ChangesetVersionInput extends ChangesetRepository {
7
7
  dryRun?: boolean;
8
8
  date?: string;
9
+ /** Version only these packages, leaving the other changesets pending. */
10
+ packages?: string[];
9
11
  }
10
12
  export interface ChangesetTagInput extends ChangesetRepository {
11
13
  dryRun?: boolean;
@@ -13,12 +15,18 @@ export interface ChangesetTagInput extends ChangesetRepository {
13
15
  push?: boolean;
14
16
  githubRelease?: boolean;
15
17
  since?: string;
18
+ /** Tag only these packages. */
19
+ packages?: string[];
20
+ /** Expected version of the single package in `packages`. */
21
+ version?: string;
16
22
  }
17
23
  export interface ChangesetTagResult {
18
24
  tags: Array<{
19
25
  package: string;
20
26
  version: string;
21
27
  tag: string;
28
+ previousTag?: string;
29
+ prerelease: boolean;
22
30
  exists: boolean;
23
31
  created: boolean;
24
32
  notes: string;
@@ -31,6 +39,14 @@ export interface ChangesetTagResult {
31
39
  * @return A release plan; `undefined` when no config is present.
32
40
  */
33
41
  export declare function getChangesetReleasePlan(input: ChangesetRepository): Promise<ChangesetReleasePlan | undefined>;
42
+ /**
43
+ * Discover the packages of the repository configuration.
44
+ *
45
+ * @param input Repository root and optional explicit config file.
46
+ * @return Packages in the configured order with their current versions.
47
+ * @throws {ValidationError} Without a config, or for an invalid one.
48
+ */
49
+ export declare function getChangesetPackages(input: ChangesetRepository): Promise<ChangesetPackage[]>;
34
50
  /**
35
51
  * Create a Changesets-compatible Markdown file.
36
52
  *
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { loadChangesetConfig } from "./changeset-config.js";
7
7
  import { findMainPluginFile } from "./package-meta.js";
8
8
  import { findConstantValues } from "./version-check.js";
9
- import { getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
9
+ import { formatChangelogEntries, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
10
10
  import { ValidationError } from "./errors.js";
11
11
  const execFileAsync = promisify(execFile);
12
12
  /**
@@ -19,6 +19,18 @@ export async function getChangesetReleasePlan(input) {
19
19
  const context = await getReleaseContext(input);
20
20
  return context?.plan;
21
21
  }
22
+ /**
23
+ * Discover the packages of the repository configuration.
24
+ *
25
+ * @param input Repository root and optional explicit config file.
26
+ * @return Packages in the configured order with their current versions.
27
+ * @throws {ValidationError} Without a config, or for an invalid one.
28
+ */
29
+ export async function getChangesetPackages(input) {
30
+ const loaded = await requireConfig(input);
31
+ const packages = await discoverPackages(input.cwd, loaded.config);
32
+ return packages.map(toChangesetPackage);
33
+ }
22
34
  async function getReleaseContext(input) {
23
35
  const loaded = await loadChangesetConfig(input);
24
36
  if (!loaded) {
@@ -110,7 +122,10 @@ export async function versionChangesets(input) {
110
122
  if (!context) {
111
123
  throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
112
124
  }
113
- const { config, packages, plan: releasePlan } = context;
125
+ const { config, packages } = context;
126
+ const releasePlan = input.packages
127
+ ? selectReleasePlan(context.plan, packages, input.packages)
128
+ : context.plan;
114
129
  const date = input.date ?? new Date().toISOString().slice(0, 10);
115
130
  if (!input.dryRun) {
116
131
  const journal = new Map();
@@ -128,9 +143,7 @@ export async function versionChangesets(input) {
128
143
  }
129
144
  const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
130
145
  for (const changeset of releasePlan.changesets) {
131
- const file = path.join(changesetDirectory, changeset.fileName);
132
- await rememberFile(journal, file);
133
- await rm(file);
146
+ await consumeChangeset(path.join(changesetDirectory, changeset.fileName), changeset, input.packages, journal);
134
147
  }
135
148
  }
136
149
  catch (error) {
@@ -147,6 +160,48 @@ export async function versionChangesets(input) {
147
160
  changed: !input.dryRun && releasePlan.changesets.length > 0,
148
161
  };
149
162
  }
163
+ /**
164
+ * Limit a release plan to some packages and the changesets that name them.
165
+ *
166
+ * @param plan Release plan of all pending changesets.
167
+ * @param packages Configured packages.
168
+ * @param names Packages to version.
169
+ * @return Release plan of the named packages.
170
+ * @throws {ValidationError} For unknown packages.
171
+ */
172
+ function selectReleasePlan(plan, packages, names) {
173
+ names.forEach((name) => {
174
+ if (!packages.some((pkg) => pkg.name === name)) {
175
+ throw new ValidationError(`Unknown package "${name}".`);
176
+ }
177
+ });
178
+ return {
179
+ releases: plan.releases.filter((release) => names.includes(release.package.name)),
180
+ changesets: plan.changesets.filter((changeset) => names.some((name) => Object.hasOwn(changeset.packages, name))),
181
+ };
182
+ }
183
+ /**
184
+ * Remove an applied changeset, or only the applied packages from a changeset
185
+ * that names other packages too, so that their releases keep its summary.
186
+ *
187
+ * @param file Changeset file.
188
+ * @param changeset Parsed changeset.
189
+ * @param applied Versioned packages, or undefined when all were versioned.
190
+ * @param journal File journal for rolling back.
191
+ */
192
+ async function consumeChangeset(file, changeset, applied, journal) {
193
+ const remaining = Object.entries(changeset.packages).filter(([name]) => undefined !== applied && !applied.includes(name));
194
+ await rememberFile(journal, file);
195
+ if (remaining.length) {
196
+ await writeFile(file, formatChangeset({
197
+ packages: Object.fromEntries(remaining),
198
+ summary: changeset.summary,
199
+ }));
200
+ }
201
+ else {
202
+ await rm(file);
203
+ }
204
+ }
150
205
  /**
151
206
  * Create missing release tags, optionally pushing them to the configured remote.
152
207
  *
@@ -164,19 +219,26 @@ export async function tagChangesets(input) {
164
219
  if (!context) {
165
220
  throw new ValidationError('No wp-release config found. Add wp-release.config.js or pass --config <path>.');
166
221
  }
222
+ const selected = selectPackages(context.packages, input);
167
223
  const packages = input.since
168
- ? (await changedPackagesSince(input.cwd, context.packages, input.since)).packages
169
- : context.packages;
224
+ ? (await changedPackagesSince(input.cwd, selected, input.since))
225
+ .packages
226
+ : selected;
170
227
  const results = [];
171
228
  const dryRun = input.dryRun || input.untagged;
172
229
  const tagFormat = context.config.tagFormat ?? '{path}/{version}';
173
230
  for (const pkg of packages) {
174
- const version = 'git-tag' === pkg.versionConfig.from.kind
175
- ? await getLatestChangelogVersion(pkg.directory, pkg.currentVersion)
176
- : pkg.currentVersion;
231
+ const version = await getTagVersion(pkg, input.version);
232
+ if (undefined !== input.version && input.version !== version) {
233
+ throw new ValidationError(`Package "${pkg.name}" is at version ${version}, not ${input.version}.`);
234
+ }
177
235
  const tag = formatTag(tagFormat, pkg, version);
178
236
  const exists = await gitTagExists(input.cwd, tag);
179
- const notes = await readReleaseNotes(pkg.directory, version);
237
+ if (!exists) {
238
+ await verifyVersionTargets(input.cwd, pkg, version);
239
+ }
240
+ const previousTag = await getPreviousTag(input.cwd, formatTag(tagFormat, pkg, ''), version);
241
+ const notes = await getReleaseNotes(input.cwd, context.config, pkg, { version, tag, previousTag }, exists ? tag : 'HEAD');
180
242
  if (!exists && !dryRun) {
181
243
  await git(input.cwd, ['tag', tag]);
182
244
  }
@@ -184,6 +246,8 @@ export async function tagChangesets(input) {
184
246
  package: pkg.name,
185
247
  version,
186
248
  tag,
249
+ ...(previousTag ? { previousTag } : {}),
250
+ prerelease: isPrerelease(version),
187
251
  exists,
188
252
  created: !exists && !dryRun,
189
253
  notes,
@@ -223,6 +287,7 @@ async function createGitHubRelease(cwd, release) {
223
287
  `${release.package}@${release.version}`,
224
288
  '--notes',
225
289
  release.notes,
290
+ ...(release.prerelease ? ['--prerelease'] : []),
226
291
  ], { cwd });
227
292
  }
228
293
  catch (error) {
@@ -231,6 +296,141 @@ async function createGitHubRelease(cwd, release) {
231
296
  }
232
297
  }
233
298
  }
299
+ /**
300
+ * Get the version to tag. Packages versioned by their git tags have no
301
+ * version file, so the expected version is the version to tag, or else the
302
+ * latest CHANGELOG.md version.
303
+ *
304
+ * @param pkg Configured package.
305
+ * @param expected Expected version from --version, if any.
306
+ * @return Version to tag.
307
+ */
308
+ async function getTagVersion(pkg, expected) {
309
+ if ('git-tag' !== pkg.versionConfig.from.kind) {
310
+ return pkg.currentVersion;
311
+ }
312
+ return (expected ??
313
+ (await getLatestChangelogVersion(pkg.directory, pkg.currentVersion)));
314
+ }
315
+ /**
316
+ * Pick the packages passed with --package, or all packages without it.
317
+ *
318
+ * @param packages Configured packages.
319
+ * @param input Tag options.
320
+ * @return Selected packages in the configured order.
321
+ * @throws {ValidationError} For unknown packages, or --version without a single package.
322
+ */
323
+ function selectPackages(packages, input) {
324
+ const names = input.packages ?? [];
325
+ if (undefined !== input.version && 1 !== names.length) {
326
+ throw new ValidationError('An expected version requires exactly one package.');
327
+ }
328
+ names.forEach((name) => {
329
+ if (!packages.some((pkg) => pkg.name === name)) {
330
+ throw new ValidationError(`Unknown package "${name}".`);
331
+ }
332
+ });
333
+ return names.length
334
+ ? packages.filter((pkg) => names.includes(pkg.name))
335
+ : packages;
336
+ }
337
+ /**
338
+ * Find the tag of the newest release before a version.
339
+ *
340
+ * @param cwd Repository root.
341
+ * @param prefix Tag prefix of the package, like `plugins/alpha/`.
342
+ * @param version Version being released.
343
+ * @return Previous tag, or undefined for the first release.
344
+ */
345
+ async function getPreviousTag(cwd, prefix, version) {
346
+ const output = await git(cwd, ['tag', '--list', `${prefix}*`]);
347
+ return output
348
+ .trim()
349
+ .split(/\r?\n/)
350
+ .filter((tag) => tag.startsWith(prefix))
351
+ .map((tag) => ({ tag, version: tag.slice(prefix.length) }))
352
+ .filter((item) => isVersion(item.version) &&
353
+ compareVersions(item.version, version) < 0)
354
+ .sort((a, b) => compareVersions(b.version, a.version))[0]?.tag;
355
+ }
356
+ /**
357
+ * Build the release notes of a tag with the configured formatter.
358
+ *
359
+ * @param cwd Repository root.
360
+ * @param config Repository release configuration.
361
+ * @param pkg Released package.
362
+ * @param release Version, tag and previous tag of the release.
363
+ * @param release.version Released version.
364
+ * @param release.tag Release tag.
365
+ * @param release.previousTag Tag of the previous release, if any.
366
+ * @param end Commit of the release: the tag, or HEAD before tagging.
367
+ * @return Release notes.
368
+ */
369
+ async function getReleaseNotes(cwd, config, pkg, release, end) {
370
+ const changelog = await readReleaseNotes(pkg.directory, release.version);
371
+ const commits = await getReleaseCommits(cwd, pkg, end, release.previousTag);
372
+ const notes = changelog ||
373
+ commits
374
+ .map((commit) => `- ${commit.subject} (${commit.shortHash})`)
375
+ .join('\n');
376
+ if (!config.releaseNotes) {
377
+ return notes;
378
+ }
379
+ const formatted = await config.releaseNotes({
380
+ package: toChangesetPackage(pkg),
381
+ ...release,
382
+ changelog,
383
+ commits,
384
+ notes,
385
+ });
386
+ if ('string' !== typeof formatted) {
387
+ throw new ValidationError(`The releaseNotes formatter didn't return a string for package "${pkg.name}".`);
388
+ }
389
+ return formatted.trim();
390
+ }
391
+ /**
392
+ * List the commits that changed a package since its previous release.
393
+ *
394
+ * @param cwd Repository root.
395
+ * @param pkg Released package.
396
+ * @param end Commit of the release.
397
+ * @param previousTag Tag of the previous release, if any.
398
+ * @return Commits, newest first.
399
+ */
400
+ async function getReleaseCommits(cwd, pkg, end, previousTag) {
401
+ const packageFromRepo = path.relative(cwd, pkg.directory).split(path.sep).join('/') ||
402
+ '.';
403
+ const output = await git(cwd, [
404
+ 'log',
405
+ '--format=%H%x1f%h%x1f%s',
406
+ previousTag ? `${previousTag}..${end}` : end,
407
+ '--',
408
+ packageFromRepo,
409
+ ]);
410
+ return output
411
+ .trim()
412
+ .split(/\r?\n/)
413
+ .filter(Boolean)
414
+ .map((line) => {
415
+ const [hash = '', shortHash = '', subject = ''] = line.split('\x1f');
416
+ return { hash, shortHash, subject };
417
+ });
418
+ }
419
+ /**
420
+ * Remove the release configuration from a package passed to config callbacks.
421
+ *
422
+ * @param pkg Configured package.
423
+ * @return Package without its group configuration.
424
+ */
425
+ function toChangesetPackage(pkg) {
426
+ return {
427
+ name: pkg.name,
428
+ directory: pkg.directory,
429
+ path: pkg.path,
430
+ type: pkg.type,
431
+ currentVersion: pkg.currentVersion,
432
+ };
433
+ }
234
434
  /**
235
435
  * Check whether changed files belong to packages without a changeset.
236
436
  *
@@ -549,9 +749,7 @@ async function prependChangelog(directory, release, date, readmeChangelog, chang
549
749
  const summaries = release.changesets
550
750
  .map((item) => item.summary.trim())
551
751
  .filter(Boolean);
552
- const section = `## ${release.nextVersion} (${date})\n\n${summaries
553
- .map((item) => `- ${item.replaceAll(/\r?\n/g, '\n ')}`)
554
- .join('\n')}\n\n`;
752
+ const section = `## ${release.nextVersion} (${date})\n\n${formatChangelogEntries(release.changesets)}\n\n`;
555
753
  const changelog = path.join(directory, 'CHANGELOG.md');
556
754
  let previous = '';
557
755
  try {
@@ -826,10 +1024,60 @@ function packageType(group) {
826
1024
  }
827
1025
  return 'other';
828
1026
  }
1027
+ const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/;
1028
+ function isVersion(version) {
1029
+ return VERSION_PATTERN.test(version);
1030
+ }
1031
+ function isPrerelease(version) {
1032
+ return Boolean(VERSION_PATTERN.exec(version)?.[4]);
1033
+ }
1034
+ /**
1035
+ * Compare two versions with SemVer precedence, where a pre-release like
1036
+ * 1.0.0-rc.1 comes before 1.0.0.
1037
+ *
1038
+ * @param left Version.
1039
+ * @param right Version to compare with.
1040
+ * @return Negative when left is lower, positive when higher, otherwise 0.
1041
+ */
829
1042
  function compareVersions(left, right) {
830
- const a = left.split('.').map(Number);
831
- const b = right.split('.').map(Number);
832
- return ((a[0] ?? 0) - (b[0] ?? 0) ||
1043
+ const [leftCore = '', leftPre] = splitPrerelease(left);
1044
+ const [rightCore = '', rightPre] = splitPrerelease(right);
1045
+ const a = leftCore.split('.').map(Number);
1046
+ const b = rightCore.split('.').map(Number);
1047
+ const core = (a[0] ?? 0) - (b[0] ?? 0) ||
833
1048
  (a[1] ?? 0) - (b[1] ?? 0) ||
834
- (a[2] ?? 0) - (b[2] ?? 0));
1049
+ (a[2] ?? 0) - (b[2] ?? 0);
1050
+ if (core || leftPre === rightPre) {
1051
+ return core;
1052
+ }
1053
+ if (undefined === leftPre || undefined === rightPre) {
1054
+ return undefined === leftPre ? 1 : -1;
1055
+ }
1056
+ return comparePrerelease(leftPre.split('.'), rightPre.split('.'));
1057
+ }
1058
+ function splitPrerelease(version) {
1059
+ const index = version.indexOf('-');
1060
+ return -1 === index
1061
+ ? [version]
1062
+ : [version.slice(0, index), version.slice(index + 1)];
1063
+ }
1064
+ function comparePrerelease(left, right) {
1065
+ const [a, ...leftRest] = left;
1066
+ const [b, ...rightRest] = right;
1067
+ if (undefined === a || undefined === b) {
1068
+ return (undefined === a ? 0 : 1) - (undefined === b ? 0 : 1);
1069
+ }
1070
+ const order = compareIdentifier(a, b);
1071
+ return order || comparePrerelease(leftRest, rightRest);
1072
+ }
1073
+ function compareIdentifier(a, b) {
1074
+ const aNumeric = /^\d+$/.test(a);
1075
+ const bNumeric = /^\d+$/.test(b);
1076
+ if (aNumeric && bNumeric) {
1077
+ return Number(a) - Number(b);
1078
+ }
1079
+ if (aNumeric || bNumeric) {
1080
+ return aNumeric ? -1 : 1;
1081
+ }
1082
+ return Number(a > b) - Number(a < b);
835
1083
  }
@@ -53,6 +53,13 @@ export declare function parseChangeset(contents: string, fileName?: string): Cha
53
53
  * @return Markdown contents with a trailing newline.
54
54
  */
55
55
  export declare function formatChangeset(changeset: Omit<Changeset, 'fileName'>): string;
56
+ /**
57
+ * Format the summaries of changesets as a Markdown list of changelog entries.
58
+ *
59
+ * @param changesets Changesets of a release.
60
+ * @return Markdown list, empty without summaries.
61
+ */
62
+ export declare function formatChangelogEntries(changesets: Changeset[]): string;
56
63
  /**
57
64
  * Return the strongest bump requested for a package across changesets.
58
65
  *
@@ -64,6 +64,19 @@ export function formatChangeset(changeset) {
64
64
  .join('\n');
65
65
  return `---\n${frontmatter}\n---\n\n${changeset.summary.trim()}\n`;
66
66
  }
67
+ /**
68
+ * Format the summaries of changesets as a Markdown list of changelog entries.
69
+ *
70
+ * @param changesets Changesets of a release.
71
+ * @return Markdown list, empty without summaries.
72
+ */
73
+ export function formatChangelogEntries(changesets) {
74
+ return changesets
75
+ .map((item) => item.summary.trim())
76
+ .filter(Boolean)
77
+ .map((item) => `- ${item.replaceAll(/\r?\n/g, '\n ')}`)
78
+ .join('\n');
79
+ }
67
80
  /**
68
81
  * Return the strongest bump requested for a package across changesets.
69
82
  *