@wpelevator/wp-release 0.2.0 → 0.4.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,140 @@
1
+ import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { readFile, writeFile } from 'node:fs/promises';
4
+ import { ValidationError } from "./errors.js";
5
+ export const SIGNATURE_EXTENSION = '.sig';
6
+ /**
7
+ * Environment variable with the base64 Ed25519 signing key.
8
+ */
9
+ export const SIGNING_KEY_ENV = 'WP_RELEASE_SIGNING_KEY';
10
+ const SEED_BYTES = 32;
11
+ const SECRET_KEY_BYTES = 64;
12
+ const PUBLIC_KEY_BYTES = 32;
13
+ // DER prefix of a PKCS#8 Ed25519 private key, followed by the 32-byte seed.
14
+ const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
15
+ // DER prefix of a SPKI Ed25519 public key, followed by the 32-byte key.
16
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
17
+ /**
18
+ * Decode a base64 key, accepting the base64url alphabet and whitespace.
19
+ *
20
+ * @param value Encoded key.
21
+ * @return Key bytes.
22
+ */
23
+ function decodeKey(value) {
24
+ return Buffer.from(value.trim(), 'base64');
25
+ }
26
+ /**
27
+ * Get an Ed25519 private key from the base64 format that sodium and the
28
+ * update-client signing_key setting use: a 32-byte seed or a 64-byte
29
+ * libsodium secret key, which is the seed followed by the public key.
30
+ *
31
+ * @param encoded Base64 key.
32
+ * @return Private key.
33
+ * @throws {ValidationError} If the key has an unsupported length.
34
+ */
35
+ export function parseSigningKey(encoded) {
36
+ const bytes = decodeKey(encoded);
37
+ if (SEED_BYTES !== bytes.length && SECRET_KEY_BYTES !== bytes.length) {
38
+ throw new ValidationError(`The signing key must be a base64 ${SEED_BYTES}-byte Ed25519 seed or ${SECRET_KEY_BYTES}-byte secret key, got ${bytes.length} bytes.`);
39
+ }
40
+ return createPrivateKey({
41
+ key: Buffer.concat([PKCS8_PREFIX, bytes.subarray(0, SEED_BYTES)]),
42
+ format: 'der',
43
+ type: 'pkcs8',
44
+ });
45
+ }
46
+ /**
47
+ * Get an Ed25519 public key from its base64 form.
48
+ *
49
+ * @param encoded Base64 32-byte public key.
50
+ * @return Public key.
51
+ * @throws {ValidationError} If the key has an unsupported length.
52
+ */
53
+ export function parsePublicKey(encoded) {
54
+ const bytes = decodeKey(encoded);
55
+ if (PUBLIC_KEY_BYTES !== bytes.length) {
56
+ throw new ValidationError(`The public key must be a base64 ${PUBLIC_KEY_BYTES}-byte Ed25519 key, got ${bytes.length} bytes.`);
57
+ }
58
+ return createPublicKey({
59
+ key: Buffer.concat([SPKI_PREFIX, bytes]),
60
+ format: 'der',
61
+ type: 'spki',
62
+ });
63
+ }
64
+ /**
65
+ * Get the base64 public key for a private key, in the format that
66
+ * verify_file_signature() callers and update-client take.
67
+ *
68
+ * @param privateKey Private key from parseSigningKey().
69
+ * @return Base64 public key.
70
+ */
71
+ export function getPublicKey(privateKey) {
72
+ const der = createPublicKey(privateKey).export({
73
+ format: 'der',
74
+ type: 'spki',
75
+ });
76
+ return der.subarray(SPKI_PREFIX.length).toString('base64');
77
+ }
78
+ /**
79
+ * Get the raw SHA-384 digest of a file, which is what WordPress core's
80
+ * verify_file_signature() verifies the signature against.
81
+ *
82
+ * @param path File path.
83
+ * @return Digest bytes.
84
+ */
85
+ export async function getFileDigest(path) {
86
+ const hash = createHash('sha384');
87
+ for await (const chunk of createReadStream(path)) {
88
+ hash.update(chunk);
89
+ }
90
+ return hash.digest();
91
+ }
92
+ /**
93
+ * Sign a file with a detached Ed25519 signature of its raw SHA-384 digest.
94
+ *
95
+ * @param path File path.
96
+ * @param privateKey Private key from parseSigningKey().
97
+ * @return Base64 signature.
98
+ */
99
+ export async function signFile(path, privateKey) {
100
+ return sign(null, await getFileDigest(path), privateKey).toString('base64');
101
+ }
102
+ /**
103
+ * Write the signature of a file to a .sig file next to it.
104
+ *
105
+ * @param path File path.
106
+ * @param privateKey Private key from parseSigningKey().
107
+ * @param force Whether to overwrite an existing signature file.
108
+ * @return Signature file path.
109
+ * @throws {ValidationError} If the signature file exists and force isn't set.
110
+ */
111
+ export async function writeSignature(path, privateKey, force = false) {
112
+ const signaturePath = path + SIGNATURE_EXTENSION;
113
+ const signature = await signFile(path, privateKey);
114
+ await writeFile(signaturePath, signature + '\n', {
115
+ flag: force ? 'w' : 'wx',
116
+ }).catch((error) => {
117
+ throw 'EEXIST' === error.code
118
+ ? new ValidationError(`${signaturePath} already exists, pass --force to overwrite it.`)
119
+ : error;
120
+ });
121
+ return signaturePath;
122
+ }
123
+ /**
124
+ * Check a file against a .sig file, which holds one base64 signature per
125
+ * line. The file is valid when any of the signatures match, so that a file
126
+ * can carry the signatures of a key rotation window.
127
+ *
128
+ * @param path File path.
129
+ * @param signaturePath Signature file path.
130
+ * @param publicKey Public key from parsePublicKey().
131
+ * @return Whether a signature matches.
132
+ */
133
+ export async function verifyFileSignature(path, signaturePath, publicKey) {
134
+ const digest = await getFileDigest(path);
135
+ const signatures = (await readFile(signaturePath, 'utf8'))
136
+ .split('\n')
137
+ .map((line) => line.trim())
138
+ .filter(Boolean);
139
+ return signatures.some((signature) => verify(null, digest, publicKey, Buffer.from(signature, 'base64')));
140
+ }
@@ -71,9 +71,12 @@ export declare class SvnDeploy {
71
71
  */
72
72
  prepare(): Promise<void>;
73
73
  /**
74
- * Check the plugin files before anything is written. A trunk sync needs
75
- * a plugin with a Version header that matches the version, and with an
76
- * SVN tag also a readme Stable tag that matches.
74
+ * Check the plugin files before anything is written. A trunk sync or a
75
+ * tag built from the files needs a plugin with a Version header that
76
+ * matches the version. When trunk is also copied to the tag, the readme
77
+ * Stable tag must match too, which a tag-only run skips since a
78
+ * pre-release tag doesn't change the stable tag. Readme-only updates need
79
+ * the readme.
77
80
  *
78
81
  * @throws {ValidationError} When the files or versions don't match.
79
82
  */
@@ -88,7 +91,9 @@ export declare class SvnDeploy {
88
91
  readPluginMeta(sourceDir: string): Promise<PackageMeta>;
89
92
  /**
90
93
  * Create a sparse checkout with only the directories the flags write to,
91
- * creating the ones that don't exist in the repository yet.
94
+ * creating the ones that don't exist in the repository yet. An existing
95
+ * tag that's replaced from the files is checked out in full, so that the
96
+ * sync can update and delete its files.
92
97
  *
93
98
  * @throws {ValidationError} If the SVN tag exists and force isn't set.
94
99
  */
@@ -109,6 +114,7 @@ export declare class SvnDeploy {
109
114
  /**
110
115
  * Copy the files for each write flag into the working copy and stage
111
116
  * additions, deletions and asset MIME types, then copy trunk to the tag.
117
+ * Without a trunk sync, the tag is built from the files instead.
112
118
  */
113
119
  sync(): Promise<void>;
114
120
  /**
@@ -133,9 +139,8 @@ export declare class SvnDeploy {
133
139
  */
134
140
  setAssetMimeTypes(): Promise<void>;
135
141
  /**
136
- * Copy trunk to the SVN tag, replacing an existing tag with force. Trunk
137
- * is copied from the working copy when this run writes to it, and from the
138
- * repository otherwise, so that tagging doesn't need a trunk checkout.
142
+ * Copy the synced trunk to the SVN tag, replacing an existing tag with
143
+ * force. Without a trunk sync, the tag was already built from the files.
139
144
  */
140
145
  copyTag(): Promise<void>;
141
146
  /**
@@ -184,7 +189,7 @@ export declare class SvnDeploy {
184
189
  /**
185
190
  * Check if the deploy reads plugin files, which assets-only runs don't.
186
191
  *
187
- * @return Whether trunk is written.
192
+ * @return Whether trunk, the readme or a tag is written.
188
193
  */
189
194
  needsFiles(): boolean;
190
195
  /**
@@ -199,12 +204,6 @@ export declare class SvnDeploy {
199
204
  * @return Rules description, empty without rules.
200
205
  */
201
206
  describeRules(): string;
202
- /**
203
- * Get the repository URL of trunk.
204
- *
205
- * @return Trunk URL without a double slash.
206
- */
207
- getTrunkUrl(): string;
208
207
  /**
209
208
  * Get the path of the tag in the working copy.
210
209
  *
@@ -9,7 +9,7 @@ import { CredentialsError, ValidationError } from "./errors.js";
9
9
  import { loadFileRules, selectFiles } from "./file-selection.js";
10
10
  import { readPackageMeta } from "./package-meta.js";
11
11
  import { Svn } from "./svn.js";
12
- import { syncDirectory } from "./sync.js";
12
+ import { copyFileIfChanged, syncDirectory } from "./sync.js";
13
13
  import { extractZip } from "./unzip.js";
14
14
  import { findVersionMismatches } from "./version-check.js";
15
15
  /**
@@ -95,29 +95,39 @@ export class SvnDeploy {
95
95
  }
96
96
  }
97
97
  /**
98
- * Check the plugin files before anything is written. A trunk sync needs
99
- * a plugin with a Version header that matches the version, and with an
100
- * SVN tag also a readme Stable tag that matches.
98
+ * Check the plugin files before anything is written. A trunk sync or a
99
+ * tag built from the files needs a plugin with a Version header that
100
+ * matches the version. When trunk is also copied to the tag, the readme
101
+ * Stable tag must match too, which a tag-only run skips since a
102
+ * pre-release tag doesn't change the stable tag. Readme-only updates need
103
+ * the readme.
101
104
  *
102
105
  * @throws {ValidationError} When the files or versions don't match.
103
106
  */
104
107
  async checkVersions() {
105
- const { trunk, svnTag, slug } = this.options;
108
+ const { trunk, readmeOnly, svnTag, slug, readme } = this.options;
106
109
  const sourceDir = this.filesDir ?? this.options.sourceDir;
107
- if (trunk) {
110
+ if (this.needsFiles()) {
108
111
  const meta = await this.readPluginMeta(sourceDir);
109
- const { version, mismatches } = await findVersionMismatches({
110
- sourceDir,
111
- meta,
112
- slug,
113
- version: this.options.version,
114
- stableTag: Boolean(svnTag),
115
- });
116
- this.version = version;
117
- if (mismatches.length) {
118
- throw new ValidationError(mismatches.map(({ message }) => message).join('\n'));
112
+ if (readmeOnly && !meta.readme) {
113
+ throw new ValidationError(`${readme} doesn't exist in ${sourceDir}.`);
114
+ }
115
+ if (trunk || svnTag) {
116
+ const { version, mismatches } = await findVersionMismatches({
117
+ sourceDir,
118
+ meta,
119
+ slug,
120
+ version: this.options.version,
121
+ stableTag: Boolean(trunk && svnTag),
122
+ });
123
+ this.version = version;
124
+ if (mismatches.length) {
125
+ throw new ValidationError(mismatches
126
+ .map(({ message }) => message)
127
+ .join('\n'));
128
+ }
129
+ this.logger.info(`Deploying version ${version}.`);
119
130
  }
120
- this.logger.info(`Deploying version ${version}.`);
121
131
  }
122
132
  }
123
133
  /**
@@ -140,7 +150,9 @@ export class SvnDeploy {
140
150
  }
141
151
  /**
142
152
  * Create a sparse checkout with only the directories the flags write to,
143
- * creating the ones that don't exist in the repository yet.
153
+ * creating the ones that don't exist in the repository yet. An existing
154
+ * tag that's replaced from the files is checked out in full, so that the
155
+ * sync can update and delete its files.
144
156
  *
145
157
  * @throws {ValidationError} If the SVN tag exists and force isn't set.
146
158
  */
@@ -159,6 +171,12 @@ export class SvnDeploy {
159
171
  if (tagPath && !this.options.force && this.hasTag()) {
160
172
  throw new ValidationError(`${tagPath} already exists in ${this.options.svnUrl}. Pass --force to replace it.`);
161
173
  }
174
+ if (tagPath && !this.options.trunk && this.hasTag()) {
175
+ await this.svn.update({
176
+ path: join(workingCopy, tagPath),
177
+ depth: 'infinity',
178
+ });
179
+ }
162
180
  }
163
181
  /**
164
182
  * Create and add a top-level directory that's missing from the repository,
@@ -185,9 +203,11 @@ export class SvnDeploy {
185
203
  /**
186
204
  * Copy the files for each write flag into the working copy and stage
187
205
  * additions, deletions and asset MIME types, then copy trunk to the tag.
206
+ * Without a trunk sync, the tag is built from the files instead.
188
207
  */
189
208
  async sync() {
190
- const { trunk, assets } = this.options;
209
+ const { trunk, readmeOnly, assets, readme } = this.options;
210
+ const tagPath = this.getTagPath();
191
211
  const workingCopy = this.getWorkingCopy();
192
212
  const sourceDir = this.filesDir ?? this.options.sourceDir;
193
213
  if (trunk) {
@@ -197,6 +217,16 @@ export class SvnDeploy {
197
217
  targetDir: join(workingCopy, 'trunk'),
198
218
  });
199
219
  }
220
+ if (tagPath && !trunk) {
221
+ await syncDirectory({
222
+ sourceDir,
223
+ files: await this.getSourceFiles(sourceDir),
224
+ targetDir: join(workingCopy, tagPath),
225
+ });
226
+ }
227
+ if (readmeOnly) {
228
+ await copyFileIfChanged(join(sourceDir, readme), join(workingCopy, 'trunk', readme));
229
+ }
200
230
  if (assets) {
201
231
  await this.syncAssets();
202
232
  }
@@ -266,20 +296,22 @@ export class SvnDeploy {
266
296
  }
267
297
  }
268
298
  /**
269
- * Copy trunk to the SVN tag, replacing an existing tag with force. Trunk
270
- * is copied from the working copy when this run writes to it, and from the
271
- * repository otherwise, so that tagging doesn't need a trunk checkout.
299
+ * Copy the synced trunk to the SVN tag, replacing an existing tag with
300
+ * force. Without a trunk sync, the tag was already built from the files.
272
301
  */
273
302
  async copyTag() {
274
303
  const tagPath = this.getTagPath();
275
304
  const workingCopy = this.getWorkingCopy();
276
- if (tagPath && this.hasTag()) {
277
- this.logger.warning(`Replacing the existing ${tagPath}.`);
278
- await this.svn.remove({ paths: [tagPath], cwd: workingCopy });
279
- }
280
- if (tagPath) {
305
+ if (tagPath && this.options.trunk) {
306
+ if (this.hasTag()) {
307
+ this.logger.warning(`Replacing the existing ${tagPath}.`);
308
+ await this.svn.remove({
309
+ paths: [tagPath],
310
+ cwd: workingCopy,
311
+ });
312
+ }
281
313
  await this.svn.copy({
282
- from: this.needsFiles() ? 'trunk' : this.getTrunkUrl(),
314
+ from: 'trunk',
283
315
  to: tagPath,
284
316
  cwd: workingCopy,
285
317
  });
@@ -367,7 +399,7 @@ export class SvnDeploy {
367
399
  * @return Planned changes, in order.
368
400
  */
369
401
  getPlan() {
370
- const { svnUrl, trunk, assets, assetsDir, force } = this.options;
402
+ const { svnUrl, trunk, readmeOnly, assets, assetsDir, readme, force } = this.options;
371
403
  const tagPath = this.getTagPath();
372
404
  const message = this.options.message ?? this.getDefaultMessage();
373
405
  const plan = [
@@ -377,7 +409,11 @@ export class SvnDeploy {
377
409
  tagPath && !force && `Stop if ${tagPath} already exists`,
378
410
  tagPath && force && `Replace ${tagPath} if it exists`,
379
411
  trunk && `Sync ${this.describeSource()} to trunk/`,
380
- tagPath && `Copy trunk/ to ${tagPath}`,
412
+ readmeOnly && `Copy ${readme} to trunk/`,
413
+ tagPath && trunk && `Copy trunk/ to ${tagPath}`,
414
+ tagPath &&
415
+ !trunk &&
416
+ `Sync ${this.describeSource()} to ${tagPath}`,
381
417
  assets && `Sync ${assetsDir} to assets/`,
382
418
  this.options.dryRun
383
419
  ? `Skip the commit "${message}" (dry run)`
@@ -392,9 +428,9 @@ export class SvnDeploy {
392
428
  * @return Relative paths with their SVN checkout depth.
393
429
  */
394
430
  getCheckoutPaths() {
395
- const { trunk, svnTag, assets } = this.options;
431
+ const { trunk, readmeOnly, svnTag, assets } = this.options;
396
432
  const paths = [];
397
- if (trunk) {
433
+ if (trunk || readmeOnly) {
398
434
  paths.push({ path: 'trunk', depth: 'infinity' });
399
435
  }
400
436
  if (svnTag) {
@@ -411,16 +447,17 @@ export class SvnDeploy {
411
447
  * @return Whether to check out and commit.
412
448
  */
413
449
  hasWrites() {
414
- const { trunk, svnTag, assets } = this.options;
415
- return Boolean(trunk || svnTag || assets);
450
+ const { trunk, readmeOnly, svnTag, assets } = this.options;
451
+ return Boolean(trunk || readmeOnly || svnTag || assets);
416
452
  }
417
453
  /**
418
454
  * Check if the deploy reads plugin files, which assets-only runs don't.
419
455
  *
420
- * @return Whether trunk is written.
456
+ * @return Whether trunk, the readme or a tag is written.
421
457
  */
422
458
  needsFiles() {
423
- return this.options.trunk;
459
+ const { trunk, readmeOnly, svnTag } = this.options;
460
+ return Boolean(trunk || readmeOnly || svnTag);
424
461
  }
425
462
  /**
426
463
  * Describe the files that are synced, including the ignore rules.
@@ -448,14 +485,6 @@ export class SvnDeploy {
448
485
  ];
449
486
  return rules.filter(Boolean).join(', ');
450
487
  }
451
- /**
452
- * Get the repository URL of trunk.
453
- *
454
- * @return Trunk URL without a double slash.
455
- */
456
- getTrunkUrl() {
457
- return `${this.options.svnUrl.replace(/\/+$/, '')}/trunk`;
458
- }
459
488
  /**
460
489
  * Get the path of the tag in the working copy.
461
490
  *
@@ -469,11 +498,15 @@ export class SvnDeploy {
469
498
  return null;
470
499
  }
471
500
  getDefaultMessage() {
472
- const { trunk, svnTag, assets } = this.options;
501
+ const { trunk, readmeOnly, svnTag, assets } = this.options;
473
502
  if (svnTag) {
474
503
  return `Release version ${svnTag}`;
475
504
  }
476
- const parts = [trunk && 'trunk', assets && 'assets'];
505
+ const parts = [
506
+ trunk && 'trunk',
507
+ readmeOnly && 'readme',
508
+ assets && 'assets',
509
+ ];
477
510
  return `Update ${parts.filter(Boolean).join(' and ')}`;
478
511
  }
479
512
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.2.0",
4
- "description": "Package, checksum, verify and version-check WordPress plugins and themes, and deploy plugins to WordPress.org SVN",
3
+ "version": "0.4.0",
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": {
7
7
  "type": "git",
@@ -35,7 +35,9 @@
35
35
  "release",
36
36
  "deploy",
37
37
  "svn",
38
- "zip"
38
+ "zip",
39
+ "checksums",
40
+ "signing"
39
41
  ],
40
42
  "dependencies": {
41
43
  "ignore": "^7.0.12",