@wpelevator/wp-release 0.8.0 → 0.10.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. |
@@ -141,6 +142,46 @@ changelogEntry: async ( { changeset, commit } ) => {
141
142
  },
142
143
  ```
143
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
+
144
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`.
145
186
 
146
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 )`.
@@ -188,15 +229,16 @@ CLI JSON results use camelCase keys like `zipPath`, and GitHub Action outputs us
188
229
 
189
230
  ### File selection
190
231
 
191
- `zip`, `checksums` and `deploy-svn` select files the same way:
232
+ `zip`, `checksums`, `deploy-svn` and `deploy-git` select files the same way:
192
233
 
193
234
  | Option | Description |
194
235
  |---|---|
195
236
  | `--distignore <file>` | Path to the ignore file. Defaults to `.distignore` in the source directory when present; an explicitly passed file must exist. |
196
237
  | `--no-distignore` | Don't read any ignore file. |
197
238
  | `--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. |
239
+ | `--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. |
198
240
 
199
- 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.
241
+ `.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.
200
242
 
201
243
  ### Plugin and theme headers
202
244
 
@@ -304,7 +346,7 @@ WP_RELEASE_SIGNING_KEY=… wp-release sign my-plugin.1.2.0.zip my-plugin.1.2.0.c
304
346
  - `--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.
305
347
  - An existing `.sig` file isn't overwritten without `--force`.
306
348
 
307
- 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`.
349
+ 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.
308
350
 
309
351
  ### Version check
310
352
 
@@ -538,7 +580,7 @@ const { valid, changed, missing, extra } = await verifyChecksums( {
538
580
  } );
539
581
  ```
540
582
 
541
- `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`.
583
+ `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`.
542
584
 
543
585
  ## Development
544
586
 
@@ -560,9 +602,9 @@ The `wpelevator-source` export condition points at `src/index.ts`. The tests and
560
602
 
561
603
  | Tool | What it does | How `wp-release` differs |
562
604
  |---|---|---|
563
- | [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. |
605
+ | [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. |
564
606
  | [`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. |
565
607
  | [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. |
566
608
  | [`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. |
567
609
  | [`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. |
568
- | [`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. |
610
+ | [`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. |
@@ -69,6 +69,79 @@ export interface ChangesetPackageGroup {
69
69
  /** Version source and files to update for every discovered package. */
70
70
  version: PackageVersionConfig;
71
71
  }
72
+ /** Inputs supplied to a dependents changelog formatter. */
73
+ export interface DependentsChangelogInput {
74
+ /** The dependent package being re-released. */
75
+ package: ChangesetPackage;
76
+ /** The released source package that the dependent bundles. */
77
+ source: {
78
+ package: ChangesetPackage;
79
+ /** Version of the source release. */
80
+ version: string;
81
+ };
82
+ }
83
+ /** Inputs supplied to a dependents dependency finder. */
84
+ export interface DependentsFindInput {
85
+ /** The dependent package. */
86
+ package: ChangesetPackage;
87
+ /** The source package. */
88
+ source: ChangesetPackage;
89
+ }
90
+ /** A dependency of a source that a package declares outside package.json. */
91
+ export interface DependentsSpec {
92
+ /** Manifest file that declares the dependency, relative to the dependent package. */
93
+ file: string;
94
+ /** Identifier that holds the spec, like a Composer package name. */
95
+ name: string;
96
+ /** Declared spec of the source. */
97
+ spec: string;
98
+ }
99
+ /** Inputs supplied to a dependents dependency update. */
100
+ export interface DependentsUpdateInput {
101
+ /** The dependent package whose declaration gets updated. */
102
+ package: ChangesetPackage;
103
+ /** The released source package. */
104
+ source: ChangesetPackage;
105
+ /** Released version of the source. */
106
+ version: string;
107
+ /** The specs to update, with their manifest files and identifiers. */
108
+ specs: DependentsSpec[];
109
+ /** Snapshot a file before writing it, so that failed versioning rolls it back. */
110
+ remember: (file: string) => Promise<void>;
111
+ }
112
+ /** How packages find and update their dependency of a source. */
113
+ export interface DependentsDependencyOptions {
114
+ /** Specs of the source that a package declares outside package.json, added to the built-in npm scan, so that the range rule and the rewrite apply to them the same way. */
115
+ find?: (input: DependentsFindInput) => Promise<DependentsSpec[]>;
116
+ /** Update a package's declared dependency of a released source to the released version; defaults to rewriting the found specs, keeping their range prefixes. Call `remember()` with every file path before writing it. */
117
+ update?: (input: DependentsUpdateInput) => Promise<void>;
118
+ }
119
+ /** npm dependency section that the dependents of a policy scan. */
120
+ export type DependentsSection = 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'devDependencies';
121
+ /**
122
+ * Packages that bundle another configured package, re-released whenever that
123
+ * source releases, so that their shipped build includes the new version. The
124
+ * dependents come from the dependencies that each package declares, like the
125
+ * npm packages depending on another through package.json: a package is a
126
+ * dependent of a source when its declared spec resolves the source's current
127
+ * version or points at the workspace, and it releases when the next version
128
+ * of the source falls outside its declared range, or always with
129
+ * `when: 'always'`.
130
+ */
131
+ export interface DependentsConfig {
132
+ /** Bump applied to dependents that release without their own changeset; defaults to patch. */
133
+ bump?: 'patch' | 'minor' | 'major';
134
+ /** Package paths, identifiers or globs never re-released as dependents; they still release with their own changesets. */
135
+ exclude?: string[];
136
+ /** Whether dependents release only when the next source version falls outside their declared ranges, or always; defaults to out-of-range. */
137
+ when?: 'out-of-range' | 'always';
138
+ /** npm dependency sections scanned for sources; defaults to dependencies and optionalDependencies, with devDependencies for dependencies that get bundled into the dependent's build. */
139
+ sections?: DependentsSection[];
140
+ /** Overrides of the built-in npm lookup and spec rewrite, for dependencies that other manifests declare. */
141
+ dependency?: DependentsDependencyOptions;
142
+ /** Changelog entry added to the dependent, for example a bundle note; defaults to "Bundle <source> <version>." */
143
+ changelog?: (input: DependentsChangelogInput) => string | Promise<string>;
144
+ }
72
145
  /** JavaScript configuration for changeset-aware releases. */
73
146
  export interface ChangesetConfig {
74
147
  /** Branch receiving ordinary changes. */
@@ -91,6 +164,8 @@ export interface ChangesetConfig {
91
164
  releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
92
165
  /** Format the CHANGELOG.md entry of a changeset, for example to credit its pull request. */
93
166
  changelogEntry?: (input: ChangelogEntryInput) => string | Promise<string>;
167
+ /** Groups of packages re-released whenever one of their source packages releases. */
168
+ dependents?: DependentsConfig;
94
169
  }
95
170
  /** Inputs supplied to a custom next-version strategy. */
96
171
  export interface NextVersionInput {
@@ -198,6 +198,54 @@ function validateChangesetConfig(config, configFile) {
198
198
  'function' !== typeof candidate.changelogEntry) {
199
199
  throw new ValidationError(`wp-release config ${configFile}.changelogEntry must be a function.`);
200
200
  }
201
+ if (undefined !== candidate.dependents &&
202
+ ('object' !== typeof candidate.dependents ||
203
+ Array.isArray(candidate.dependents) ||
204
+ null === candidate.dependents)) {
205
+ throw new ValidationError(`wp-release config ${configFile}.dependents must be an object.`);
206
+ }
207
+ const dependentsOptions = candidate.dependents;
208
+ if (undefined !== dependentsOptions?.bump &&
209
+ !['patch', 'minor', 'major'].includes(dependentsOptions.bump)) {
210
+ throw new ValidationError(`wp-release config ${configFile}.dependents.bump must be patch, minor or major.`);
211
+ }
212
+ if (undefined !== dependentsOptions?.exclude &&
213
+ (!Array.isArray(dependentsOptions.exclude) ||
214
+ !dependentsOptions.exclude.length ||
215
+ dependentsOptions.exclude.some((item) => 'string' !== typeof item || !item.trim()))) {
216
+ throw new ValidationError(`wp-release config ${configFile}.dependents.exclude must be an array of non-empty strings.`);
217
+ }
218
+ if (undefined !== dependentsOptions?.when &&
219
+ !['out-of-range', 'always'].includes(dependentsOptions.when)) {
220
+ throw new ValidationError(`wp-release config ${configFile}.dependents.when must be out-of-range or always.`);
221
+ }
222
+ if (undefined !== dependentsOptions?.sections &&
223
+ (!Array.isArray(dependentsOptions.sections) ||
224
+ !dependentsOptions.sections.length ||
225
+ dependentsOptions.sections.some((section) => ![
226
+ 'dependencies',
227
+ 'optionalDependencies',
228
+ 'peerDependencies',
229
+ 'devDependencies',
230
+ ].includes(section)))) {
231
+ throw new ValidationError(`wp-release config ${configFile}.dependents.sections must be a list of dependencies, optionalDependencies, peerDependencies or devDependencies.`);
232
+ }
233
+ if (undefined !== dependentsOptions?.changelog &&
234
+ 'function' !== typeof dependentsOptions.changelog) {
235
+ throw new ValidationError(`wp-release config ${configFile}.dependents.changelog must be a function.`);
236
+ }
237
+ if (undefined !== dependentsOptions?.dependency) {
238
+ if ('object' !== typeof dependentsOptions.dependency ||
239
+ null === dependentsOptions.dependency) {
240
+ throw new ValidationError(`wp-release config ${configFile}.dependents.dependency must be an object.`);
241
+ }
242
+ ['find', 'update'].forEach((key) => {
243
+ if (undefined !== dependentsOptions.dependency[key] &&
244
+ 'function' !== typeof dependentsOptions.dependency[key]) {
245
+ throw new ValidationError(`wp-release config ${configFile}.dependents.dependency.${key} must be a function.`);
246
+ }
247
+ });
248
+ }
201
249
  if (undefined !== candidate.releaseNotes &&
202
250
  'function' !== typeof candidate.releaseNotes) {
203
251
  throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);
@@ -8,6 +8,8 @@ export interface ChangesetVersionInput extends ChangesetRepository {
8
8
  date?: string;
9
9
  /** Version only these packages, leaving the other changesets pending. */
10
10
  packages?: string[];
11
+ /** Whether the selected sources release their configured dependents; defaults to true. */
12
+ dependents?: boolean;
11
13
  }
12
14
  /** Versioned releases, the applied changesets and whether files were changed. */
13
15
  export interface VersionChangesetsResult {
@@ -93,4 +95,5 @@ export declare function getChangesetStatus(input: ChangesetRepository & {
93
95
  changedPackages: string[];
94
96
  changedPackageFiles: Record<string, string[]>;
95
97
  missingChangesets: string[];
98
+ warnings: string[];
96
99
  }>;