@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.
- package/README.md +55 -8
- package/dist/checksums.js +3 -2
- package/dist/cli.d.ts +30 -1
- package/dist/cli.js +32 -0
- package/dist/commands/args.d.ts +10 -0
- package/dist/commands/args.js +13 -0
- package/dist/commands/checksums.d.ts +9 -7
- package/dist/commands/checksums.js +3 -3
- package/dist/commands/command.d.ts +2 -2
- package/dist/commands/deploy-svn.js +5 -2
- package/dist/commands/sign.d.ts +21 -0
- package/dist/commands/sign.js +55 -0
- package/dist/commands/verify.d.ts +14 -11
- package/dist/commands/verify.js +289 -27
- package/dist/commands/zip.d.ts +11 -8
- package/dist/commands/zip.js +4 -4
- package/dist/download.d.ts +15 -0
- package/dist/download.js +27 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +3 -1
- package/dist/signing.d.ts +69 -0
- package/dist/signing.js +140 -0
- package/dist/svn-deploy.d.ts +12 -14
- package/dist/svn-deploy.js +44 -27
- package/package.json +5 -3
package/dist/signing.js
ADDED
|
@@ -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
|
+
}
|
package/dist/svn-deploy.d.ts
CHANGED
|
@@ -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
|
|
75
|
-
* a plugin with a Version header that
|
|
76
|
-
*
|
|
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
|
|
138
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/svn-deploy.js
CHANGED
|
@@ -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
|
|
99
|
-
* a plugin with a Version header that
|
|
100
|
-
*
|
|
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 (
|
|
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
|
|
281
|
-
*
|
|
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.
|
|
288
|
-
this.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
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
|
|
456
|
+
* @return Whether trunk, the readme or a tag is written.
|
|
433
457
|
*/
|
|
434
458
|
needsFiles() {
|
|
435
|
-
|
|
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.
|
|
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",
|