@wpelevator/wp-release 0.4.0 → 0.5.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,10 +1,10 @@
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-deploy-svn`](../../actions/wp-release-deploy-svn) GitHub Action.
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.
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
 
7
- **Status:** first release (0.x). All commands are implemented and tested against local SVN repositories, but `deploy-svn` hasn't been used for a WordPress.org deploy yet, so try it with `--dry-run` first. Signatures are tested against libsodium, but haven't been used for a real release yet.
7
+ **Status:** first release (0.x). Signatures are tested against libsodium, but haven't been used for a real release yet.
8
8
 
9
9
  Option names follow the existing community tools (10up WordPress.org actions, `wp dist-archive`, `wp-scripts plugin-zip`, `grunt-wp-deploy`) so that migrating needs few or no renames. See [Comparison with existing tools](#comparison-with-existing-tools) for the differences.
10
10
 
@@ -35,18 +35,47 @@ npx wp-release --help
35
35
 
36
36
  Run `npx wp-release <command> --help` for the options of each command. Every command reads the plugin or theme directory from the first argument or `--source-dir`, defaulting to the current directory, and prints its result as JSON with `--json`.
37
37
 
38
+ ### GitHub Actions annotations and job summaries
39
+
40
+ With the `github` log format, the CLI uses current GitHub Actions features to report problems where you look for them, instead of only in the log. `--log-format <name>` picks how the CLI logs, separately from the result that `--json` prints:
41
+
42
+ | Log format | Output |
43
+ |---|---|
44
+ | `text` | Plain text. The default outside GitHub Actions. |
45
+ | `github` | GitHub Actions annotations and job summaries. The default when `GITHUB_ACTIONS=true`. `--ci` is a shorthand for it. |
46
+
47
+ Pass `--log-format text` for plain text in GitHub Actions. The GitHub Actions always use the `github` log format. Only the CLI checks `GITHUB_ACTIONS`: the library classes don't read the environment, and choose the format by the [writer](#logging) they get.
48
+
49
+ Errors, warnings and notices are printed as [workflow commands](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands), so they show up as annotations on the run and on the files of a pull request:
50
+
51
+ - `version-check` and `deploy-svn` annotate each version mismatch on the file and line of the version: the `Version` header, the readme `Stable tag` or the PHP constant.
52
+ - `verify` annotates each changed file and each file that isn't in the checksums.
53
+
54
+ Each command also adds a section to the [job summary](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#adding-a-job-summary) of the run, when `GITHUB_STEP_SUMMARY` is set:
55
+
56
+ | Command | Summary |
57
+ |---|---|
58
+ | `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. |
59
+ | `zip` | ZIP, number of files and checksums manifest. |
60
+ | `checksums` | Manifest and number of files. |
61
+ | `sign` | Signature files and the public key. |
62
+ | `verify` | That all files match, or the counts and a table of the changed, missing and extra files. |
63
+ | `version-check` | The matching version, or a table of the mismatches with their files and lines. |
64
+
65
+ 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.
66
+
38
67
  ### Naming convention
39
68
 
40
69
  `@wpelevator/wp-release` is the suite of release tools and `wp-release` is its CLI. "Release" is the whole workflow; each command is one operation in it. Commands are flat: a single word or dashed words like `version-check` and `deploy-svn`, without command groups or aliases. Each GitHub Action is named `wp-release-` followed by the command name, so the CLI command and the `uses:` name always match:
41
70
 
42
71
  | Capability | CLI command | GitHub Action |
43
72
  |---|---|---|
44
- | Build a ZIP | `wp-release zip` | [`wpelevator/wp-release-zip`](../../actions/wp-release-zip) |
45
- | Create a checksums manifest | `wp-release checksums` | [`wpelevator/wp-release-checksums`](../../actions/wp-release-checksums) |
46
- | Sign files | `wp-release sign` | [`wpelevator/wp-release-sign`](../../actions/wp-release-sign) |
73
+ | Build a ZIP | `wp-release zip` | [`wpelevator/wp-release-zip`](https://github.com/wpelevator/wp-release-zip) |
74
+ | Create a checksums manifest | `wp-release checksums` | [`wpelevator/wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums) |
75
+ | Sign files | `wp-release sign` | [`wpelevator/wp-release-sign`](https://github.com/wpelevator/wp-release-sign) |
47
76
  | Verify files against a manifest | `wp-release verify` | |
48
77
  | Check that the versions agree | `wp-release version-check` | |
49
- | Deploy to WordPress.org SVN | `wp-release deploy-svn` | [`wpelevator/wp-release-deploy-svn`](../../actions/wp-release-deploy-svn) |
78
+ | Deploy to WordPress.org SVN | `wp-release deploy-svn` | [`wpelevator/wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) |
50
79
 
51
80
  Each concept has one name, written in the syntax of each interface:
52
81
 
@@ -59,6 +88,7 @@ Each concept has one name, written in the syntax of each interface:
59
88
  | SVN tag to create | `--svn-tag <version>` | `svn-tag` | `svnTag` | Tag written by `deploy-svn`, distinct from a git tag. |
60
89
  | SVN repository | `--svn-url <url>` | `svn-url` | `svnUrl` | Deploy destination. |
61
90
  | 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. |
91
+ | 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. |
62
92
  | 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. |
63
93
 
64
94
  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".
@@ -83,6 +113,8 @@ The commands read the same headers as WordPress. The main plugin file is the top
83
113
 
84
114
  ### ZIP
85
115
 
116
+ The [`wp-release-zip`](https://github.com/wpelevator/wp-release-zip) GitHub Action runs this command.
117
+
86
118
  `zip` writes `{slug}.{version}.zip` to the current directory, or to `--output <file>`, with the selected files in a `{slug}/` root folder:
87
119
 
88
120
  ```bash
@@ -97,6 +129,8 @@ wp-release zip dist --slug my-plugin --checksums
97
129
 
98
130
  ### Checksums
99
131
 
132
+ The [`wp-release-checksums`](https://github.com/wpelevator/wp-release-checksums) GitHub Action runs this command.
133
+
100
134
  `checksums` writes a manifest with the MD5 and SHA-256 hash of every selected file, in the format of the WordPress.org plugin checksums API (`https://downloads.wordpress.org/plugin-checksums/{slug}/{version}.json`). Paths are relative to the plugin or theme folder, sorted, and use forward slashes:
101
135
 
102
136
  ```json
@@ -134,7 +168,7 @@ wp-release verify wp-content/plugins/my-plugin --checksums my-plugin.1.2.0.check
134
168
  - Manifest paths outside the package folder (absolute or with `..`) are rejected.
135
169
  - `--zip <file|url>` checks the files of a ZIP instead of a directory. The ZIP can be a URL, such as the one that WordPress.org builds for a release, and its single root folder is found automatically (or set `--root-folder`). The ZIP is extracted to a temporary directory after the same safety checks as `deploy-svn --from-zip`.
136
170
  - The version in the manifest must match the `Version` header of the checked files, so that an older release with its own valid manifest can't pass as a newer one.
137
- - A manifest can't list itself. A copy of the manifest and of its `.sig` inside the checked files, at the root with the same names as the `--checksums` file or at the path of `--embedded`, is skipped when comparing files, but it must be identical to the `--checksums` file. See [Manifest inside the plugin](#manifest-inside-the-plugin).
171
+ - A manifest can't list itself. A copy of the manifest and of its `.sig` inside the checked files, at the root with the same names as the `--checksums` file or at the path of `--embedded`, is skipped when comparing files, but it must be identical to the `--checksums` file and its `.sig`. A path that the manifest lists is never skipped, and a file that isn't a matching copy is reported as not in the checksums. See [Manifest inside the plugin](#manifest-inside-the-plugin).
138
172
 
139
173
  A checksums manifest proves only that the files match the manifest. With `--public-key <key>`, `verify` first checks `<checksums>.sig` against the base64 Ed25519 public key and fails with exit code `2` before comparing any file when the signature is missing or invalid, so that the manifest itself can be trusted. See [Sign](#sign).
140
174
 
@@ -158,12 +192,14 @@ wp-release deploy-svn build --trunk --svn-tag 1.2.0
158
192
  --checksums build/checksums.json --public-key "$PUBLIC_KEY"
159
193
  ```
160
194
 
161
- - The manifest can have any name and location, for example `--output build/includes/signatures/checksums.json`, and missing folders are created. Tell `verify` where the shipped copy is with `--embedded <path>`, like `--embedded includes/signatures/checksums.json`. The copy and its `.sig` are then skipped when comparing files, must exist, and must be identical to the `--checksums` file and its signature. Without `--embedded`, `verify` looks for the manifest name at the root and only checks it when it's there.
195
+ - The manifest can have any name and location, for example `--output build/includes/signatures/checksums.json`, and missing folders are created. Tell `verify` where the shipped copy is with `--embedded <path>`, like `--embedded includes/signatures/checksums.json`. Both the `--checksums` file and its `.sig` must then exist, both copies must be shipped, and they're skipped when comparing files and must be identical to the originals. The manifest can't list the embedded path. Without `--embedded`, `verify` looks for the manifest name at the root, and skips a file only when it exists, the matching original exists and the manifest doesn't list it. Otherwise it's checked like any other file, so a listed file that has the name of the manifest can't be deleted unnoticed, and a shipped `.sig` without an original is reported as not in the checksums.
162
196
 
163
197
  The public key has to come from somewhere other than the plugin that it checks, because someone who can overwrite the plugin files can also replace a key and a signature inside them. A copy of the manifest also doesn't prevent a downgrade to an older release that was signed too, so the installing side has to enforce a minimum version.
164
198
 
165
199
  ### Sign
166
200
 
201
+ The [`wp-release-sign`](https://github.com/wpelevator/wp-release-sign) GitHub Action runs this command.
202
+
167
203
  `sign` writes a detached signature next to each file, `<file>.sig`, so that downloads and installed copies can be trusted. Sign both the ZIP and the checksums manifest: the ZIP signature covers installs and updates, and the manifest signature covers the contents after unpacking and copies that weren't built by you.
168
204
 
169
205
  ```bash
@@ -192,6 +228,8 @@ wp-release version-check --constant MY_PLUGIN_VERSION --git-tag v1.2.0
192
228
 
193
229
  ### Deploy to SVN
194
230
 
231
+ The [`wp-release-deploy-svn`](https://github.com/wpelevator/wp-release-deploy-svn) GitHub Action runs this command.
232
+
195
233
  `deploy-svn` syncs a plugin to the WordPress.org plugin SVN repository. Each SVN write is a separate flag and nothing is written unless requested. All writes go into one commit.
196
234
 
197
235
  | Flag | Default | Writes |
@@ -240,6 +278,7 @@ Validation:
240
278
  - `--version` defaults to `--svn-tag`, then the `Version` header of the deployed files; when `--version` and `--svn-tag` are both set they must match.
241
279
  - Pre-release versions like `1.0.0-rc.1` can be tagged, but syncing one to `trunk/` warns, since trunk is for the development version.
242
280
  - `--trunk` without `--svn-tag` works but warns, because trunk then differs from the stable tag that WordPress.org serves downloads from.
281
+ - A version mismatch throws a `VersionMismatchError`, a `ValidationError` with the `mismatches` and a problem for each, with its file and line. See [Logging](#logging).
243
282
 
244
283
  ### Exit codes
245
284
 
@@ -271,6 +310,30 @@ const { committed, revision, version, changes } = await new SvnDeploy(
271
310
 
272
311
  `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.
273
312
 
313
+ ### Logging
314
+
315
+ `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:
316
+
317
+ - `TextWriter` prints plain text to `stdout` and `stderr`. It's the default.
318
+ - `GitHubActionsWriter` prints [workflow commands](#github-actions-annotations-and-job-summaries), so that notices, warnings and errors with a file are annotations, and appends summaries to `GITHUB_STEP_SUMMARY`. It's the CLI's `github` log format, and the GitHub Actions pass it to their logger. Its `workspace` and `summaryPath` options default to `GITHUB_WORKSPACE` and `GITHUB_STEP_SUMMARY`.
319
+
320
+ ```js
321
+ import { GitHubActionsWriter, Logger, logError } from '@wpelevator/wp-release';
322
+
323
+ const logger = new Logger( { writer: new GitHubActionsWriter(), verbose: true } );
324
+
325
+ logger.error( 'Version header is 1.2.0, expected 1.3.0.', {
326
+ file: 'my-plugin.php',
327
+ line: 4,
328
+ title: 'Version mismatch',
329
+ } );
330
+ logger.summary( ( { workspace } ) => `### Checked ${ workspace }` );
331
+ ```
332
+
333
+ A writer implements `LogWriter` with `write()`, `startGroup()`, `endGroup()` and `summary()`, so other CI systems can have their own format. `summary()` takes a function that builds the Markdown, which only writers with a summary call.
334
+
335
+ A `ValidationError` can list `problems`, each with a `message` and an optional `file`, `line` and `title`. `logError( logger, error, title )` logs each problem as an error on its file and line and lists them in the summary under the title, or logs the message when there are none. `VersionMismatchError` and `version-check` report the version mismatches this way.
336
+
274
337
  ZIP files and metadata:
275
338
 
276
339
  ```js
package/dist/cli.js CHANGED
@@ -5,8 +5,11 @@ import sign from "./commands/sign.js";
5
5
  import verify from "./commands/verify.js";
6
6
  import versionCheck from "./commands/version-check.js";
7
7
  import deploySvn from "./commands/deploy-svn.js";
8
- import { Logger } from "./logger.js";
9
- import { getBoolean } from "./commands/command.js";
8
+ import { Logger, TextWriter } from "./logger.js";
9
+ import { getBoolean, getString } from "./commands/command.js";
10
+ import { ValidationError } from "./errors.js";
11
+ import { GitHubActionsWriter } from "./github-actions-writer.js";
12
+ import { logError } from "./report.js";
10
13
  export const EXIT_SUCCESS = 0;
11
14
  export const EXIT_FAILURE = 1;
12
15
  export const EXIT_INVALID = 2;
@@ -22,15 +25,19 @@ const globalArgs = {
22
25
  help: { type: 'boolean', short: 'h' },
23
26
  verbose: { type: 'boolean', short: 'v' },
24
27
  quiet: { type: 'boolean', short: 'q' },
28
+ 'log-format': { type: 'string' },
25
29
  ci: { type: 'boolean' },
26
30
  json: { type: 'boolean' },
27
31
  };
28
32
  const globalOptions = `Global options:
29
- -h, --help Show help for the command
30
- -v, --verbose Print more output
31
- -q, --quiet Print errors only
32
- --ci Use GitHub Actions annotations (default when GITHUB_ACTIONS=true)
33
- --json Print the result as JSON`;
33
+ -h, --help Show help for the command
34
+ -v, --verbose Print more output
35
+ -q, --quiet Print errors only
36
+ --log-format <name> Log format: text, or github for GitHub Actions
37
+ annotations and job summaries (default: github
38
+ when GITHUB_ACTIONS=true, otherwise text)
39
+ --ci Same as --log-format github
40
+ --json Print the result as JSON`;
34
41
  /**
35
42
  * Get the top-level help text listing all commands.
36
43
  *
@@ -92,6 +99,40 @@ function getErrorExitCode(error) {
92
99
  }
93
100
  return EXIT_FAILURE;
94
101
  }
102
+ /**
103
+ * Log writers by log format name, each created for the CLI streams. The
104
+ * names match the writer classes: text is TextWriter and github is
105
+ * GitHubActionsWriter.
106
+ */
107
+ const logFormats = {
108
+ text: (streams) => new TextWriter(streams),
109
+ github: (streams) => new GitHubActionsWriter(streams),
110
+ };
111
+ /**
112
+ * Create the log writer of the log format: --log-format, github with --ci, or
113
+ * github in GitHub Actions (GITHUB_ACTIONS=true) and text elsewhere. Only the
114
+ * CLI reads the environment, so the library itself stays CI-agnostic.
115
+ *
116
+ * @param values Parsed option values.
117
+ * @param streams Output streams.
118
+ * @return Log writer.
119
+ * @throws {ValidationError} For an unknown log format, or --ci with another log format.
120
+ */
121
+ function createLogWriter(values, streams) {
122
+ const ci = getBoolean(values, 'ci');
123
+ const detected = 'true' === process.env.GITHUB_ACTIONS || ci ? 'github' : 'text';
124
+ const logFormat = getString(values, 'log-format') ?? detected;
125
+ const create = Object.hasOwn(logFormats, logFormat)
126
+ ? logFormats[logFormat]
127
+ : undefined;
128
+ if (!create) {
129
+ throw new ValidationError(`Unknown log format "${logFormat}". Use ${Object.keys(logFormats).join(' or ')}.`);
130
+ }
131
+ if (ci && 'github' !== logFormat) {
132
+ throw new ValidationError(`--ci is the same as --log-format github and can't be combined with --log-format ${logFormat}.`);
133
+ }
134
+ return create(streams);
135
+ }
95
136
  /**
96
137
  * Parse the arguments of a command and run it.
97
138
  *
@@ -107,7 +148,7 @@ function getErrorExitCode(error) {
107
148
  */
108
149
  async function runCommand(command, args, io) {
109
150
  const { stdout, stderr, cwd } = io;
110
- let logger = new Logger({ stdout, stderr });
151
+ let logger = new Logger({ writer: new TextWriter({ stdout, stderr }) });
111
152
  try {
112
153
  const { values, positionals } = parseArgs({
113
154
  args,
@@ -115,11 +156,9 @@ async function runCommand(command, args, io) {
115
156
  allowPositionals: true,
116
157
  });
117
158
  logger = new Logger({
118
- ci: getBoolean(values, 'ci'),
159
+ writer: createLogWriter(values, { stdout, stderr }),
119
160
  quiet: getBoolean(values, 'quiet') || getBoolean(values, 'json'),
120
161
  verbose: getBoolean(values, 'verbose'),
121
- stdout,
122
- stderr,
123
162
  });
124
163
  const { exitCode, result } = await command.run({
125
164
  values,
@@ -133,7 +172,7 @@ async function runCommand(command, args, io) {
133
172
  return exitCode;
134
173
  }
135
174
  catch (error) {
136
- logger.error(error instanceof Error ? error.message : String(error));
175
+ logError(logger, error, `wp-release ${command.name}`);
137
176
  return getErrorExitCode(error);
138
177
  }
139
178
  }
@@ -3,6 +3,7 @@ import { CHECKSUMS_EXTENSION, createChecksums, writeChecksums, } from "../checks
3
3
  import { loadFileRules, resolveFileSelection } from "../file-selection.js";
4
4
  import { FILENAME_FORMAT, formatFilename } from "../filename.js";
5
5
  import { readPackageMeta } from "../package-meta.js";
6
+ import { getChecksumsSummary } from "../summary.js";
6
7
  import { checkDirectory, fileSelectionArgs, getFileSelectionInput, getSourceDirArg, ignoreOwnFile, ignoreOwnSignature, resolveOutput, sourceDirArgs, } from "./args.js";
7
8
  import { getBoolean, getString } from "./command.js";
8
9
  export default {
@@ -71,6 +72,7 @@ matches the ZIP contents.`,
71
72
  await writeChecksums(checksumsPath, manifest, getBoolean(values, 'force'));
72
73
  logger.debug(`Hashed ${files} files in ${sourceDir}.`);
73
74
  logger.info(checksumsPath);
75
+ logger.summary((context) => getChecksumsSummary(context, { checksumsPath, files }));
74
76
  return { exitCode: 0, result: { checksumsPath, files } };
75
77
  },
76
78
  };
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { stat } from 'node:fs/promises';
3
3
  import { ValidationError } from "../errors.js";
4
+ import { getSignSummary } from "../summary.js";
4
5
  import { SIGNING_KEY_ENV, getPublicKey, parseSigningKey, writeSignature, } from "../signing.js";
5
6
  import { getBoolean } from "./command.js";
6
7
  export default {
@@ -50,6 +51,7 @@ that belongs to the signing key.`,
50
51
  logger.info(signaturePath);
51
52
  signatures.push(signaturePath);
52
53
  }
54
+ logger.summary((context) => getSignSummary(context, { signatures, publicKey }));
53
55
  return { exitCode: 0, result: { signatures, publicKey } };
54
56
  },
55
57
  };
@@ -7,6 +7,8 @@ import { downloadFile, isUrl } from "../download.js";
7
7
  import { loadFileRules, parseExclude } from "../file-selection.js";
8
8
  import { ValidationError } from "../errors.js";
9
9
  import { readPackageMeta } from "../package-meta.js";
10
+ import { logProblems } from "../report.js";
11
+ import { getVerifySummary } from "../summary.js";
10
12
  import { extractZip, readZipEntries } from "../unzip.js";
11
13
  import { SIGNATURE_EXTENSION, parsePublicKey, verifyFileSignature, } from "../signing.js";
12
14
  import { checkDirectory, getSourceDirArg, ignoreOwnFile, ignoreOwnSignature, sourceDirArgs, } from "./args.js";
@@ -99,33 +101,20 @@ function getEmbeddedPath(checksumsPath, embedded) {
99
101
  (isAbsolute(embedded) ||
100
102
  embedded.includes('\\') ||
101
103
  path.startsWith('../') ||
102
- '.' === path)) {
104
+ ['.', '..'].includes(path))) {
103
105
  throw new ValidationError(`--embedded must be a path inside the plugin with forward slashes, got "${embedded}".`);
104
106
  }
105
107
  return embedded || path.endsWith('.json') ? path : null;
106
108
  }
107
109
  /**
108
- * Compare the copies of the manifest and its signature inside the checked
109
- * directory with the given ones. A release that ships the manifest and its
110
- * signature with the plugin files must ship the same bytes that were
111
- * signed, otherwise a verifier on the site would check something else. The
112
- * copies are not part of the manifest, since a manifest can't list itself,
113
- * so they're skipped when comparing the files.
110
+ * Get the manifest and signature pairs to look for inside the checked files.
114
111
  *
115
- * Without an explicit path, the copies are expected at the root with the
116
- * name of the manifest, and are checked only when they exist. With an
117
- * explicit path they must exist.
118
- *
119
- * @param input Directory, manifest and embedded path.
120
- * @param input.dir Directory to check.
121
- * @param input.checksumsPath Manifest path.
122
- * @param input.embedded Explicit path of the embedded manifest.
123
- * @return The copies that were found to differ or be missing, and their paths.
112
+ * @param checksumsPath Manifest path.
113
+ * @param path Relative path of the embedded manifest, if any.
114
+ * @return Pairs of the originals and their copies.
124
115
  */
125
- async function checkEmbeddedCopies(input) {
126
- const { dir, checksumsPath, embedded } = input;
127
- const path = getEmbeddedPath(checksumsPath, embedded);
128
- const pairs = path
116
+ function getCopyPairs(checksumsPath, path) {
117
+ return path
129
118
  ? [
130
119
  { original: checksumsPath, copy: path },
131
120
  {
@@ -134,30 +123,124 @@ async function checkEmbeddedCopies(input) {
134
123
  },
135
124
  ]
136
125
  : [];
137
- const results = await Promise.all(pairs.map(async ({ original, copy }) => {
138
- const file = join(dir, copy);
139
- const hasCopy = existsSync(file);
140
- const hasOriginal = existsSync(original);
141
- const isSame = !hasCopy ||
142
- !hasOriginal ||
143
- resolve(original) === resolve(file) ||
144
- (await readFile(original)).equals(await readFile(file));
145
- return {
146
- copy,
147
- missing: Boolean(embedded) && hasOriginal && !hasCopy,
148
- differing: !isSame,
149
- };
150
- }));
126
+ }
127
+ /**
128
+ * Check that an explicit --embedded path can be verified: the manifest can't
129
+ * list its own copies, and the originals to compare with must exist.
130
+ *
131
+ * @param pairs Pairs of the originals and their copies.
132
+ * @param manifest Checksums manifest.
133
+ * @throws {ValidationError} If a copy is listed or an original is missing.
134
+ */
135
+ function checkExplicitPairs(pairs, manifest) {
136
+ pairs.forEach(({ original, copy }) => {
137
+ if (Object.hasOwn(manifest.files, copy)) {
138
+ throw new ValidationError(`The checksums list ${copy}, but a manifest can't list its own embedded copy.`);
139
+ }
140
+ if (!existsSync(original)) {
141
+ throw new ValidationError(`--embedded needs ${original} to compare ${copy} with, but it doesn't exist.`);
142
+ }
143
+ });
144
+ }
145
+ /**
146
+ * Check that two paths are the same file or have the same bytes.
147
+ *
148
+ * @param original Path of the original.
149
+ * @param file Path of the copy.
150
+ * @return Whether the contents are identical.
151
+ */
152
+ async function hasSameContents(original, file) {
153
+ return (resolve(original) === resolve(file) ||
154
+ (await readFile(original)).equals(await readFile(file)));
155
+ }
156
+ /**
157
+ * Compare the copies of the manifest and its signature inside the checked
158
+ * directory with the given ones. A release that ships the manifest and its
159
+ * signature with the plugin files must ship the same bytes that were
160
+ * signed, otherwise a verifier on the site would check something else. The
161
+ * copies are not part of the manifest, since a manifest can't list itself,
162
+ * so they're skipped when comparing the files.
163
+ *
164
+ * With an explicit path both originals and both copies must exist. Without
165
+ * one, the copies are expected at the root with the name of the manifest,
166
+ * and a file is treated as a copy only when it exists, the matching
167
+ * original exists and the manifest doesn't list it. Any other file is
168
+ * checked like the rest of the files.
169
+ *
170
+ * @param input Directory, manifest and embedded path.
171
+ * @param input.dir Directory to check.
172
+ * @param input.manifest Checksums manifest.
173
+ * @param input.checksumsPath Manifest path.
174
+ * @param input.embedded Explicit path of the embedded manifest.
175
+ * @return The copies that were found to differ or be missing, and the paths of the existing copies.
176
+ * @throws {ValidationError} If an explicit path can't be verified.
177
+ */
178
+ async function checkEmbeddedCopies(input) {
179
+ const { dir, manifest, checksumsPath, embedded } = input;
180
+ const pairs = getCopyPairs(checksumsPath, getEmbeddedPath(checksumsPath, embedded));
181
+ if (embedded) {
182
+ checkExplicitPairs(pairs, manifest);
183
+ }
184
+ const found = pairs.filter(({ original, copy }) => existsSync(join(dir, copy)) &&
185
+ (embedded ||
186
+ (existsSync(original) &&
187
+ !Object.hasOwn(manifest.files, copy))));
188
+ const results = await Promise.all(found.map(async ({ original, copy }) => ({
189
+ copy,
190
+ isSame: await hasSameContents(original, join(dir, copy)),
191
+ })));
151
192
  return {
152
- paths: pairs.map(({ copy }) => copy),
193
+ paths: found.map(({ copy }) => copy),
153
194
  differing: results
154
- .filter((result) => result.differing)
155
- .map((result) => result.copy),
156
- missing: results
157
- .filter((result) => result.missing)
158
- .map((result) => result.copy),
195
+ .filter(({ isSame }) => !isSame)
196
+ .map(({ copy }) => copy),
197
+ missing: embedded
198
+ ? pairs
199
+ .filter(({ copy }) => !existsSync(join(dir, copy)))
200
+ .map(({ copy }) => copy)
201
+ : [],
159
202
  };
160
203
  }
204
+ /**
205
+ * Get the problems of a verify run. Changed files and files that aren't in
206
+ * the checksums are reported on the file. Missing files have no file to
207
+ * report on.
208
+ *
209
+ * @param dir Checked directory.
210
+ * @param result Differences between the files and the manifest.
211
+ * @param result.changed Changed files.
212
+ * @param result.missing Missing files.
213
+ * @param result.extra Files that aren't in the checksums.
214
+ * @param result.versionMismatch Manifest and file versions when they differ.
215
+ * @return Problems.
216
+ */
217
+ function getVerifyProblems(dir, result) {
218
+ const { changed, missing, extra, versionMismatch } = result;
219
+ return [
220
+ ...changed.map((file) => ({
221
+ message: `Changed: ${file}`,
222
+ file: join(dir, file),
223
+ title: 'Changed file',
224
+ })),
225
+ ...missing.map((file) => ({
226
+ message: `Missing: ${file}`,
227
+ title: 'Missing file',
228
+ })),
229
+ ...extra.map((file) => ({
230
+ message: `Not in the checksums: ${file}`,
231
+ file: join(dir, file),
232
+ title: 'File not in the checksums',
233
+ })),
234
+ ...(versionMismatch
235
+ ? [
236
+ {
237
+ message: `The checksums are for version ${versionMismatch.manifest}, but the files are version ${versionMismatch.files}.`,
238
+ title: 'Version mismatch',
239
+ },
240
+ ]
241
+ : []),
242
+ ];
243
+ }
161
244
  /**
162
245
  * Compare the version in the manifest with the Version header of the
163
246
  * checked files, so that an older signed release can't pass as a newer one.
@@ -193,6 +276,7 @@ async function verifyTarget(input) {
193
276
  const { dir } = target;
194
277
  const copies = await checkEmbeddedCopies({
195
278
  dir,
279
+ manifest,
196
280
  checksumsPath,
197
281
  embedded,
198
282
  });
@@ -211,25 +295,32 @@ async function verifyTarget(input) {
211
295
  !copies.differing.length &&
212
296
  !copies.missing.length &&
213
297
  !versionMismatch;
214
- changed.forEach((file) => logger.error(`Changed: ${file}`));
215
- missing.forEach((file) => logger.error(`Missing: ${file}`));
216
- extra.forEach((file) => logger.error(`Not in the checksums: ${file}`));
217
- if (versionMismatch) {
218
- logger.error(`The checksums are for version ${versionMismatch.manifest}, but the files are version ${versionMismatch.files}.`);
219
- }
220
- logger.info(`${matched.length} files match, ${changed.length} changed, ${missing.length} missing and ${extra.length} extra.`);
221
- return {
222
- exitCode: valid ? 0 : 2,
223
- result: {
224
- valid,
298
+ const counts = `${matched.length} files match, ${changed.length} changed, ${missing.length} missing and ${extra.length} extra.`;
299
+ if (valid) {
300
+ logger.summary((context) => getVerifySummary(context, {
225
301
  checksumsPath,
226
302
  matched: matched.length,
303
+ }));
304
+ }
305
+ else {
306
+ logProblems(logger, 'wp-release verify', getVerifyProblems(dir, {
227
307
  changed,
228
308
  missing,
229
309
  extra,
230
310
  versionMismatch,
231
- },
311
+ }), counts);
312
+ }
313
+ logger.info(counts);
314
+ const result = {
315
+ valid,
316
+ checksumsPath,
317
+ matched: matched.length,
318
+ changed,
319
+ missing,
320
+ extra,
321
+ versionMismatch,
232
322
  };
323
+ return { exitCode: valid ? 0 : 2, result };
233
324
  }
234
325
  export default {
235
326
  name: 'verify',
@@ -248,11 +339,13 @@ Options:
248
339
  --root-folder <name> Folder in the ZIP (default: the one folder that
249
340
  every entry is in)
250
341
  --embedded <path> Where the manifest and its .sig are shipped inside the
251
- files, like includes/checksums.json (default: the
252
- name of the --checksums file at the root, when it
253
- exists). They're skipped when comparing files and
254
- must be identical to the --checksums file and its
255
- .sig, and with this option they must exist
342
+ files, like includes/checksums.json. Both the
343
+ --checksums file and its .sig must exist, both
344
+ copies must be shipped, and they must be identical
345
+ to them. Without this option, a file with the name
346
+ of the --checksums file at the root (and its .sig)
347
+ is a copy only when it exists, the matching original
348
+ exists and the manifest doesn't list it
256
349
  --public-key <key> Base64 Ed25519 public key. Checks <checksums>.sig, as
257
350
  written by "wp-release sign", before comparing any
258
351
  file, and fails when it's missing or invalid
@@ -270,7 +363,9 @@ ignore file inside the checked directory can't hide added files. Use
270
363
 
271
364
  A manifest can't list itself. A copy of the manifest and of its .sig inside
272
365
  the checked files is therefore skipped, but it must be identical to the
273
- --checksums file. See --embedded for where it's looked for.`,
366
+ --checksums file. A path that the manifest lists is never skipped, and a
367
+ file that isn't a matching copy is reported as extra. See --embedded for
368
+ where the copies are looked for.`,
274
369
  options: {
275
370
  checksums: { type: 'string' },
276
371
  zip: { type: 'string' },
@@ -2,7 +2,9 @@ import { basename, resolve } from 'node:path';
2
2
  import { isTagRef } from "../deploy-options.js";
3
3
  import { ValidationError } from "../errors.js";
4
4
  import { readPackageMeta } from "../package-meta.js";
5
- import { GIT_TAG_FORMAT, findVersionMismatches } from "../version-check.js";
5
+ import { GIT_TAG_FORMAT, findVersionMismatches, getVersionMismatchProblems, } from "../version-check.js";
6
+ import { logProblems } from "../report.js";
7
+ import { getVersionCheckSummary } from "../summary.js";
6
8
  import { checkDirectory, getSourceDirArg, sourceDirArgs } from "./args.js";
7
9
  import { getBoolean, getString, getStrings } from "./command.js";
8
10
  /**
@@ -68,9 +70,10 @@ no Stable tag, so it's only checked for plugins.`,
68
70
  constants: getStrings(values, 'constant'),
69
71
  stableTag: !getBoolean(values, 'no-stable-tag'),
70
72
  });
71
- mismatches.forEach((mismatch) => logger.error(mismatch.message));
73
+ logProblems(logger, 'wp-release version-check', getVersionMismatchProblems(sourceDir, mismatches));
72
74
  if (!mismatches.length) {
73
75
  logger.info(`All versions match ${version}.`);
76
+ logger.summary(() => getVersionCheckSummary(version));
74
77
  }
75
78
  return {
76
79
  exitCode: mismatches.length ? 2 : 0,
@@ -5,6 +5,7 @@ import { ValidationError } from "../errors.js";
5
5
  import { loadFileRules, resolveFileSelection, selectFiles, } from "../file-selection.js";
6
6
  import { FILENAME_FORMAT, formatFilename } from "../filename.js";
7
7
  import { readPackageMeta } from "../package-meta.js";
8
+ import { getZipSummary } from "../summary.js";
8
9
  import { createZip } from "../zip.js";
9
10
  import { checkDirectory, fileSelectionArgs, getFileSelectionInput, getSourceDirArg, ignoreOwnFile, ignoreOwnSignature, resolveOutput, sourceDirArgs, } from "./args.js";
10
11
  import { getBoolean, getString } from "./command.js";
@@ -126,13 +127,12 @@ always give the same ZIP.`,
126
127
  await writeChecksums(checksumsPath, manifest, force);
127
128
  logger.info(checksumsPath);
128
129
  }
129
- return {
130
- exitCode: 0,
131
- result: {
132
- zipPath,
133
- ...(checksumsPath ? { checksumsPath } : {}),
134
- files: files.length,
135
- },
130
+ const result = {
131
+ zipPath,
132
+ ...(checksumsPath ? { checksumsPath } : {}),
133
+ files: files.length,
136
134
  };
135
+ logger.summary((context) => getZipSummary(context, result));
136
+ return { exitCode: 0, result };
137
137
  },
138
138
  };