@wpelevator/wp-release 0.7.0 → 0.9.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
@@ -14,7 +14,7 @@ Option names follow the existing community tools (10up WordPress.org actions, `w
14
14
  npm install --save-dev @wpelevator/wp-release
15
15
  ```
16
16
 
17
- Requires Node.js 22.12 or later. `deploy-svn` also needs the `svn` command line client.
17
+ Requires Node.js 22.12 or later. `deploy-svn` also needs the `svn` command line client; the optional `--exclude-gitattributes` flag needs Git.
18
18
 
19
19
  The package is an ES module with TypeScript type definitions. CommonJS projects can load it with `require( '@wpelevator/wp-release' )`, which Node.js supports for ES modules without top-level `await` since 22.12.
20
20
 
@@ -29,6 +29,7 @@ npx wp-release --help
29
29
  | `wp-release zip [<source-dir>] [options]` | Create a ZIP archive with a `{slug}/` root folder, respecting `.distignore`. |
30
30
  | `wp-release checksums [<source-dir>] [options]` | Write the MD5 and SHA-256 hashes of every distributed file to a checksums manifest. |
31
31
  | `wp-release sign <file>... [options]` | Sign files with Ed25519, writing a detached `<file>.sig` next to each. |
32
+ | `wp-release keygen` | Generate a base64 Ed25519 private seed and public key for signing and verification. |
32
33
  | `wp-release verify [<source-dir>] --checksums <file> [options]` | Check a plugin or theme directory, or a ZIP, against a checksums manifest. |
33
34
  | `wp-release version-check [<source-dir>] [options]` | Check that the `Version` header, readme `Stable tag`, PHP constants and git tag agree. |
34
35
  | `wp-release deploy-svn [<source-dir>] [options]` | Deploy a plugin to the WordPress.org plugin SVN repository. |
@@ -131,6 +132,16 @@ Add release notes in a feature PR with `npx wp-release changeset-add --package @
131
132
 
132
133
  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.
133
134
 
135
+ Configure `changelogEntry( { package, changeset, commit } )` to format the changelog entry of each changeset, where `commit` is the `{ hash, shortHash, subject }` of the commit that added the changeset file, if it's committed. The formatted entries go into `CHANGELOG.md`, the `changelog` of each release in the `changeset-version` result, and so the release notes; plugin readmes keep the plain summaries. `getGitHubPullRequest( { cwd, commit } )` finds the `{ number, url, author }` of the pull request that merged a commit with the authenticated `gh` CLI, for example to credit it:
136
+
137
+ ```js
138
+ changelogEntry: async ( { changeset, commit } ) => {
139
+ const pullRequest = commit && ( await getGitHubPullRequest( { cwd: import.meta.dirname, commit: commit.hash } ) );
140
+
141
+ return pullRequest ? `${ changeset.summary } (#${ pullRequest.number } by @${ pullRequest.author })` : changeset.summary;
142
+ },
143
+ ```
144
+
134
145
  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
146
 
136
147
  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 )`.
@@ -178,15 +189,16 @@ CLI JSON results use camelCase keys like `zipPath`, and GitHub Action outputs us
178
189
 
179
190
  ### File selection
180
191
 
181
- `zip`, `checksums` and `deploy-svn` select files the same way:
192
+ `zip`, `checksums`, `deploy-svn` and `deploy-git` select files the same way:
182
193
 
183
194
  | Option | Description |
184
195
  |---|---|
185
196
  | `--distignore <file>` | Path to the ignore file. Defaults to `.distignore` in the source directory when present; an explicitly passed file must exist. |
186
197
  | `--no-distignore` | Don't read any ignore file. |
187
198
  | `--exclude <patterns>` | Extra gitignore-style patterns, comma-separated or repeated, like `--exclude build/,assets/*,images/**/*`. They're applied after the ignore file, so a `!` pattern can also re-include a file. |
199
+ | `--exclude-gitattributes` | Also exclude paths marked `export-ignore` by Git attributes. This is opt-in and requires the source directory to be inside a Git repository. |
188
200
 
189
- Both use full gitignore syntax, including `!` negation. Version control metadata (`.git`, `.svn` and `.hg`, at any depth, including the `.git` files of worktrees and submodules) is always left out, also without an ignore file and when deploying from a ZIP, and no `!` pattern can re-include it. This keeps a repository's history out of ZIPs and SVN deploys when the source directory is the repository root. The comma-separated `--exclude` list works like `--exclude` in `wp i18n make-pot`. `deploy-svn --from-zip` ignores both options, since the ZIP is already filtered. Symbolic links that aren't excluded are an error, since ZIP archives and WordPress.org SVN don't support them.
201
+ `.distignore` and `--exclude` use full gitignore syntax, including `!` negation. `--exclude-gitattributes` asks Git to evaluate `export-ignore` attributes, including nested `.gitattributes` files, then adds those paths to the exclusions. It is off by default, so releases don't unexpectedly depend on Git or change selection when a repository adds attributes. Git must be installed and the source directory must be inside a working tree; the flag is ignored by `deploy-svn --from-zip`, since the ZIP is already filtered. Version control metadata (`.git`, `.svn` and `.hg`, at any depth, including the `.git` files of worktrees and submodules) is always left out, also without an ignore file and when deploying from a ZIP, and no `!` pattern can re-include it. This keeps a repository's history out of ZIPs and deploys when the source directory is the repository root. The comma-separated `--exclude` list works like `--exclude` in `wp i18n make-pot`. Selected symbolic links are an error, since ZIP archives and WordPress.org SVN don't support them.
190
202
 
191
203
  ### Plugin and theme headers
192
204
 
@@ -294,7 +306,7 @@ WP_RELEASE_SIGNING_KEY=… wp-release sign my-plugin.1.2.0.zip my-plugin.1.2.0.c
294
306
  - `--verbose` prints the public key of the signing key, which `--json` also returns as `publicKey`. Pass it to `verify --public-key` and to the plugin that checks the signatures.
295
307
  - An existing `.sig` file isn't overwritten without `--force`.
296
308
 
297
- Generating a key pair isn't part of the package yet. With PHP: `php -r 'echo base64_encode( random_bytes( 32 ) );'` gives a seed, and the public key is printed by `sign --verbose`.
309
+ Generate a key pair with `wp-release keygen`. It prints the private seed once as `WP_RELEASE_SIGNING_KEY=…` and the matching public key. Store the private seed securely; never commit it or put it in a workflow log. Don't run `keygen` in CI or with `--github-output`, since those outputs can expose the private seed. The public key can be distributed to verifiers.
298
310
 
299
311
  ### Version check
300
312
 
@@ -528,7 +540,7 @@ const { valid, changed, missing, extra } = await verifyChecksums( {
528
540
  } );
529
541
  ```
530
542
 
531
- `loadFileRules()` reads the ignore file and `--exclude` patterns into a gitignore matcher and `selectFiles()` lists the files it doesn't ignore. `compareChecksums()` compares two hash maps without reading any files, and `parseChecksums()` validates manifest JSON. `extractZip()` extracts a ZIP after the same checks as `deploy-svn --from-zip`.
543
+ `loadFileRules()` reads the ignore file and `--exclude` patterns into a gitignore matcher, optionally adds Git `export-ignore` paths, and `selectFiles()` lists the files it doesn't ignore. `compareChecksums()` compares two hash maps without reading any files, and `parseChecksums()` validates manifest JSON. `extractZip()` extracts a ZIP after the same checks as `deploy-svn --from-zip`.
532
544
 
533
545
  ## Development
534
546
 
@@ -550,9 +562,9 @@ The `wpelevator-source` export condition points at `src/index.ts`. The tests and
550
562
 
551
563
  | Tool | What it does | How `wp-release` differs |
552
564
  |---|---|---|
553
- | [10up WordPress.org plugin deploy](https://github.com/10up/action-wordpress-plugin-deploy) and [readme/asset update](https://github.com/10up/action-wordpress-plugin-asset-update) actions | Two separate GitHub Actions configured through env vars. A deploy always syncs the build to `trunk/` and copies it to the tag; readme and asset updates need the second action. Assets come from `.wordpress-org` by default, and files are selected with `.distignore` or `.gitattributes`. Files are synced with `rsync`. | One `deploy-svn` command with independent write flags, so a release, a readme-only update and an assets-only update are the same command with different flags. Runs locally and in any CI, not only GitHub Actions, and syncs in Node.js without `rsync`. Checks that the `Version` header, readme `Stable tag` and SVN tag agree before writing, which the 10up actions don't. Assets are deployed only when `--assets-dir` is passed, instead of always syncing `.wordpress-org` when it exists, so a plugin's own `assets/` folder is never uploaded by accident. Adds `--exclude` for patterns without an ignore file and `--from-zip` to deploy the exact files of a ZIP. |
565
+ | [10up WordPress.org plugin deploy](https://github.com/10up/action-wordpress-plugin-deploy) and [readme/asset update](https://github.com/10up/action-wordpress-plugin-asset-update) actions | Two separate GitHub Actions configured through env vars. A deploy always syncs the build to `trunk/` and copies it to the tag; readme and asset updates need the second action. Assets come from `.wordpress-org` by default, and files are selected with `.distignore` or `.gitattributes`. Files are synced with `rsync`. | One `deploy-svn` command with independent write flags, so a release, a readme-only update and an assets-only update are the same command with different flags. Runs locally and in any CI, not only GitHub Actions, and syncs in Node.js without `rsync`. Checks that the `Version` header, readme `Stable tag` and SVN tag agree before writing, which the 10up actions don't. Assets are deployed only when `--assets-dir` is passed, instead of always syncing `.wordpress-org` when it exists, so a plugin's own `assets/` folder is never uploaded by accident. Adds `--exclude` for patterns without an ignore file, opt-in Git `export-ignore` support and `--from-zip` to deploy the exact files of a ZIP. |
554
566
  | [`grunt-wp-deploy`](https://github.com/stephenharris/grunt-wp-deploy) | Grunt task with `deploy_trunk` and `deploy_tag` booleans, interactive password prompts and a `Stable tag` check. | No Grunt dependency and never interactive. `--readme-only` is a separate flag, where `grunt-wp-deploy` can only update trunk with the full build. Assets are independent of trunk writes. |
555
567
  | [codeinwp theme deploy](https://github.com/codeinwp/action-wordpress-theme-deploy) | Fork of the 10up plugin action that commits themes to the themes SVN repository. | Doesn't deploy themes. Theme releases are ZIP uploads on WordPress.org; `zip` builds the upload with the theme folder as the root. |
556
568
  | [`wp dist-archive`](https://github.com/wp-cli/dist-archive-command) | WP-CLI command that zips a project using `.distignore`, with `--plugin-dirname` and `--filename-format`. | Same `.distignore` file and `{name}.{version}` style filename format, but needs only Node.js instead of PHP and WP-CLI. `.distignore` is matched with full gitignore syntax, including `!` negation, and `--distignore` / `--exclude` let you point at another ignore file or add patterns on the command line. Builds are reproducible, and `--checksums` writes a manifest next to the ZIP. |
557
569
  | [`wp plugin verify-checksums`](https://developer.wordpress.org/cli/commands/plugin/verify-checksums/) | WP-CLI command that checks installed plugins against the WordPress.org checksums API. | Reads the same manifest format, but from a file, so it also works for plugins and themes that aren't hosted on WordPress.org and for build directories in CI. `checksums` writes the manifest that WordPress.org generates for its own plugins. |
558
- | [`wp-scripts plugin-zip`](https://github.com/WordPress/gutenberg/tree/trunk/packages/scripts#plugin-zip) | Zips files from `package.json` `files` or a fixed allowlist, named after the npm package, with `--root-folder` / `--no-root-folder`. | Reuses the root folder flags, selects files with `.distignore` instead of an allowlist, puts the version from the plugin headers in the filename and streams the archive instead of building it in memory. |
570
+ | [`wp-scripts plugin-zip`](https://github.com/WordPress/gutenberg/tree/trunk/packages/scripts#plugin-zip) | Zips files from `package.json` `files` or a fixed allowlist, named after the npm package, with `--root-folder` / `--no-root-folder`. | Reuses the root folder flags, selects files with `.distignore` by default and can optionally honor Git `export-ignore` attributes, puts the version from the plugin headers in the filename and streams the archive instead of building it in memory. |
@@ -89,6 +89,8 @@ export interface ChangesetConfig {
89
89
  nextVersion?: (input: NextVersionInput) => string;
90
90
  /** Format the release notes of a tag, for example to add links. */
91
91
  releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
92
+ /** Format the CHANGELOG.md entry of a changeset, for example to credit its pull request. */
93
+ changelogEntry?: (input: ChangelogEntryInput) => string | Promise<string>;
92
94
  }
93
95
  /** Inputs supplied to a custom next-version strategy. */
94
96
  export interface NextVersionInput {
@@ -117,6 +119,13 @@ export interface ReleaseNotesInput {
117
119
  /** The default release notes: the changelog section, or the commit list without one. */
118
120
  notes: string;
119
121
  }
122
+ /** Inputs supplied to a custom changelog entry formatter. */
123
+ export interface ChangelogEntryInput {
124
+ package: ChangesetPackage;
125
+ changeset: Changeset;
126
+ /** Commit that added the changeset file, unless it isn't committed yet. */
127
+ commit?: ReleaseCommit;
128
+ }
120
129
  export type WpReleaseConfig = ChangesetConfig;
121
130
  export interface PackageJsonVersionOptions {
122
131
  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.changelogEntry &&
198
+ 'function' !== typeof candidate.changelogEntry) {
199
+ throw new ValidationError(`wp-release config ${configFile}.changelogEntry must be a function.`);
200
+ }
197
201
  if (undefined !== candidate.releaseNotes &&
198
202
  'function' !== typeof candidate.releaseNotes) {
199
203
  throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);
@@ -1,4 +1,4 @@
1
- import type { Changeset, ChangesetPackage, ChangesetReleasePlan, PackageRelease } from './changesets.ts';
1
+ import type { Changeset, ChangesetPackage, ChangesetReleasePlan, PackageRelease, VersionedRelease } from './changesets.ts';
2
2
  export interface ChangesetRepository {
3
3
  cwd: string;
4
4
  configFile?: string;
@@ -9,6 +9,13 @@ export interface ChangesetVersionInput extends ChangesetRepository {
9
9
  /** Version only these packages, leaving the other changesets pending. */
10
10
  packages?: string[];
11
11
  }
12
+ /** Versioned releases, the applied changesets and whether files were changed. */
13
+ export interface VersionChangesetsResult {
14
+ changesets: Changeset[];
15
+ releases: VersionedRelease[];
16
+ date: string;
17
+ changed: boolean;
18
+ }
12
19
  export interface ChangesetTagInput extends ChangesetRepository {
13
20
  dryRun?: boolean;
14
21
  untagged?: boolean;
@@ -64,10 +71,7 @@ export declare function addChangeset(input: ChangesetRepository & {
64
71
  * @param input Repository and versioning options.
65
72
  * @return Planned releases and whether files were changed.
66
73
  */
67
- export declare function versionChangesets(input: ChangesetVersionInput): Promise<ChangesetReleasePlan & {
68
- date: string;
69
- changed: boolean;
70
- }>;
74
+ export declare function versionChangesets(input: ChangesetVersionInput): Promise<VersionChangesetsResult>;
71
75
  /**
72
76
  * Create missing release tags, optionally pushing them to the configured remote.
73
77
  *
@@ -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 { formatChangelogEntries, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
9
+ import { formatChangelogList, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
10
10
  import { ValidationError } from "./errors.js";
11
11
  const execFileAsync = promisify(execFile);
12
12
  /**
@@ -127,10 +127,12 @@ export async function versionChangesets(input) {
127
127
  ? selectReleasePlan(context.plan, packages, input.packages)
128
128
  : context.plan;
129
129
  const date = input.date ?? new Date().toISOString().slice(0, 10);
130
+ const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
131
+ const releases = await getVersionedReleases(input.cwd, config, releasePlan.releases, changesetDirectory);
130
132
  if (!input.dryRun) {
131
133
  const journal = new Map();
132
134
  try {
133
- for (const release of releasePlan.releases) {
135
+ for (const release of releases) {
134
136
  const configured = packages.find((pkg) => pkg.name === release.package.name);
135
137
  if (!configured) {
136
138
  throw new ValidationError(`Unknown package "${release.package.name}".`);
@@ -141,7 +143,6 @@ export async function versionChangesets(input) {
141
143
  await verifyVersionTargets(input.cwd, configured, release.nextVersion);
142
144
  await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
143
145
  }
144
- const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
145
146
  for (const changeset of releasePlan.changesets) {
146
147
  await consumeChangeset(path.join(changesetDirectory, changeset.fileName), changeset, input.packages, journal);
147
148
  }
@@ -155,11 +156,76 @@ export async function versionChangesets(input) {
155
156
  }
156
157
  }
157
158
  return {
158
- ...releasePlan,
159
+ changesets: releasePlan.changesets,
160
+ releases,
159
161
  date,
160
162
  changed: !input.dryRun && releasePlan.changesets.length > 0,
161
163
  };
162
164
  }
165
+ /**
166
+ * Format the changelog entries of each release, with the configured
167
+ * formatter and the commit that added each changeset.
168
+ *
169
+ * @param cwd Repository root.
170
+ * @param config Repository release configuration.
171
+ * @param releases Package releases.
172
+ * @param changesetDirectory Directory of the changeset files.
173
+ * @return Releases with their changelog entries.
174
+ */
175
+ async function getVersionedReleases(cwd, config, releases, changesetDirectory) {
176
+ const commits = new Map();
177
+ const getCommit = (changeset) => {
178
+ const cached = commits.get(changeset.fileName) ??
179
+ getChangesetCommit(cwd, path.join(changesetDirectory, changeset.fileName));
180
+ commits.set(changeset.fileName, cached);
181
+ return cached;
182
+ };
183
+ return Promise.all(releases.map(async (release) => {
184
+ const entries = [];
185
+ // Format one at a time, so that formatters calling an API aren't rate limited.
186
+ for (const changeset of release.changesets) {
187
+ entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
188
+ }
189
+ return { ...release, changelog: formatChangelogList(entries) };
190
+ }));
191
+ }
192
+ async function getChangelogEntry(config, pkg, changeset, getCommit) {
193
+ const summary = changeset.summary.trim();
194
+ if (!summary || !config.changelogEntry) {
195
+ return summary;
196
+ }
197
+ const commit = await getCommit(changeset);
198
+ const entry = await config.changelogEntry({
199
+ package: pkg,
200
+ changeset,
201
+ ...(commit ? { commit } : {}),
202
+ });
203
+ if ('string' !== typeof entry) {
204
+ throw new ValidationError(`The changelogEntry formatter didn't return a string for changeset ${changeset.fileName}.`);
205
+ }
206
+ return entry.trim();
207
+ }
208
+ /**
209
+ * Find the commit that added a changeset file.
210
+ *
211
+ * @param cwd Repository root.
212
+ * @param file Changeset file.
213
+ * @return Commit, or undefined when the file isn't committed.
214
+ */
215
+ async function getChangesetCommit(cwd, file) {
216
+ const output = await git(cwd, [
217
+ 'log',
218
+ '--diff-filter=A',
219
+ '--format=%H%x1f%h%x1f%s',
220
+ '-1',
221
+ '--',
222
+ path.relative(cwd, file).split(path.sep).join('/'),
223
+ ]);
224
+ const [hash, shortHash = '', subject = ''] = output
225
+ .trim()
226
+ .split('\x1f');
227
+ return hash ? { hash, shortHash, subject } : undefined;
228
+ }
163
229
  /**
164
230
  * Limit a release plan to some packages and the changesets that name them.
165
231
  *
@@ -749,7 +815,7 @@ async function prependChangelog(directory, release, date, readmeChangelog, chang
749
815
  const summaries = release.changesets
750
816
  .map((item) => item.summary.trim())
751
817
  .filter(Boolean);
752
- const section = `## ${release.nextVersion} (${date})\n\n${formatChangelogEntries(release.changesets)}\n\n`;
818
+ const section = `## ${release.nextVersion} (${date})\n\n${release.changelog}\n\n`;
753
819
  const changelog = path.join(directory, 'CHANGELOG.md');
754
820
  let previous = '';
755
821
  try {
@@ -32,6 +32,11 @@ export interface PackageRelease {
32
32
  nextVersion: string;
33
33
  changesets: Changeset[];
34
34
  }
35
+ /** A package release with its formatted changelog entries, once versioned. */
36
+ export interface VersionedRelease extends PackageRelease {
37
+ /** Changelog entries as a Markdown list, as added to CHANGELOG.md. */
38
+ changelog: string;
39
+ }
35
40
  /** Deterministic release plan assembled from pending changesets. */
36
41
  export interface ChangesetReleasePlan {
37
42
  changesets: Changeset[];
@@ -60,6 +65,13 @@ export declare function formatChangeset(changeset: Omit<Changeset, 'fileName'>):
60
65
  * @return Markdown list, empty without summaries.
61
66
  */
62
67
  export declare function formatChangelogEntries(changesets: Changeset[]): string;
68
+ /**
69
+ * Format changelog entries as a Markdown list, indenting their extra lines.
70
+ *
71
+ * @param entries Changelog entries; empty ones are left out.
72
+ * @return Markdown list, empty without entries.
73
+ */
74
+ export declare function formatChangelogList(entries: string[]): string;
63
75
  /**
64
76
  * Return the strongest bump requested for a package across changesets.
65
77
  *
@@ -71,8 +71,16 @@ export function formatChangeset(changeset) {
71
71
  * @return Markdown list, empty without summaries.
72
72
  */
73
73
  export function formatChangelogEntries(changesets) {
74
- return changesets
75
- .map((item) => item.summary.trim())
74
+ return formatChangelogList(changesets.map((item) => item.summary.trim()));
75
+ }
76
+ /**
77
+ * Format changelog entries as a Markdown list, indenting their extra lines.
78
+ *
79
+ * @param entries Changelog entries; empty ones are left out.
80
+ * @return Markdown list, empty without entries.
81
+ */
82
+ export function formatChangelogList(entries) {
83
+ return entries
76
84
  .filter(Boolean)
77
85
  .map((item) => `- ${item.replaceAll(/\r?\n/g, '\n ')}`)
78
86
  .join('\n');
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChecksumsResult } from './commands/checksums.ts';
2
2
  import type { SignResult } from './commands/sign.ts';
3
+ import type { KeygenResult } from './commands/keygen.ts';
3
4
  import type { ZipResult } from './commands/zip.ts';
4
5
  import type { ChangesetVersionResult } from './commands/changeset-version.ts';
5
6
  import { Logger } from './logger.ts';
@@ -44,6 +45,7 @@ export interface CommandResults {
44
45
  zip: ZipResult;
45
46
  checksums: ChecksumsResult;
46
47
  sign: SignResult;
48
+ keygen: KeygenResult;
47
49
  'changeset-add': import('./commands/changeset-add.ts').ChangesetAddResult;
48
50
  'changeset-status': import('./commands/changeset-status.ts').ChangesetStatusResult;
49
51
  'changeset-version': ChangesetVersionResult;
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { parseArgs } from 'node:util';
3
3
  import zip from "./commands/zip.js";
4
4
  import checksums from "./commands/checksums.js";
5
5
  import sign from "./commands/sign.js";
6
+ import keygen from "./commands/keygen.js";
6
7
  import verify from "./commands/verify.js";
7
8
  import versionCheck from "./commands/version-check.js";
8
9
  import deploySvn from "./commands/deploy-svn.js";
@@ -23,6 +24,7 @@ export const commands = [
23
24
  zip,
24
25
  checksums,
25
26
  sign,
27
+ keygen,
26
28
  verify,
27
29
  versionCheck,
28
30
  deploySvn,
@@ -15,6 +15,7 @@ export declare const fileSelectionArgs: ParseArgsOptionsConfig;
15
15
  export declare function getFileSelectionInput(values: ParsedValues): {
16
16
  distignore: string | false | undefined;
17
17
  exclude: string[];
18
+ excludeGitattributes: boolean;
18
19
  };
19
20
  /**
20
21
  * Check that a command got no more positional arguments than it accepts.
@@ -11,6 +11,7 @@ export const fileSelectionArgs = {
11
11
  distignore: { type: 'string' },
12
12
  'no-distignore': { type: 'boolean' },
13
13
  exclude: { type: 'string', multiple: true },
14
+ 'exclude-gitattributes': { type: 'boolean' },
14
15
  };
15
16
  /**
16
17
  * Map the parsed file selection flags to resolveFileSelection() input.
@@ -24,6 +25,7 @@ export function getFileSelectionInput(values) {
24
25
  ? false
25
26
  : getString(values, 'distignore'),
26
27
  exclude: getStrings(values, 'exclude'),
28
+ excludeGitattributes: getBoolean(values, 'exclude-gitattributes'),
27
29
  };
28
30
  }
29
31
  /**
@@ -1,5 +1,4 @@
1
1
  import { versionChangesets } from "../changeset-release.js";
2
- import { formatChangelogEntries } from "../changesets.js";
3
2
  import { getChangesetVersionSummary } from "../summary.js";
4
3
  import { parseChangesetVersionArgs, changesetVersionArgs, } from "./changeset-args.js";
5
4
  export default {
@@ -38,7 +37,7 @@ changesets without packages are left for a run without --package.`,
38
37
  bump: item.bump,
39
38
  currentVersion: item.currentVersion,
40
39
  nextVersion: item.nextVersion,
41
- changelog: formatChangelogEntries(item.changesets),
40
+ changelog: item.changelog,
42
41
  })),
43
42
  },
44
43
  };
@@ -28,6 +28,8 @@ Options:
28
28
  --no-distignore Don't read any ignore file
29
29
  --exclude <patterns> Extra gitignore-style patterns, comma-separated or
30
30
  repeated, like build/,assets/*,images/**/*
31
+ --exclude-gitattributes Also exclude paths marked export-ignore in Git
32
+ attributes (requires a Git working tree)
31
33
  --force Overwrite an existing --output file
32
34
 
33
35
  The manifest uses the WordPress.org plugin checksums format, with paths
@@ -46,6 +46,7 @@ Options:
46
46
  --distignore <file> Ignore file (default: <source-dir>/.distignore when present)
47
47
  --no-distignore Don't read any ignore file
48
48
  --exclude <patterns> Extra gitignore-style patterns, comma-separated or repeated
49
+ --exclude-gitattributes Also exclude paths marked export-ignore in Git attributes
49
50
  --message <text> Release commit message
50
51
  --dry-run Run everything except the push
51
52
 
@@ -60,6 +60,7 @@ Options:
60
60
  --distignore <file> Ignore file (default: <source-dir>/.distignore when present)
61
61
  --no-distignore Don't read any ignore file
62
62
  --exclude <patterns> Extra gitignore-style patterns, comma-separated or repeated
63
+ --exclude-gitattributes Also exclude paths marked export-ignore in Git attributes
63
64
  --svn-url <url> Repository URL with {slug}
64
65
  (default: https://plugins.svn.wordpress.org/{slug}/)
65
66
  --svn-username <name> SVN username (env: SVN_USERNAME, password: SVN_PASSWORD)
@@ -0,0 +1,17 @@
1
+ import type { CommandOutcome } from './command.ts';
2
+ /** Generated Ed25519 signing keys. */
3
+ export interface KeygenResult {
4
+ /** Base64 Ed25519 seed for WP_RELEASE_SIGNING_KEY. */
5
+ privateKey: string;
6
+ /** Base64 Ed25519 public key for verification. */
7
+ publicKey: string;
8
+ }
9
+ declare const _default: {
10
+ name: string;
11
+ usage: string;
12
+ summary: string;
13
+ help: string;
14
+ options: {};
15
+ run({ values, logger }: import("./command.ts").CommandContext): Promise<CommandOutcome<KeygenResult>>;
16
+ };
17
+ export default _default;
@@ -0,0 +1,23 @@
1
+ import { ValidationError } from "../errors.js";
2
+ import { generateSigningKeyPair } from "../signing.js";
3
+ import { getBoolean } from "./command.js";
4
+ export default {
5
+ name: 'keygen',
6
+ usage: 'keygen',
7
+ summary: 'Generate an Ed25519 signing key pair.',
8
+ help: `Generate a signing key pair for "wp-release sign" and "wp-release verify".
9
+
10
+ The private seed is printed once as WP_RELEASE_SIGNING_KEY. Store it securely
11
+ and never commit it or share it. The public key can be distributed to verifiers.`,
12
+ options: {},
13
+ async run({ values, logger }) {
14
+ if (getBoolean(values, 'github-output')) {
15
+ throw new ValidationError('keygen cannot be used with --github-output, which would store the private seed in a workflow output.');
16
+ }
17
+ const result = generateSigningKeyPair();
18
+ logger.info(`WP_RELEASE_SIGNING_KEY=${result.privateKey}`);
19
+ logger.info(`Public key: ${result.publicKey}`);
20
+ logger.info('Store the private seed securely; it cannot be recovered from the public key.');
21
+ return { exitCode: 0, result };
22
+ },
23
+ };
@@ -56,6 +56,8 @@ Options:
56
56
  --no-distignore Don't read any ignore file
57
57
  --exclude <patterns> Extra gitignore-style patterns, comma-separated or
58
58
  repeated, like build/,assets/*,images/**/*
59
+ --exclude-gitattributes Also exclude paths marked export-ignore in Git
60
+ attributes (requires a Git working tree)
59
61
  --checksums Also write the checksums manifest of the archived
60
62
  files next to the ZIP, with .checksums.json
61
63
  instead of .zip
@@ -27,6 +27,8 @@ export interface DeployInput {
27
27
  distignore?: string | false;
28
28
  /** Extra gitignore-style patterns. */
29
29
  exclude?: string | string[];
30
+ /** Also exclude paths marked export-ignore by Git attributes. */
31
+ excludeGitattributes?: boolean;
30
32
  /** Readme file name. */
31
33
  readme?: string;
32
34
  /** ZIP archive to deploy instead of sourceDir. */
@@ -139,6 +139,7 @@ export function resolveDeployOptions(input = {}) {
139
139
  sourceDir,
140
140
  distignore: input.distignore,
141
141
  exclude: input.exclude,
142
+ excludeGitattributes: input.excludeGitattributes,
142
143
  }),
143
144
  assetsDir,
144
145
  readme: input.readme || 'readme.txt',
@@ -21,6 +21,8 @@ export interface FileSelectionInput {
21
21
  distignore?: string | false;
22
22
  /** Extra gitignore-style patterns, comma or newline separated. */
23
23
  exclude?: string | string[];
24
+ /** Also exclude paths marked export-ignore by Git attributes. */
25
+ excludeGitattributes?: boolean;
24
26
  }
25
27
  export interface FileSelection {
26
28
  /** Ignore file path, or null when disabled. */
@@ -29,6 +31,10 @@ export interface FileSelection {
29
31
  distignoreRequired: boolean;
30
32
  /** Extra gitignore-style patterns. */
31
33
  exclude: string[];
34
+ /** Source directory, needed to resolve Git attributes. */
35
+ sourceDir?: string;
36
+ /** Also exclude paths marked export-ignore by Git attributes. */
37
+ excludeGitattributes?: boolean;
32
38
  }
33
39
  /**
34
40
  * Parse exclude patterns from a comma or newline separated string, an array
@@ -1,7 +1,8 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { join, posix, resolve } from 'node:path';
1
+ import { realpath, readdir, readFile } from 'node:fs/promises';
2
+ import { isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import ignore from 'ignore';
4
4
  import { ValidationError } from "./errors.js";
5
+ import { createGitRunner } from "./git.js";
5
6
  export const DISTIGNORE_FILENAME = '.distignore';
6
7
  /**
7
8
  * Version control metadata that is never released, at any depth. Without
@@ -35,11 +36,13 @@ export function parseExclude(value) {
35
36
  * @return Ignore file path (or null), whether it's required, and the patterns.
36
37
  */
37
38
  export function resolveFileSelection(input) {
38
- const { cwd, sourceDir, distignore, exclude } = input;
39
+ const { cwd, sourceDir, distignore, exclude, excludeGitattributes = false, } = input;
39
40
  const selection = {
40
41
  distignore: join(sourceDir, DISTIGNORE_FILENAME),
41
42
  distignoreRequired: false,
42
43
  exclude: parseExclude(exclude),
44
+ sourceDir,
45
+ excludeGitattributes,
43
46
  };
44
47
  if (false === distignore) {
45
48
  selection.distignore = null;
@@ -61,8 +64,101 @@ export function resolveFileSelection(input) {
61
64
  */
62
65
  export async function loadFileRules(selection) {
63
66
  const { distignore = null, distignoreRequired = false, exclude = [], } = selection;
64
- const rules = await readIgnoreFile(distignore, distignoreRequired);
65
- return ignore().add(rules).add(exclude).add(ALWAYS_EXCLUDED);
67
+ const rules = ignore()
68
+ .add(await readIgnoreFile(distignore, distignoreRequired))
69
+ .add(exclude);
70
+ if (selection.excludeGitattributes) {
71
+ if (!selection.sourceDir) {
72
+ throw new ValidationError('--exclude-gitattributes requires a source directory.');
73
+ }
74
+ const excluded = await getExportIgnoredPaths(selection.sourceDir, rules);
75
+ rules.add(excluded.map((path) => `/${escapePattern(path)}`));
76
+ }
77
+ return rules.add(ALWAYS_EXCLUDED);
78
+ }
79
+ /**
80
+ * Read Git's export-ignore attribute for the source files selected by the
81
+ * other ignore rules. Git itself evaluates .gitattributes, including nested
82
+ * files and its pattern syntax.
83
+ *
84
+ * @param sourceDir Directory whose files are being selected.
85
+ * @param rules Rules already loaded from .distignore and --exclude.
86
+ * @return Source-relative paths marked export-ignore.
87
+ */
88
+ async function getExportIgnoredPaths(sourceDir, rules) {
89
+ const runner = createGitRunner();
90
+ let root;
91
+ try {
92
+ ({ stdout: root } = await runner({
93
+ args: ['rev-parse', '--show-toplevel'],
94
+ cwd: sourceDir,
95
+ }));
96
+ }
97
+ catch {
98
+ throw new ValidationError('--exclude-gitattributes requires the source directory to be inside a Git repository.');
99
+ }
100
+ root = await realpath(root.trim());
101
+ const sourceFromRoot = relative(root, await realpath(sourceDir));
102
+ if (isAbsolute(sourceFromRoot) ||
103
+ sourceFromRoot === '..' ||
104
+ sourceFromRoot.startsWith(`..${posix.sep}`)) {
105
+ throw new ValidationError('--exclude-gitattributes requires the source directory to be inside a Git repository.');
106
+ }
107
+ const candidates = await findAttributeCandidates(sourceDir, '', rules);
108
+ if (!candidates.length) {
109
+ return [];
110
+ }
111
+ const pathsFromRoot = candidates.map((path) => posix.join(sourceFromRoot.split('\\').join('/'), path));
112
+ const { stdout } = await runner({
113
+ args: ['check-attr', '-z', '--stdin', 'export-ignore'],
114
+ cwd: root,
115
+ input: pathsFromRoot.map((path) => `${path}\0`).join(''),
116
+ });
117
+ const fields = stdout.split('\0');
118
+ const ignored = [];
119
+ for (let index = 0; index + 2 < fields.length; index += 3) {
120
+ const candidate = candidates[index / 3];
121
+ if (fields[index + 2] === 'set' && candidate) {
122
+ ignored.push(candidate);
123
+ }
124
+ }
125
+ return ignored;
126
+ }
127
+ /**
128
+ * List regular files and symbolic links not already excluded by ignore rules.
129
+ * Symbolic links are included here so Git can exclude them before selectFiles
130
+ * applies its unsupported-link validation.
131
+ *
132
+ * @param sourceDir Source directory.
133
+ * @param dir Relative directory being traversed.
134
+ * @param rules Ignore rules.
135
+ * @return Relative paths to candidate files.
136
+ */
137
+ async function findAttributeCandidates(sourceDir, dir, rules) {
138
+ const entries = await readdir(join(sourceDir, dir), {
139
+ withFileTypes: true,
140
+ });
141
+ const paths = await Promise.all(entries.map(async (entry) => {
142
+ const path = posix.join(dir, entry.name);
143
+ if (entry.isDirectory() && !rules.ignores(`${path}/`)) {
144
+ return findAttributeCandidates(sourceDir, path, rules);
145
+ }
146
+ if ((entry.isFile() || entry.isSymbolicLink()) &&
147
+ !rules.ignores(path)) {
148
+ return [path];
149
+ }
150
+ return [];
151
+ }));
152
+ return paths.flat();
153
+ }
154
+ /**
155
+ * Escape a path for an anchored gitignore pattern.
156
+ *
157
+ * @param path Relative path.
158
+ * @return Pattern-safe path.
159
+ */
160
+ function escapePattern(path) {
161
+ return path.replace(/[\\*?[\\]!#]/g, '\\$&');
66
162
  }
67
163
  /**
68
164
  * Read the ignore file, or nothing when there's no file to read.
@@ -24,6 +24,8 @@ export interface GitDeployInput {
24
24
  distignore?: string | false;
25
25
  /** Extra gitignore-style patterns. */
26
26
  exclude?: string | string[];
27
+ /** Also exclude paths marked export-ignore by Git attributes. */
28
+ excludeGitattributes?: boolean;
27
29
  /** Commit message of the release. */
28
30
  message?: string;
29
31
  /** Run everything except the push. */
@@ -55,6 +55,7 @@ export function resolveGitDeployOptions(input = {}) {
55
55
  sourceDir,
56
56
  distignore: input.distignore,
57
57
  exclude: input.exclude,
58
+ excludeGitattributes: input.excludeGitattributes,
58
59
  }),
59
60
  };
60
61
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A GitHub pull request of a commit.
3
+ */
4
+ export interface GitHubPullRequest {
5
+ number: number;
6
+ url: string;
7
+ /** GitHub handle of the pull request author, without the @. */
8
+ author: string;
9
+ }
10
+ export interface GetGitHubPullRequestInput {
11
+ /** Directory of the repository, whose GitHub remote `gh` uses. */
12
+ cwd: string;
13
+ /** Commit hash. */
14
+ commit: string;
15
+ }
16
+ /**
17
+ * Find the pull request that merged a commit, with the authenticated `gh`
18
+ * command line client.
19
+ *
20
+ * @param input Repository directory and commit.
21
+ * @return The first pull request of the commit, or undefined without one.
22
+ * @throws {ValidationError} If `gh` fails, like without authentication.
23
+ */
24
+ export declare function getGitHubPullRequest(input: GetGitHubPullRequestInput): Promise<GitHubPullRequest | undefined>;
package/dist/github.js ADDED
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { ValidationError } from "./errors.js";
4
+ const execFileAsync = promisify(execFile);
5
+ /**
6
+ * Find the pull request that merged a commit, with the authenticated `gh`
7
+ * command line client.
8
+ *
9
+ * @param input Repository directory and commit.
10
+ * @return The first pull request of the commit, or undefined without one.
11
+ * @throws {ValidationError} If `gh` fails, like without authentication.
12
+ */
13
+ export async function getGitHubPullRequest(input) {
14
+ let stdout;
15
+ try {
16
+ ({ stdout } = await execFileAsync('gh', ['api', `repos/{owner}/{repo}/commits/${input.commit}/pulls`], { cwd: input.cwd }));
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ throw new ValidationError(`Could not find the pull request of commit ${input.commit}: ${message}`);
21
+ }
22
+ const [pullRequest] = JSON.parse(stdout);
23
+ return pullRequest
24
+ ? {
25
+ number: pullRequest.number,
26
+ url: pullRequest.html_url,
27
+ author: pullRequest.user?.login ?? '',
28
+ }
29
+ : undefined;
30
+ }
package/dist/index.d.ts CHANGED
@@ -9,12 +9,12 @@ export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules,
9
9
  export { FILENAME_FORMAT, formatFilename } from './filename.ts';
10
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from './checksums.ts';
11
11
  export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from './package-meta.ts';
12
- export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from './signing.ts';
12
+ export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from './signing.ts';
13
13
  export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from './zip.ts';
14
14
  export { isUrl, downloadFile } from './download.ts';
15
15
  export { readZipEntries, getZipEntryErrors, extractZip } from './unzip.ts';
16
16
  export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from './version-check.ts';
17
- export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, getChangesetBump, getNextVersion, } from './changesets.ts';
17
+ export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, formatChangelogList, getChangesetBump, getNextVersion, } from './changesets.ts';
18
18
  export { addChangeset, getChangesetPackages, getChangesetReleasePlan, getChangesetStatus, versionChangesets, tagChangesets, } from './changeset-release.ts';
19
19
  export { defineConfig, packageJsonVersion, composerJsonVersion, wordpressPluginVersion, wordpressThemeVersion, wordpressStableTag, gitTagVersion, phpConstantVersion, loadChangesetConfig, } from './changeset-config.ts';
20
20
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from './svn.ts';
@@ -26,6 +26,7 @@ export { getGitDeployInputFromArgs } from './commands/deploy-git.ts';
26
26
  export type { CliIo, CommandResults } from './cli.ts';
27
27
  export type { ChecksumsResult } from './commands/checksums.ts';
28
28
  export type { SignResult } from './commands/sign.ts';
29
+ export type { KeygenResult } from './commands/keygen.ts';
29
30
  export type { ZipResult } from './commands/zip.ts';
30
31
  export type { Command, CommandContext, CommandOutcome, ParsedValues, } from './commands/command.ts';
31
32
  export type { DeployArgsInput } from './commands/deploy-svn.ts';
@@ -40,12 +41,14 @@ export type { FindMainPluginFileInput, HeaderValues, PackageMeta, ParseFileHeade
40
41
  export type { CreateZipInput } from './zip.ts';
41
42
  export type { ExtractZipInput, ZipEntryInfo, ZipEntryType } from './unzip.ts';
42
43
  export type { FindVersionMismatchesInput, GitTagInput, VersionCheckResult, VersionMismatch, } from './version-check.ts';
43
- export type { Changeset, ChangesetBump, ChangesetPackages, ChangesetConfig, ChangesetPackageType, ChangesetPackage, PackageRelease, ChangesetReleasePlan, } from './changesets.ts';
44
- export type { ChangesetRepository, ChangesetVersionInput, ChangesetTagInput, ChangesetTagResult, } from './changeset-release.ts';
45
- export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
44
+ export type { Changeset, ChangesetBump, ChangesetPackages, ChangesetConfig, ChangesetPackageType, ChangesetPackage, PackageRelease, VersionedRelease, ChangesetReleasePlan, } from './changesets.ts';
45
+ export type { ChangesetRepository, ChangesetVersionInput, VersionChangesetsResult, ChangesetTagInput, ChangesetTagResult, } from './changeset-release.ts';
46
+ export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, ChangelogEntryInput, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
46
47
  export type { SvnCheckoutInput, SvnCommandInput, SvnCommandOutput, SvnCommitInput, SvnCopyInput, SvnDepth, SvnOptions, SvnPathsInput, SvnPropsetInput, SvnRunner, SvnStatusEntry, SvnStatusType, SvnUpdateInput, } from './svn.ts';
47
48
  export type { SyncDirectoryInput, SyncResult } from './sync.ts';
48
49
  export type { SummaryContext } from './summary.ts';
50
+ export { getGitHubPullRequest } from './github.ts';
51
+ export type { GitHubPullRequest, GetGitHubPullRequestInput } from './github.ts';
49
52
  export type { CheckoutPath, DeployResult } from './svn-deploy.ts';
50
53
  export type { GitCommandInput, GitCommandOutput, GitRunner } from './git.ts';
51
54
  export type { GitDeployInput, GitDeployOptions, GitDeployResult, } from './git-deploy.ts';
package/dist/index.js CHANGED
@@ -9,12 +9,12 @@ export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules,
9
9
  export { FILENAME_FORMAT, formatFilename } from "./filename.js";
10
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from "./checksums.js";
11
11
  export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from "./package-meta.js";
12
- export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from "./signing.js";
12
+ export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from "./signing.js";
13
13
  export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from "./zip.js";
14
14
  export { isUrl, downloadFile } from "./download.js";
15
15
  export { readZipEntries, getZipEntryErrors, extractZip } from "./unzip.js";
16
16
  export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from "./version-check.js";
17
- export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, getChangesetBump, getNextVersion, } from "./changesets.js";
17
+ export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, formatChangelogList, getChangesetBump, getNextVersion, } from "./changesets.js";
18
18
  export { addChangeset, getChangesetPackages, getChangesetReleasePlan, getChangesetStatus, versionChangesets, tagChangesets, } from "./changeset-release.js";
19
19
  export { defineConfig, packageJsonVersion, composerJsonVersion, wordpressPluginVersion, wordpressThemeVersion, wordpressStableTag, gitTagVersion, phpConstantVersion, loadChangesetConfig, } from "./changeset-config.js";
20
20
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from "./svn.js";
@@ -23,3 +23,4 @@ export { ASSET_MIME_TYPES, SvnDeploy } from "./svn-deploy.js";
23
23
  export { GitError, createGitRunner, redactUrls } from "./git.js";
24
24
  export { GitDeploy, isValidRefName, resolveGitDeployOptions, } from "./git-deploy.js";
25
25
  export { getGitDeployInputFromArgs } from "./commands/deploy-git.js";
26
+ export { getGitHubPullRequest } from "./github.js";
package/dist/signing.d.ts CHANGED
@@ -4,6 +4,15 @@ export declare const SIGNATURE_EXTENSION = ".sig";
4
4
  * Environment variable with the base64 Ed25519 signing key.
5
5
  */
6
6
  export declare const SIGNING_KEY_ENV = "WP_RELEASE_SIGNING_KEY";
7
+ /**
8
+ * Generate an Ed25519 key pair in the base64 formats used by wp-release.
9
+ *
10
+ * @return Private seed for WP_RELEASE_SIGNING_KEY and public verification key.
11
+ */
12
+ export declare function generateSigningKeyPair(): {
13
+ privateKey: string;
14
+ publicKey: string;
15
+ };
7
16
  /**
8
17
  * Get an Ed25519 private key from the base64 format that sodium and the
9
18
  * update-client signing_key setting use: a 32-byte seed or a 64-byte
package/dist/signing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
1
+ import { createHash, createPrivateKey, generateKeyPairSync, createPublicKey, sign, verify, } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { readFile, writeFile } from 'node:fs/promises';
4
4
  import { ValidationError } from "./errors.js";
@@ -14,6 +14,23 @@ const PUBLIC_KEY_BYTES = 32;
14
14
  const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
15
15
  // DER prefix of a SPKI Ed25519 public key, followed by the 32-byte key.
16
16
  const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
17
+ /**
18
+ * Generate an Ed25519 key pair in the base64 formats used by wp-release.
19
+ *
20
+ * @return Private seed for WP_RELEASE_SIGNING_KEY and public verification key.
21
+ */
22
+ export function generateSigningKeyPair() {
23
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
24
+ const privateJwk = privateKey.export({ format: 'jwk' });
25
+ const publicJwk = publicKey.export({ format: 'jwk' });
26
+ if (!privateJwk.d || !publicJwk.x) {
27
+ throw new ValidationError('Could not export the generated Ed25519 key pair.');
28
+ }
29
+ return {
30
+ privateKey: Buffer.from(privateJwk.d, 'base64url').toString('base64'),
31
+ publicKey: Buffer.from(publicJwk.x, 'base64url').toString('base64'),
32
+ };
33
+ }
17
34
  /**
18
35
  * Decode a base64 key, accepting the base64url alphabet and whitespace.
19
36
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Package, checksum, sign, verify and version-check WordPress plugins and themes, and deploy plugins to WordPress.org SVN",
5
5
  "license": "MIT",
6
6
  "repository": {