@wpelevator/wp-release 0.4.1 → 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.
@@ -0,0 +1,287 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { normalizeTag } from "./deploy-options.js";
3
+ /**
4
+ * SVN host of the WordPress.org plugin repositories.
5
+ */
6
+ export const WPORG_SVN_HOST = 'https://plugins.svn.wordpress.org/';
7
+ /**
8
+ * Number of files or SVN changes listed in a summary before the rest are
9
+ * counted.
10
+ */
11
+ export const SUMMARY_LIST_LIMIT = 100;
12
+ /**
13
+ * Get the path of a file relative to the workspace.
14
+ *
15
+ * @param workspace Workspace directory.
16
+ * @param file File path, absolute or relative to the working directory.
17
+ * @return Path with forward slashes, or null when it's outside the workspace.
18
+ */
19
+ export function getWorkspacePath(workspace, file) {
20
+ const path = relative(workspace, resolve(file));
21
+ const inWorkspace = '' !== path && !path.startsWith('..') && !isAbsolute(path);
22
+ return inWorkspace ? path.split(sep).join('/') : null;
23
+ }
24
+ /**
25
+ * Format text as inline code, with a fence that is longer than any backticks
26
+ * in the text.
27
+ *
28
+ * @param text Text.
29
+ * @return Markdown code span.
30
+ */
31
+ export function formatCode(text) {
32
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(([ticks]) => ticks.length));
33
+ const fence = '`'.repeat(longest + 1);
34
+ const padding = longest ? ' ' : '';
35
+ return `${fence}${padding}${text}${padding}${fence}`;
36
+ }
37
+ /**
38
+ * Format a Markdown table.
39
+ *
40
+ * @param header Column titles.
41
+ * @param rows Cells of each row, already formatted as Markdown.
42
+ * @return Markdown table.
43
+ */
44
+ export function formatTable(header, rows) {
45
+ const formatRow = (cells) => `| ${cells
46
+ .map((cell) => cell.replaceAll('|', '\\|').replace(/\r?\n/g, ' '))
47
+ .join(' | ')} |`;
48
+ return [
49
+ formatRow(header),
50
+ formatRow(header.map(() => '---')),
51
+ ...rows.map(formatRow),
52
+ ].join('\n');
53
+ }
54
+ /**
55
+ * Format a file path as code, relative to the workspace when it's inside it.
56
+ * Files aren't linked, since build directories and release files usually
57
+ * aren't in the repository.
58
+ *
59
+ * @param context Workspace directory.
60
+ * @param file File path.
61
+ * @param line Line in the file, if known.
62
+ * @return Markdown code.
63
+ */
64
+ export function formatFile(context, file, line) {
65
+ const path = getWorkspacePath(context.workspace, file) ?? file;
66
+ return formatCode(`${path}${line ? `:${line}` : ''}`);
67
+ }
68
+ /**
69
+ * Format a list, counting the items after the limit instead of listing them.
70
+ *
71
+ * @param items Items, already formatted as Markdown.
72
+ * @return Markdown list.
73
+ */
74
+ function formatList(items) {
75
+ const listed = items.slice(0, SUMMARY_LIST_LIMIT);
76
+ const more = items.length - listed.length;
77
+ return [
78
+ ...listed.map((item) => `- ${item}`),
79
+ ...(more ? [`- … and ${more} more`] : []),
80
+ ].join('\n');
81
+ }
82
+ /**
83
+ * Wrap content in a collapsed section.
84
+ *
85
+ * @param title Section title.
86
+ * @param content Markdown content.
87
+ * @return Markdown with a details element.
88
+ */
89
+ function formatDetails(title, content) {
90
+ return `<details><summary>${title}</summary>\n\n${content}\n\n</details>`;
91
+ }
92
+ /**
93
+ * Join summary blocks into one summary.
94
+ *
95
+ * @param blocks Markdown blocks, empty ones are left out.
96
+ * @return Markdown with a line break at the end.
97
+ */
98
+ function joinBlocks(blocks) {
99
+ return `${blocks.filter(Boolean).join('\n\n')}\n`;
100
+ }
101
+ /**
102
+ * Get the summary of a ZIP.
103
+ *
104
+ * @param context Workspace directory.
105
+ * @param result ZIP and manifest paths and the number of files.
106
+ * @return Markdown summary.
107
+ */
108
+ export function getZipSummary(context, result) {
109
+ return joinBlocks([
110
+ '### ZIP',
111
+ formatList([
112
+ `**ZIP:** ${formatFile(context, result.zipPath)}, ${result.files} files`,
113
+ ...(result.checksumsPath
114
+ ? [
115
+ `**Checksums:** ${formatFile(context, result.checksumsPath)}`,
116
+ ]
117
+ : []),
118
+ ]),
119
+ ]);
120
+ }
121
+ /**
122
+ * Get the summary of a checksums manifest.
123
+ *
124
+ * @param context Workspace directory.
125
+ * @param result Manifest path and the number of hashed files.
126
+ * @return Markdown summary.
127
+ */
128
+ export function getChecksumsSummary(context, result) {
129
+ return joinBlocks([
130
+ '### Checksums',
131
+ formatList([
132
+ `**Manifest:** ${formatFile(context, result.checksumsPath)}, ${result.files} files`,
133
+ ]),
134
+ ]);
135
+ }
136
+ /**
137
+ * Get the summary of signed files.
138
+ *
139
+ * @param context Workspace directory.
140
+ * @param result Signature paths and the public key.
141
+ * @return Markdown summary.
142
+ */
143
+ export function getSignSummary(context, result) {
144
+ return joinBlocks([
145
+ '### Signatures',
146
+ formatList(result.signatures.map((path) => formatFile(context, path))),
147
+ `Public key: ${formatCode(result.publicKey)}`,
148
+ ]);
149
+ }
150
+ /**
151
+ * Get the summary of a verify run where every file matches. Failed runs are
152
+ * summarized with their problems instead.
153
+ *
154
+ * @param context Workspace directory.
155
+ * @param result Verify result.
156
+ * @param result.checksumsPath Checksums manifest path.
157
+ * @param result.matched Number of matching files.
158
+ * @return Markdown summary.
159
+ */
160
+ export function getVerifySummary(context, result) {
161
+ return joinBlocks([
162
+ '### Verify',
163
+ `All ${result.matched} files match ${formatFile(context, result.checksumsPath)}.`,
164
+ ]);
165
+ }
166
+ /**
167
+ * Get the summary of a version check without mismatches.
168
+ *
169
+ * @param version Version that all sources match.
170
+ * @return Markdown summary.
171
+ */
172
+ export function getVersionCheckSummary(version) {
173
+ return joinBlocks([
174
+ '### Version check',
175
+ `All versions match ${formatCode(version ?? '')}.`,
176
+ ]);
177
+ }
178
+ /**
179
+ * Get the summary of failed checks, with the file and line of each problem.
180
+ * Problems after the list limit are counted instead of listed.
181
+ *
182
+ * @param context Workspace directory.
183
+ * @param title What failed, like the command name.
184
+ * @param problems Problems.
185
+ * @param description Text above the problems, if any.
186
+ * @return Markdown summary.
187
+ */
188
+ export function getProblemsSummary(context, title, problems, description = '') {
189
+ const more = problems.length - SUMMARY_LIST_LIMIT;
190
+ return joinBlocks([
191
+ `### ${title} failed`,
192
+ description,
193
+ formatTable(['Problem', 'Location'], problems
194
+ .slice(0, SUMMARY_LIST_LIMIT)
195
+ .map(({ message, file, line }) => [
196
+ message,
197
+ file ? formatFile(context, file, line) : '',
198
+ ])),
199
+ more > 0 && `… and ${more} more`,
200
+ ]);
201
+ }
202
+ /**
203
+ * Get the summary of a failed run.
204
+ *
205
+ * @param title What failed, like the command name.
206
+ * @param message Error message.
207
+ * @return Markdown summary.
208
+ */
209
+ export function getErrorSummary(title, message) {
210
+ return joinBlocks([
211
+ `### ${title} failed`,
212
+ message
213
+ .split(/\r?\n/)
214
+ .map((line) => `> ${line}`)
215
+ .join('\n'),
216
+ ]);
217
+ }
218
+ /**
219
+ * Get the paths that a deploy writes to.
220
+ *
221
+ * @param options Resolved deploy options.
222
+ * @return SVN paths.
223
+ */
224
+ function getDeployWrites(options) {
225
+ const { trunk, readmeOnly, svnTag, assets, assetsDir, readme } = options;
226
+ return [
227
+ trunk && 'trunk/',
228
+ readmeOnly && `trunk/${readme}`,
229
+ svnTag && `tags/${normalizeTag(svnTag)}/`,
230
+ assets && assetsDir && 'assets/',
231
+ ].filter((path) => Boolean(path));
232
+ }
233
+ /**
234
+ * Describe the result of a deploy commit.
235
+ *
236
+ * @param input Deploy options and result.
237
+ * @return Markdown text.
238
+ */
239
+ function describeDeployResult(input) {
240
+ const { options, committed, revision, changes } = input;
241
+ const isWporg = options.svnUrl.startsWith(WPORG_SVN_HOST);
242
+ const revisionLabel = `r${revision}`;
243
+ const revisionLink = isWporg
244
+ ? `[${revisionLabel}](https://plugins.trac.wordpress.org/changeset/${revision})`
245
+ : revisionLabel;
246
+ const uncommitted = changes.length
247
+ ? `Dry run, ${changes.length} changes were not committed.`
248
+ : 'No changes to commit.';
249
+ return committed
250
+ ? `Committed ${revisionLink} with ${changes.length} changes.`
251
+ : uncommitted;
252
+ }
253
+ /**
254
+ * Get the summary of a deploy, with the written paths, the commit and the
255
+ * SVN changes.
256
+ *
257
+ * @param input Deploy options and result.
258
+ * @return Markdown summary.
259
+ */
260
+ export function getDeploySummary(input) {
261
+ const { options, version, changes } = input;
262
+ const isWporg = options.svnUrl.startsWith(WPORG_SVN_HOST);
263
+ const plugin = isWporg
264
+ ? `[${options.slug}](https://wordpress.org/plugins/${options.slug}/)`
265
+ : formatCode(options.slug);
266
+ return joinBlocks([
267
+ isWporg ? '### Deploy to WordPress.org' : '### Deploy to SVN',
268
+ formatList([
269
+ `**Plugin:** ${plugin}`,
270
+ ...(version ? [`**Version:** ${formatCode(version)}`] : []),
271
+ `**Writes:** ${getDeployWrites(options)
272
+ .map(formatCode)
273
+ .join(', ')}`,
274
+ `**Result:** ${describeDeployResult(input)}`,
275
+ ]),
276
+ changes.length > 0 &&
277
+ formatDetails(`SVN changes (${changes.length})`, formatTable(['Status', 'Path'], changes
278
+ .slice(0, SUMMARY_LIST_LIMIT)
279
+ .map(({ status, path }) => [
280
+ status,
281
+ formatCode(path),
282
+ ])) +
283
+ (changes.length > SUMMARY_LIST_LIMIT
284
+ ? `\n\n… and ${changes.length - SUMMARY_LIST_LIMIT} more`
285
+ : '')),
286
+ ]);
287
+ }
@@ -78,7 +78,8 @@ export declare class SvnDeploy {
78
78
  * pre-release tag doesn't change the stable tag. Readme-only updates need
79
79
  * the readme.
80
80
  *
81
- * @throws {ValidationError} When the files or versions don't match.
81
+ * @throws {ValidationError} When the files don't match.
82
+ * @throws {VersionMismatchError} When the versions don't match.
82
83
  */
83
84
  checkVersions(): Promise<void>;
84
85
  /**
@@ -10,8 +10,9 @@ import { loadFileRules, selectFiles } from "./file-selection.js";
10
10
  import { readPackageMeta } from "./package-meta.js";
11
11
  import { Svn } from "./svn.js";
12
12
  import { copyFileIfChanged, syncDirectory } from "./sync.js";
13
+ import { getDeploySummary } from "./summary.js";
13
14
  import { extractZip } from "./unzip.js";
14
- import { findVersionMismatches } from "./version-check.js";
15
+ import { VersionMismatchError, findVersionMismatches, } from "./version-check.js";
15
16
  /**
16
17
  * MIME types of the plugin directory asset formats. svn guesses binary
17
18
  * types, but WordPress.org serves assets with the svn:mime-type property,
@@ -102,7 +103,8 @@ export class SvnDeploy {
102
103
  * pre-release tag doesn't change the stable tag. Readme-only updates need
103
104
  * the readme.
104
105
  *
105
- * @throws {ValidationError} When the files or versions don't match.
106
+ * @throws {ValidationError} When the files don't match.
107
+ * @throws {VersionMismatchError} When the versions don't match.
106
108
  */
107
109
  async checkVersions() {
108
110
  const { trunk, readmeOnly, svnTag, slug, readme } = this.options;
@@ -122,9 +124,7 @@ export class SvnDeploy {
122
124
  });
123
125
  this.version = version;
124
126
  if (mismatches.length) {
125
- throw new ValidationError(mismatches
126
- .map(({ message }) => message)
127
- .join('\n'));
127
+ throw new VersionMismatchError(sourceDir, mismatches);
128
128
  }
129
129
  this.logger.info(`Deploying version ${version}.`);
130
130
  }
@@ -379,7 +379,9 @@ export class SvnDeploy {
379
379
  await this.checkVersions();
380
380
  await this.checkout();
381
381
  await this.sync();
382
- return await this.commit();
382
+ const result = await this.commit();
383
+ this.logger.summary(() => getDeploySummary({ options: this.options, ...result }));
384
+ return result;
383
385
  }
384
386
  finally {
385
387
  await this.cleanup();
@@ -1,3 +1,5 @@
1
+ import { ValidationError } from './errors.ts';
2
+ import type { Problem } from './errors.ts';
1
3
  import type { PackageMeta } from './package-meta.ts';
2
4
  export declare const GIT_TAG_FORMAT = "{version}";
3
5
  /**
@@ -6,8 +8,10 @@ export declare const GIT_TAG_FORMAT = "{version}";
6
8
  export interface VersionMismatch {
7
9
  /** Where the version comes from, like "Stable tag" or "Git tag". */
8
10
  source: string;
9
- /** File that holds the version, if any. */
11
+ /** File that holds the version, relative to the source directory, if any. */
10
12
  file: string | null;
13
+ /** Line of the version in the file, starting at 1, if known. */
14
+ line: number | null;
11
15
  /** Version found, or null when it's missing. */
12
16
  actual: string | null;
13
17
  /** Human-readable description. */
@@ -45,6 +49,28 @@ export interface VersionCheckResult {
45
49
  /** Every mismatch, empty when all versions agree. */
46
50
  mismatches: VersionMismatch[];
47
51
  }
52
+ /**
53
+ * Get the problems of version mismatches, with their files relative to the
54
+ * working directory instead of the plugin directory.
55
+ *
56
+ * @param sourceDir Directory that the mismatch files are relative to.
57
+ * @param mismatches Mismatches from findVersionMismatches().
58
+ * @return Problems.
59
+ */
60
+ export declare function getVersionMismatchProblems(sourceDir: string, mismatches: VersionMismatch[]): Problem[];
61
+ /**
62
+ * Versions that don't agree, thrown by a deploy before anything is written.
63
+ * The message lists every mismatch, and the problems have their files and
64
+ * lines.
65
+ */
66
+ export declare class VersionMismatchError extends ValidationError {
67
+ name: string;
68
+ /** Directory that the mismatch files are relative to. */
69
+ readonly sourceDir: string;
70
+ /** Every mismatch. */
71
+ readonly mismatches: VersionMismatch[];
72
+ constructor(sourceDir: string, mismatches: VersionMismatch[]);
73
+ }
48
74
  /**
49
75
  * Get the version from a git tag with a format like v{version} or
50
76
  * {slug}-{version}. A leading "v" is stripped from the version, like for
@@ -64,6 +90,18 @@ export declare function getGitTagVersion(input: GitTagInput): string | null;
64
90
  * @return Values in the order they appear.
65
91
  */
66
92
  export declare function findConstantValues(contents: string, name: string): string[];
93
+ /**
94
+ * Find the definitions of a PHP constant set with define( 'NAME', '1.2.0' )
95
+ * or const NAME = '1.2.0'.
96
+ *
97
+ * @param contents PHP code.
98
+ * @param name Constant name.
99
+ * @return Values and their line numbers, in the order they appear.
100
+ */
101
+ export declare function findConstantDefinitions(contents: string, name: string): Array<{
102
+ value: string;
103
+ line: number;
104
+ }>;
67
105
  /**
68
106
  * Check that the Version header, readme Stable tag, PHP constants and git tag
69
107
  * of a plugin or theme agree, and report every one that doesn't. Themes have
@@ -4,6 +4,7 @@ import ignore from 'ignore';
4
4
  import { normalizeTag } from "./deploy-options.js";
5
5
  import { ValidationError } from "./errors.js";
6
6
  import { selectFiles } from "./file-selection.js";
7
+ import { findFileHeaderLine, findReadmeHeaderLine, getLineNumber, readFileStart, } from "./package-meta.js";
7
8
  export const GIT_TAG_FORMAT = '{version}';
8
9
  const TAG_REF_PREFIX = 'refs/tags/';
9
10
  /**
@@ -11,6 +12,39 @@ const TAG_REF_PREFIX = 'refs/tags/';
11
12
  * dependencies with their own versions.
12
13
  */
13
14
  const CONSTANT_SEARCH_IGNORE = ['node_modules/', 'vendor/', '.*'];
15
+ /**
16
+ * Get the problems of version mismatches, with their files relative to the
17
+ * working directory instead of the plugin directory.
18
+ *
19
+ * @param sourceDir Directory that the mismatch files are relative to.
20
+ * @param mismatches Mismatches from findVersionMismatches().
21
+ * @return Problems.
22
+ */
23
+ export function getVersionMismatchProblems(sourceDir, mismatches) {
24
+ return mismatches.map(({ message, file, line }) => ({
25
+ message,
26
+ file: file ? join(sourceDir, file) : null,
27
+ line,
28
+ title: 'Version mismatch',
29
+ }));
30
+ }
31
+ /**
32
+ * Versions that don't agree, thrown by a deploy before anything is written.
33
+ * The message lists every mismatch, and the problems have their files and
34
+ * lines.
35
+ */
36
+ export class VersionMismatchError extends ValidationError {
37
+ name = 'VersionMismatchError';
38
+ /** Directory that the mismatch files are relative to. */
39
+ sourceDir;
40
+ /** Every mismatch. */
41
+ mismatches;
42
+ constructor(sourceDir, mismatches) {
43
+ super(mismatches.map(({ message }) => message).join('\n'), getVersionMismatchProblems(sourceDir, mismatches));
44
+ this.sourceDir = sourceDir;
45
+ this.mismatches = mismatches;
46
+ }
47
+ }
14
48
  /**
15
49
  * Get the version from a git tag with a format like v{version} or
16
50
  * {slug}-{version}. A leading "v" is stripped from the version, like for
@@ -44,6 +78,17 @@ function escapeRegExp(text) {
44
78
  * @return Values in the order they appear.
45
79
  */
46
80
  export function findConstantValues(contents, name) {
81
+ return findConstantDefinitions(contents, name).map(({ value }) => value);
82
+ }
83
+ /**
84
+ * Find the definitions of a PHP constant set with define( 'NAME', '1.2.0' )
85
+ * or const NAME = '1.2.0'.
86
+ *
87
+ * @param contents PHP code.
88
+ * @param name Constant name.
89
+ * @return Values and their line numbers, in the order they appear.
90
+ */
91
+ export function findConstantDefinitions(contents, name) {
47
92
  const quotedName = `(['"])${escapeRegExp(name)}\\1`;
48
93
  const patterns = [
49
94
  new RegExp(`\\bdefine\\s*\\(\\s*${quotedName}\\s*,\\s*(['"])(.*?)\\2\\s*[,)]`, 'g'),
@@ -52,7 +97,10 @@ export function findConstantValues(contents, name) {
52
97
  return patterns
53
98
  .flatMap((pattern) => [...contents.matchAll(pattern)])
54
99
  .sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
55
- .map((match) => match[3] ?? '');
100
+ .map((match) => ({
101
+ value: match[3] ?? '',
102
+ line: getLineNumber(contents, match.index ?? 0),
103
+ }));
56
104
  }
57
105
  /**
58
106
  * Compare a version with the expected one.
@@ -100,16 +148,17 @@ async function compareConstants(sourceDir, constants, expected) {
100
148
  })));
101
149
  return constants.flatMap((name) => {
102
150
  const source = `Constant ${name}`;
103
- const definitions = sources.flatMap(({ file, contents }) => findConstantValues(contents, name).map((actual) => ({
151
+ const definitions = sources.flatMap(({ file, contents }) => findConstantDefinitions(contents, name).map(({ value, line }) => ({
104
152
  source,
105
153
  file,
106
- actual,
154
+ line,
155
+ actual: value,
107
156
  })));
108
157
  if (definitions.length) {
109
158
  return definitions.map((definition) => compareVersion(definition, expected));
110
159
  }
111
160
  return [
112
- compareVersion({ source, file: null, actual: null }, expected),
161
+ compareVersion({ source, file: null, line: null, actual: null }, expected),
113
162
  ];
114
163
  });
115
164
  }
@@ -127,11 +176,12 @@ function compareGitTag(input, expected) {
127
176
  return {
128
177
  source: 'Git tag',
129
178
  file: null,
179
+ line: null,
130
180
  actual: tag,
131
181
  message: `Git tag ${tag} doesn't match the format ${input.gitTagFormat}.`,
132
182
  };
133
183
  }
134
- return compareVersion({ source: `Git tag ${tag}`, file: null, actual }, expected);
184
+ return compareVersion({ source: `Git tag ${tag}`, file: null, line: null, actual }, expected);
135
185
  }
136
186
  /**
137
187
  * Check that the Version header, readme Stable tag, PHP constants and git tag
@@ -152,6 +202,7 @@ export async function findVersionMismatches(input) {
152
202
  {
153
203
  source: 'Version header',
154
204
  file: meta.mainFile,
205
+ line: null,
155
206
  actual: null,
156
207
  message: `Version header is missing in ${meta.mainFile}.`,
157
208
  },
@@ -164,12 +215,14 @@ export async function findVersionMismatches(input) {
164
215
  compareVersion({
165
216
  source: 'Version header',
166
217
  file: meta.mainFile,
218
+ line: findFileHeaderLine((await readFileStart(join(sourceDir, meta.mainFile))) ?? '', 'Version'),
167
219
  actual: meta.version,
168
220
  }, expected),
169
221
  checkStableTag && !readme
170
222
  ? {
171
223
  source: 'Stable tag',
172
224
  file: null,
225
+ line: null,
173
226
  actual: null,
174
227
  message: `Readme is missing, expected Stable tag ${expected}.`,
175
228
  }
@@ -178,6 +231,7 @@ export async function findVersionMismatches(input) {
178
231
  ? compareVersion({
179
232
  source: 'Stable tag',
180
233
  file: readme.file,
234
+ line: findReadmeHeaderLine(await readFile(join(sourceDir, readme.file), 'utf8'), 'Stable tag'),
181
235
  actual: readme.headers['Stable tag'] ?? null,
182
236
  }, expected)
183
237
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.4.1",
3
+ "version": "0.5.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": {