@wpelevator/wp-release 0.3.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,11 @@ 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. Readme-only updates need
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
77
79
  * the readme.
78
80
  *
79
81
  * @throws {ValidationError} When the files or versions don't match.
@@ -89,7 +91,9 @@ export declare class SvnDeploy {
89
91
  readPluginMeta(sourceDir: string): Promise<PackageMeta>;
90
92
  /**
91
93
  * Create a sparse checkout with only the directories the flags write to,
92
- * 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.
93
97
  *
94
98
  * @throws {ValidationError} If the SVN tag exists and force isn't set.
95
99
  */
@@ -110,6 +114,7 @@ export declare class SvnDeploy {
110
114
  /**
111
115
  * Copy the files for each write flag into the working copy and stage
112
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.
113
118
  */
114
119
  sync(): Promise<void>;
115
120
  /**
@@ -134,9 +139,8 @@ export declare class SvnDeploy {
134
139
  */
135
140
  setAssetMimeTypes(): Promise<void>;
136
141
  /**
137
- * Copy trunk to the SVN tag, replacing an existing tag with force. Trunk
138
- * is copied from the working copy when this run writes to it, and from the
139
- * 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.
140
144
  */
141
145
  copyTag(): Promise<void>;
142
146
  /**
@@ -185,7 +189,7 @@ export declare class SvnDeploy {
185
189
  /**
186
190
  * Check if the deploy reads plugin files, which assets-only runs don't.
187
191
  *
188
- * @return Whether trunk or the readme is written.
192
+ * @return Whether trunk, the readme or a tag is written.
189
193
  */
190
194
  needsFiles(): boolean;
191
195
  /**
@@ -200,12 +204,6 @@ export declare class SvnDeploy {
200
204
  * @return Rules description, empty without rules.
201
205
  */
202
206
  describeRules(): string;
203
- /**
204
- * Get the repository URL of trunk.
205
- *
206
- * @return Trunk URL without a double slash.
207
- */
208
- getTrunkUrl(): string;
209
207
  /**
210
208
  * Get the path of the tag in the working copy.
211
209
  *
@@ -95,9 +95,11 @@ 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. Readme-only updates need
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
101
103
  * the readme.
102
104
  *
103
105
  * @throws {ValidationError} When the files or versions don't match.
@@ -105,18 +107,18 @@ export class SvnDeploy {
105
107
  async checkVersions() {
106
108
  const { trunk, readmeOnly, svnTag, slug, readme } = this.options;
107
109
  const sourceDir = this.filesDir ?? this.options.sourceDir;
108
- if (trunk || readmeOnly) {
110
+ if (this.needsFiles()) {
109
111
  const meta = await this.readPluginMeta(sourceDir);
110
112
  if (readmeOnly && !meta.readme) {
111
113
  throw new ValidationError(`${readme} doesn't exist in ${sourceDir}.`);
112
114
  }
113
- if (trunk) {
115
+ if (trunk || svnTag) {
114
116
  const { version, mismatches } = await findVersionMismatches({
115
117
  sourceDir,
116
118
  meta,
117
119
  slug,
118
120
  version: this.options.version,
119
- stableTag: Boolean(svnTag),
121
+ stableTag: Boolean(trunk && svnTag),
120
122
  });
121
123
  this.version = version;
122
124
  if (mismatches.length) {
@@ -148,7 +150,9 @@ export class SvnDeploy {
148
150
  }
149
151
  /**
150
152
  * Create a sparse checkout with only the directories the flags write to,
151
- * 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.
152
156
  *
153
157
  * @throws {ValidationError} If the SVN tag exists and force isn't set.
154
158
  */
@@ -167,6 +171,12 @@ export class SvnDeploy {
167
171
  if (tagPath && !this.options.force && this.hasTag()) {
168
172
  throw new ValidationError(`${tagPath} already exists in ${this.options.svnUrl}. Pass --force to replace it.`);
169
173
  }
174
+ if (tagPath && !this.options.trunk && this.hasTag()) {
175
+ await this.svn.update({
176
+ path: join(workingCopy, tagPath),
177
+ depth: 'infinity',
178
+ });
179
+ }
170
180
  }
171
181
  /**
172
182
  * Create and add a top-level directory that's missing from the repository,
@@ -193,9 +203,11 @@ export class SvnDeploy {
193
203
  /**
194
204
  * Copy the files for each write flag into the working copy and stage
195
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.
196
207
  */
197
208
  async sync() {
198
209
  const { trunk, readmeOnly, assets, readme } = this.options;
210
+ const tagPath = this.getTagPath();
199
211
  const workingCopy = this.getWorkingCopy();
200
212
  const sourceDir = this.filesDir ?? this.options.sourceDir;
201
213
  if (trunk) {
@@ -205,6 +217,13 @@ export class SvnDeploy {
205
217
  targetDir: join(workingCopy, 'trunk'),
206
218
  });
207
219
  }
220
+ if (tagPath && !trunk) {
221
+ await syncDirectory({
222
+ sourceDir,
223
+ files: await this.getSourceFiles(sourceDir),
224
+ targetDir: join(workingCopy, tagPath),
225
+ });
226
+ }
208
227
  if (readmeOnly) {
209
228
  await copyFileIfChanged(join(sourceDir, readme), join(workingCopy, 'trunk', readme));
210
229
  }
@@ -277,20 +296,22 @@ export class SvnDeploy {
277
296
  }
278
297
  }
279
298
  /**
280
- * Copy trunk to the SVN tag, replacing an existing tag with force. Trunk
281
- * is copied from the working copy when this run writes to it, and from the
282
- * 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.
283
301
  */
284
302
  async copyTag() {
285
303
  const tagPath = this.getTagPath();
286
304
  const workingCopy = this.getWorkingCopy();
287
- if (tagPath && this.hasTag()) {
288
- this.logger.warning(`Replacing the existing ${tagPath}.`);
289
- await this.svn.remove({ paths: [tagPath], cwd: workingCopy });
290
- }
291
- 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
+ }
292
313
  await this.svn.copy({
293
- from: this.needsFiles() ? 'trunk' : this.getTrunkUrl(),
314
+ from: 'trunk',
294
315
  to: tagPath,
295
316
  cwd: workingCopy,
296
317
  });
@@ -389,7 +410,10 @@ export class SvnDeploy {
389
410
  tagPath && force && `Replace ${tagPath} if it exists`,
390
411
  trunk && `Sync ${this.describeSource()} to trunk/`,
391
412
  readmeOnly && `Copy ${readme} to trunk/`,
392
- tagPath && `Copy trunk/ to ${tagPath}`,
413
+ tagPath && trunk && `Copy trunk/ to ${tagPath}`,
414
+ tagPath &&
415
+ !trunk &&
416
+ `Sync ${this.describeSource()} to ${tagPath}`,
393
417
  assets && `Sync ${assetsDir} to assets/`,
394
418
  this.options.dryRun
395
419
  ? `Skip the commit "${message}" (dry run)`
@@ -429,10 +453,11 @@ export class SvnDeploy {
429
453
  /**
430
454
  * Check if the deploy reads plugin files, which assets-only runs don't.
431
455
  *
432
- * @return Whether trunk or the readme is written.
456
+ * @return Whether trunk, the readme or a tag is written.
433
457
  */
434
458
  needsFiles() {
435
- return this.options.trunk || this.options.readmeOnly;
459
+ const { trunk, readmeOnly, svnTag } = this.options;
460
+ return Boolean(trunk || readmeOnly || svnTag);
436
461
  }
437
462
  /**
438
463
  * Describe the files that are synced, including the ignore rules.
@@ -460,14 +485,6 @@ export class SvnDeploy {
460
485
  ];
461
486
  return rules.filter(Boolean).join(', ');
462
487
  }
463
- /**
464
- * Get the repository URL of trunk.
465
- *
466
- * @return Trunk URL without a double slash.
467
- */
468
- getTrunkUrl() {
469
- return `${this.options.svnUrl.replace(/\/+$/, '')}/trunk`;
470
- }
471
488
  /**
472
489
  * Get the path of the tag in the working copy.
473
490
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.3.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",