@wpelevator/wp-release 0.14.0 → 0.15.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
@@ -41,7 +41,7 @@ npx wp-release --help
41
41
  | `wp-release changeset-version [options]` | Apply version bumps and changelog entries. |
42
42
  | `wp-release changeset-tag [options]` | Create missing package release tags. |
43
43
 
44
- 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`. In GitHub Actions, `--github-output` sets the result as JSON to the `result` output of the step instead, for later steps and jobs to read with `fromJSON( steps.<id>.outputs.result )`.
44
+ 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`. In GitHub Actions, `--github-output` sets the result as JSON to the `result` output of the step instead, for later steps and jobs to read with `fromJSON( steps.<id>.outputs.result )`. Each top-level string, number or boolean of the result is also its own output, like `steps.<id>.outputs.zipPath` for `zip`, so a step reads it without `fromJSON()`; lists, like the `tags` of `changeset-tag` or the `deploys` of `deploy-git`, are only in `result`. Since GitHub evaluates the `env` of a step also when its `if` is false, and `fromJSON()` fails for the empty output of a skipped step, read the plain outputs in steps that run conditionally.
45
45
 
46
46
  ### GitHub Actions annotations and job summaries
47
47
 
@@ -65,7 +65,7 @@ Each command also adds a section to the [job summary](https://docs.github.com/en
65
65
  |---|---|
66
66
  | `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. |
67
67
  | `deploy-git` | Repository, release commit and the pushed branch and tag. |
68
- | `zip` | ZIP, number of files and checksums manifest. |
68
+ | `zip` | ZIP, number of files and checksums manifest; with `--sign`, signature files and the public key of each written format. |
69
69
  | `checksums` | Manifest and number of files. |
70
70
  | `sign` | Signature files and the public key of each written format. |
71
71
  | `verify` | That all files match, or the counts and a table of the changed, missing and extra files. |
@@ -281,7 +281,7 @@ Each concept has one name, written in the syntax of each interface:
281
281
  | Concept | CLI | GitHub Action | Library | Meaning |
282
282
  |---|---|---|---|---|
283
283
  | Plugin or theme directory | `<source-dir>` argument or `--source-dir` | `source-dir` | `sourceDir` | Directory that a command reads, whether it's the repository or a build directory. |
284
- | Generated file | `--output <file>` | | `outputPath` | File written by `zip` or `checksums`. |
284
+ | Generated file | `--output <file>` | `output` | `outputPath` | File written by `zip` or `checksums`. |
285
285
  | ZIP to deploy | `--from-zip <file>` | `from-zip` | `fromZip` | Existing ZIP read as the deploy source. |
286
286
  | Git tag to compare | `--git-tag <tag>` | | `gitTag` | Git tag checked by `version-check`. |
287
287
  | SVN tag to create | `--svn-tag <version>` | `svn-tag` | `svnTag` | Tag written by `deploy-svn`, distinct from a git tag. |
@@ -291,9 +291,14 @@ Each concept has one name, written in the syntax of each interface:
291
291
  | Source history | `--split-prefix <dir>` | | `splitPrefix` | Directory of a monorepo package whose history, from `git subtree split`, becomes the source history of `deploy-git` releases. |
292
292
  | 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. |
293
293
  | 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. |
294
+ | Release config | `--config <path>` | `config` in ZIP and SVN actions | `configFile` | Explicit JavaScript config path for configured packaging or deployment. |
295
+ | Release tag format | `--tag-format <format>` | `tag-format` in SVN action | `tagFormat` | How `release-ref` identifies a package and its version. |
296
+ | Release ref to deploy | `--release-ref <ref>` | `release-ref` in SVN action | `ref` | Read configured packages and deployment targets from a release ref. |
294
297
  | 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. |
295
298
 
296
- 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`, `GitDeploy`) without repeating "release".
299
+ CLI JSON results use camelCase keys like `zipPath`, and GitHub Action outputs use kebab-case like `zip-path`. The ZIP action supports `package` and `config`, and the SVN action supports `release-ref`, repeated `package` values as newline-separated input, `list`, `config` and `tag-format`.
300
+
301
+ Library classes are named after the capability (`SvnDeploy`, `Svn`, `GitDeploy`) without repeating "release".
297
302
 
298
303
  ### File selection
299
304
 
@@ -328,6 +333,7 @@ wp-release zip dist --slug my-plugin --checksums
328
333
  - `--filename-format` changes the name, like `--filename-format {slug}` for `my-plugin.zip`.
329
334
  - Entries are sorted and use a fixed timestamp and file mode, so the same files always give the same ZIP.
330
335
  - `--checksums` also writes the [checksums manifest](#checksums) of the archived files next to the ZIP, with `.checksums.json` instead of `.zip`.
336
+ - `--sign` also [signs](#sign) the ZIP and the manifest with the `WP_RELEASE_SIGNING_KEY` key in the same command that writes them, like `wp-release sign` with the same files, writing a WordPress `.sig` next to each. `--sign-format <names>` picks the [signature formats](#signature-formats), like the `--format` of `sign`, such as `--sign-format wordpress,minisign`, and implies `--sign`. The signature formats, signing key and existing signature paths are checked before writing the ZIP or manifest, so correcting invalid signing options permits a retry without `--force`. `--force` also overwrites existing signatures. With the GitHub log format, the job summary includes the signatures and the public key of each written format. The result lists the signature files in `signatures` and the public key of the signing key in `publicKey`.
331
337
  - An existing ZIP or manifest isn't overwritten without `--force`. A ZIP or manifest written inside the source directory is never archived.
332
338
  - `--package <name>` zips a package of the [configured packages](#deploy-configured-packages), by identifier or path under the configured root like `plugins/alpha`, instead of a source directory: its directory with the `files` config of its package group, the same files that its deploys select. The slug defaults to the directory name and the version to the `Version` header, and `--slug` and `--version` still set them. Any package of the config works, with or without a version or deploy targets. To zip what a deploy just released, pass the `package` and `version` of the `deploy-git --release-ref` result, in the same job and from the same build, so the ZIP has exactly the files of the release tag in the release repository. It can't be combined with `<source-dir>` or the file selection options. The `--json` result has the `package`, `slug` and `version`. `zipPackage()` builds the same ZIP from a script, with `getPackageFiles()` for the directory and the files of a package.
333
339
 
@@ -414,6 +420,7 @@ WP_RELEASE_SIGNING_KEY=… wp-release sign my-plugin.1.2.0.zip my-plugin.1.2.0.c
414
420
  - The key is read from the `WP_RELEASE_SIGNING_KEY` environment variable only, as a base64 32-byte Ed25519 seed or a 64-byte libsodium secret key, the formats that `sodium_crypto_sign_seed_keypair()` and the update-pilot-server package signing key take. There's no flag for it, since it would show in the process list, and it's never logged.
415
421
  - `--verbose` prints the public key of the signing key in the form of each written format. `--json` returns it as `publicKey`, and in every form as `publicKeys`. Pass it to `verify --public-key` and to the plugin that checks the signatures.
416
422
  - An existing signature file isn't overwritten without `--force`.
423
+ - `zip --sign` signs a ZIP and its manifest as it writes them, and `signFiles()` signs files from a script.
417
424
 
418
425
  #### Signature formats
419
426
 
@@ -745,7 +752,9 @@ npm run lint --workspace @wpelevator/wp-release
745
752
  - `test` runs the `.ts` tests directly with `node --test`, using Node.js type stripping, so it needs Node.js 22.18 or later but no build. The SVN integration tests in `svn-deploy.integration.test.ts` create repositories with `svnadmin` and deploy to their `file://` URLs; they're skipped when `svn` or `svnadmin` isn't installed.
746
753
  - `lint` runs ESLint and a `tsc` type check.
747
754
 
748
- The `wpelevator-source` export condition points at `src/index.ts`. The tests and the `wp-release-deploy-svn` action build use it to work with the source instead of a `dist/` build. It's not set by other tools, so the published package always resolves to `dist/`.
755
+ The `wpelevator-source` export condition points at `src/index.ts`. The wp-release tests and monorepo CLI scripts use it to work with the source instead of a `dist/` build. Action builds use the declared dependency's default export, which resolves to `dist/` for the workspace copy and the published package. Build wp-release before building the actions.
756
+
757
+ `execute( 'deploy-svn', args, { cwd, logger, svnRunner } )` runs a single-plugin deployment or a configured plan or deployment. The optional `svnRunner` replaces the external command runner in both modes; without it, the command uses its normal runner.
749
758
 
750
759
  ### Architecture
751
760
 
@@ -756,6 +765,8 @@ wp-release has two layers, so that it fits any repository layout:
756
765
 
757
766
  Nothing assumes a directory structure or naming convention that the config doesn't set.
758
767
 
768
+ The GitHub Actions are adapters to these commands: they use the same flag names and `execute()` entry point, with secrets supplied through environment variables. The SVN action injects a command runner that checks or installs SVN only when a deployment runs its first SVN command; config planning and deployment behavior remain in `wp-release`.
769
+
759
770
  The package uses ES modules as its unit of encapsulation, like most Node.js CLI tools: a module exports a few functions and keeps its helpers private, so there's no class to wrap them in. A class is used only when it holds state across steps or binds dependencies, so that callers and tests can replace them:
760
771
 
761
772
  - **Workflows with steps** are classes, so that their steps share state and dependencies on the instance: `SvnDeploy` shares the temporary working copy across `prepare()`, `checkout()`, `sync()` and `commit()`, `GitDeploy` binds the options and the git runner to its steps, and `ChangesetRelease` binds the repository and the runners to `add()`, `version()` and `tag()`.
package/dist/cli.d.ts CHANGED
@@ -3,9 +3,8 @@ import type { SignResult } from './commands/sign.ts';
3
3
  import type { KeygenResult } from './commands/keygen.ts';
4
4
  import type { ZipResult } from './commands/zip.ts';
5
5
  import type { ChangesetVersionResult } from './commands/changeset-version.ts';
6
- import { Logger } from './logger.ts';
7
6
  import type { OutputStream } from './logger.ts';
8
- import type { Command, CommandOutcome } from './commands/command.ts';
7
+ import type { CommandContext, Command, CommandOutcome } from './commands/command.ts';
9
8
  /** Input stream that interactive prompts read from in a terminal. */
10
9
  export type InputStream = NodeJS.ReadableStream & {
11
10
  isTTY?: boolean;
@@ -44,10 +43,23 @@ export declare function getCommandHelp(command: Command): string;
44
43
  * @return Command definition.
45
44
  */
46
45
  export declare function findCommand(args: string[]): Command | undefined;
46
+ /**
47
+ * Format the result of a command as GitHub Actions step outputs: the whole
48
+ * result as JSON in "result", and each top-level string, number or boolean
49
+ * as its own output, like "zipPath", so that a workflow reads it without
50
+ * fromJSON(). Lists and objects are only in "result". Every value but the
51
+ * compact JSON, which has no line breaks, uses a random delimiter, so that a
52
+ * value with line breaks can't set other outputs.
53
+ *
54
+ * @param result Command result.
55
+ * @return Lines for the GITHUB_OUTPUT file.
56
+ */
57
+ export declare function formatGitHubOutputs(result: unknown): string;
47
58
  /**
48
59
  * Results of the commands that can be run with execute(), by command name.
49
60
  */
50
61
  export interface CommandResults {
62
+ 'deploy-svn': import('./commands/deploy-svn.ts').DeploySvnResult;
51
63
  zip: ZipResult;
52
64
  checksums: ChecksumsResult;
53
65
  sign: SignResult;
@@ -68,14 +80,11 @@ export interface CommandResults {
68
80
  * @param context.cwd Working directory that relative paths resolve against.
69
81
  * @param context.configFile Explicit config file path, when supplied by a wrapper.
70
82
  * @param context.logger Logger for the command output.
83
+ * @param context.svnRunner Optional SVN command runner supplied by a wrapper.
71
84
  * @return Exit code and result of the command.
72
85
  * @throws {Error} If the command is unknown, the arguments are invalid or the command fails.
73
86
  */
74
- export declare function execute<Name extends keyof CommandResults>(name: Name, args: string[], context: {
75
- cwd: string;
76
- logger: Logger;
77
- configFile?: string;
78
- }): Promise<CommandOutcome<CommandResults[Name]>>;
87
+ export declare function execute<Name extends keyof CommandResults>(name: Name, args: string[], context: Pick<CommandContext, 'cwd' | 'logger' | 'configFile' | 'svnRunner'>): Promise<CommandOutcome<CommandResults[Name]>>;
79
88
  /**
80
89
  * Run the CLI.
81
90
  *
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { appendFileSync } from 'node:fs';
2
3
  import { parseArgs } from 'node:util';
3
4
  import zip from "./commands/zip.js";
@@ -177,6 +178,28 @@ function createLogWriter(values, streams) {
177
178
  }
178
179
  return create(streams);
179
180
  }
181
+ /**
182
+ * Format the result of a command as GitHub Actions step outputs: the whole
183
+ * result as JSON in "result", and each top-level string, number or boolean
184
+ * as its own output, like "zipPath", so that a workflow reads it without
185
+ * fromJSON(). Lists and objects are only in "result". Every value but the
186
+ * compact JSON, which has no line breaks, uses a random delimiter, so that a
187
+ * value with line breaks can't set other outputs.
188
+ *
189
+ * @param result Command result.
190
+ * @return Lines for the GITHUB_OUTPUT file.
191
+ */
192
+ export function formatGitHubOutputs(result) {
193
+ const values = 'object' === typeof result && null !== result
194
+ ? Object.entries(result).filter(([name, value]) => 'result' !== name &&
195
+ ['string', 'number', 'boolean'].includes(typeof value))
196
+ : [];
197
+ const delimiter = `ghadelimiter_${randomUUID()}`;
198
+ return [
199
+ `result=${JSON.stringify(result)}\n`,
200
+ ...values.map(([name, value]) => `${name}<<${delimiter}\n${String(value)}\n${delimiter}\n`),
201
+ ].join('');
202
+ }
180
203
  /**
181
204
  * Get the GitHub Actions step output file for --github-output, checked before
182
205
  * the command runs.
@@ -241,8 +264,7 @@ async function runCommand(command, args, io) {
241
264
  stdout.write(JSON.stringify(result, null, '\t') + '\n');
242
265
  }
243
266
  if (githubOutputFile) {
244
- // Compact JSON has no line breaks, so it fits a single output line.
245
- appendFileSync(githubOutputFile, `result=${JSON.stringify(result)}\n`);
267
+ appendFileSync(githubOutputFile, formatGitHubOutputs(result));
246
268
  }
247
269
  return exitCode;
248
270
  }
@@ -262,6 +284,7 @@ async function runCommand(command, args, io) {
262
284
  * @param context.cwd Working directory that relative paths resolve against.
263
285
  * @param context.configFile Explicit config file path, when supplied by a wrapper.
264
286
  * @param context.logger Logger for the command output.
287
+ * @param context.svnRunner Optional SVN command runner supplied by a wrapper.
265
288
  * @return Exit code and result of the command.
266
289
  * @throws {Error} If the command is unknown, the arguments are invalid or the command fails.
267
290
  */
@@ -1,4 +1,5 @@
1
1
  import type { ParseArgsOptionsConfig } from 'node:util';
2
+ import type { SignatureFormat } from '../signing.ts';
2
3
  import type { ParsedValues } from './command.ts';
3
4
  /**
4
5
  * Options shared by the commands that select files with .distignore and
@@ -69,3 +70,13 @@ export interface ResolveOutputInput {
69
70
  * @return Absolute file path.
70
71
  */
71
72
  export declare function resolveOutput(input: ResolveOutputInput): string;
73
+ /**
74
+ * Get the signature formats from the --format values of sign or the
75
+ * --sign-format values of zip, which can be repeated
76
+ * or comma-separated.
77
+ *
78
+ * @param values Option values.
79
+ * @return Unique formats, the default when none are given.
80
+ * @throws {ValidationError} If a format is unknown.
81
+ */
82
+ export declare function parseSignatureFormats(values: string[]): SignatureFormat[];
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { ValidationError } from "../errors.js";
4
+ import { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_FORMATS, isSignatureFormat, } from "../signing.js";
4
5
  import { getBoolean, getString, getStrings } from "./command.js";
5
6
  /**
6
7
  * Options shared by the commands that select files with .distignore and
@@ -95,3 +96,25 @@ export function getSourceDirArg(values, positionals) {
95
96
  export function resolveOutput(input) {
96
97
  return resolve(input.cwd, input.output ?? input.filename);
97
98
  }
99
+ /**
100
+ * Get the signature formats from the --format values of sign or the
101
+ * --sign-format values of zip, which can be repeated
102
+ * or comma-separated.
103
+ *
104
+ * @param values Option values.
105
+ * @return Unique formats, the default when none are given.
106
+ * @throws {ValidationError} If a format is unknown.
107
+ */
108
+ export function parseSignatureFormats(values) {
109
+ const names = values
110
+ .flatMap((value) => value.split(','))
111
+ .map((name) => name.trim())
112
+ .filter(Boolean);
113
+ const unknown = names.filter((name) => !isSignatureFormat(name));
114
+ if (unknown.length) {
115
+ throw new ValidationError(`Unknown signature format ${unknown.join(', ')}, use ${Object.keys(SIGNATURE_FORMATS).join(', ')}.`);
116
+ }
117
+ return names.length
118
+ ? [...new Set(names.filter(isSignatureFormat))]
119
+ : [DEFAULT_SIGNATURE_FORMAT];
120
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ParseArgsOptionsConfig } from 'node:util';
2
2
  import type { PromptTerminal } from '../changeset-prompt.ts';
3
3
  import type { Logger } from '../logger.ts';
4
+ import type { SvnRunner } from '../svn.ts';
4
5
  /**
5
6
  * Parsed option values from node:util parseArgs().
6
7
  */
@@ -18,6 +19,8 @@ export interface CommandContext {
18
19
  tagFormat?: string;
19
20
  /** Logger for the command output. */
20
21
  logger: Logger;
22
+ /** SVN command runner supplied by a wrapper, instead of spawning svn directly. */
23
+ svnRunner?: SvnRunner;
21
24
  /** Terminal for interactive prompts, only when the CLI runs in one and prints no JSON. */
22
25
  terminal?: PromptTerminal;
23
26
  }
@@ -1,5 +1,14 @@
1
1
  import type { DeployInput } from '../deploy-options.ts';
2
- import type { ParsedValues } from './command.ts';
2
+ import type { DeployResult } from '../svn-deploy.ts';
3
+ import type { PackageDeployPlanItem } from '../package-deploy.ts';
4
+ import type { CommandOutcome, ParsedValues } from './command.ts';
5
+ import type { PackageDeployOutcome } from './package-deploys.ts';
6
+ /** Results of a single deploy, a configured plan or configured deploys. */
7
+ export type DeploySvnResult = DeployResult | {
8
+ packages: PackageDeployPlanItem[];
9
+ } | {
10
+ deploys: PackageDeployOutcome<DeployResult>[];
11
+ };
3
12
  export interface DeployArgsInput {
4
13
  /** Parsed CLI values. */
5
14
  values: ParsedValues;
@@ -76,19 +85,6 @@ declare const _default: {
76
85
  type: "string";
77
86
  };
78
87
  };
79
- run({ values, positionals, cwd, configFile, tagFormat, logger }: import("./command.ts").CommandContext): Promise<{
80
- exitCode: number;
81
- result: {
82
- packages: import("../package-deploy.ts").PackageDeployPlanItem[];
83
- };
84
- } | {
85
- exitCode: number;
86
- result: {
87
- deploys: import("./package-deploys.ts").PackageDeployOutcome<import("../svn-deploy.ts").DeployResult>[];
88
- };
89
- } | {
90
- exitCode: number;
91
- result: import("../svn-deploy.ts").DeployResult;
92
- }>;
88
+ run({ values, positionals, cwd, configFile, tagFormat, logger, svnRunner, }: import("./command.ts").CommandContext): Promise<CommandOutcome<DeploySvnResult>>;
93
89
  };
94
90
  export default _default;
@@ -1,6 +1,7 @@
1
1
  import { resolveDeployOptions } from "../deploy-options.js";
2
2
  import { getSvnDeploys } from "../package-deploy.js";
3
3
  import { SvnDeploy } from "../svn-deploy.js";
4
+ import { Svn } from "../svn.js";
4
5
  import { fileSelectionArgs, getFileSelectionInput, getSourceDirArg, sourceDirArgs, } from "./args.js";
5
6
  import { getBoolean, getString } from "./command.js";
6
7
  import { getPackageDeployArgs, listPackageDeploys, packageDeployArgs, runPackageDeploys, } from "./package-deploys.js";
@@ -23,6 +24,26 @@ const TARGET_FLAGS = [
23
24
  'svn-url',
24
25
  'message',
25
26
  ];
27
+ /**
28
+ * Run an SVN deploy with the optional command runner supplied by a wrapper.
29
+ *
30
+ * @param input Deploy input.
31
+ * @param logger Logger for the deploy.
32
+ * @param runner Optional SVN command runner.
33
+ * @return Deploy result.
34
+ */
35
+ async function runDeploy(input, logger, runner) {
36
+ const options = resolveDeployOptions(input);
37
+ const svn = runner
38
+ ? new Svn({
39
+ runner,
40
+ username: options.svnUsername,
41
+ password: options.svnPassword,
42
+ logger,
43
+ })
44
+ : undefined;
45
+ return new SvnDeploy(options, logger, svn).run();
46
+ }
26
47
  /**
27
48
  * Map the parsed "deploy-svn" arguments to resolveDeployOptions() input.
28
49
  * Flags that aren't passed stay undefined, so that the same defaults apply
@@ -135,7 +156,7 @@ Examples:
135
156
  ...packageDeployArgs,
136
157
  'dry-run': { type: 'boolean' },
137
158
  },
138
- async run({ values, positionals, cwd, configFile, tagFormat, logger }) {
159
+ async run({ values, positionals, cwd, configFile, tagFormat, logger, svnRunner, }) {
139
160
  const { ref, packages, list } = getPackageDeployArgs(values, positionals, TARGET_FLAGS);
140
161
  if (ref) {
141
162
  const deploys = await getSvnDeploys({
@@ -148,14 +169,13 @@ Examples:
148
169
  });
149
170
  return list
150
171
  ? listPackageDeploys(deploys, logger, ref)
151
- : runPackageDeploys(deploys, (input) => new SvnDeploy(resolveDeployOptions({
172
+ : runPackageDeploys(deploys, (input) => runDeploy({
152
173
  ...input,
153
174
  svnUsername: getString(values, 'svn-username'),
154
175
  force: getBoolean(values, 'force'),
155
- }), logger).run(), logger, ref);
176
+ }, logger, svnRunner), logger, ref);
156
177
  }
157
- const options = resolveDeployOptions(getDeployInputFromArgs({ values, positionals, cwd }));
158
- const result = await new SvnDeploy(options, logger).run();
178
+ const result = await runDeploy(getDeployInputFromArgs({ values, positionals, cwd }), logger, svnRunner);
159
179
  return { exitCode: 0, result };
160
180
  },
161
181
  };
@@ -11,15 +11,6 @@ export interface SignResult {
11
11
  /** Public key of the signing key in the form of each signature format. */
12
12
  publicKeys: Record<SignatureFormat, string>;
13
13
  }
14
- /**
15
- * Get the signature formats from the --format values, which can be repeated
16
- * or comma-separated.
17
- *
18
- * @param values Option values.
19
- * @return Unique formats, the default when none are given.
20
- * @throws {ValidationError} If a format is unknown.
21
- */
22
- export declare function parseSignatureFormats(values: string[]): SignatureFormat[];
23
14
  declare const _default: {
24
15
  name: string;
25
16
  usage: string;
@@ -1,30 +1,9 @@
1
1
  import { resolve } from 'node:path';
2
- import { stat } from 'node:fs/promises';
3
2
  import { ValidationError } from "../errors.js";
4
3
  import { getSignSummary } from "../summary.js";
5
- import { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_FORMATS, SIGNING_KEY_ENV, getPublicKey, getPublicKeys, isSignatureFormat, parseSigningKey, writeSignature, } from "../signing.js";
4
+ import { SIGNING_KEY_ENV, signFiles } from "../signing.js";
5
+ import { parseSignatureFormats } from "./args.js";
6
6
  import { getBoolean, getStrings } from "./command.js";
7
- /**
8
- * Get the signature formats from the --format values, which can be repeated
9
- * or comma-separated.
10
- *
11
- * @param values Option values.
12
- * @return Unique formats, the default when none are given.
13
- * @throws {ValidationError} If a format is unknown.
14
- */
15
- export function parseSignatureFormats(values) {
16
- const names = values
17
- .flatMap((value) => value.split(','))
18
- .map((name) => name.trim())
19
- .filter(Boolean);
20
- const unknown = names.filter((name) => !isSignatureFormat(name));
21
- if (unknown.length) {
22
- throw new ValidationError(`Unknown signature format ${unknown.join(', ')}, use ${Object.keys(SIGNATURE_FORMATS).join(', ')}.`);
23
- }
24
- return names.length
25
- ? [...new Set(names.filter(isSignatureFormat))]
26
- : [DEFAULT_SIGNATURE_FORMAT];
27
- }
28
7
  export default {
29
8
  name: 'sign',
30
9
  usage: 'sign <file>... [options]',
@@ -58,34 +37,18 @@ form of each format.`,
58
37
  force: { type: 'boolean' },
59
38
  },
60
39
  async run({ values, positionals, cwd, logger, }) {
61
- const encoded = process.env[SIGNING_KEY_ENV];
62
40
  const formats = parseSignatureFormats(getStrings(values, 'format'));
63
41
  if (!positionals.length) {
64
42
  throw new ValidationError('Pass the files to sign.');
65
43
  }
66
- if (!encoded?.trim()) {
67
- throw new ValidationError(`Set the ${SIGNING_KEY_ENV} environment variable to the base64 Ed25519 signing key.`);
68
- }
69
- const privateKey = parseSigningKey(encoded);
70
- const publicKey = getPublicKey(privateKey);
71
- const publicKeys = getPublicKeys(privateKey);
72
- const paths = positionals.map((file) => resolve(cwd, file));
73
- await Promise.all(paths.map(async (path) => {
74
- if (!(await stat(path).catch(() => null))?.isFile()) {
75
- throw new ValidationError(`${path} is not a file.`);
76
- }
77
- }));
78
- formats.forEach((format) => logger.debug(`Signing ${format} with the public key ${publicKeys[format]}.`));
79
- const signatures = [];
80
- const force = getBoolean(values, 'force');
81
- const jobs = paths.flatMap((path) => formats.map((format) => ({ path, format })));
82
- // Sign in order, so that a failure leaves a predictable set of files.
83
- for (const { path, format } of jobs) {
84
- const signaturePath = await writeSignature(path, privateKey, force, format);
85
- logger.info(signaturePath);
86
- signatures.push(signaturePath);
87
- }
88
- const result = { signatures, formats, publicKey, publicKeys };
44
+ const signed = await signFiles({
45
+ paths: positionals.map((file) => resolve(cwd, file)),
46
+ formats,
47
+ force: getBoolean(values, 'force'),
48
+ });
49
+ formats.forEach((format) => logger.debug(`Signed ${format} with the public key ${signed.publicKeys[format]}.`));
50
+ signed.signatures.forEach((path) => logger.info(path));
51
+ const result = { ...signed, formats };
89
52
  logger.summary((context) => getSignSummary(context, result));
90
53
  return { exitCode: 0, result };
91
54
  },
@@ -8,6 +8,10 @@ export interface ZipResult extends ZipPackageResult {
8
8
  version: string | null;
9
9
  /** Zipped package, with --package. */
10
10
  package?: string;
11
+ /** Absolute paths of the signatures of the ZIP and the manifest, with --sign. */
12
+ signatures?: string[];
13
+ /** Base64 public key of the signing key, with --sign. */
14
+ publicKey?: string;
11
15
  }
12
16
  declare const _default: {
13
17
  name: string;
@@ -21,6 +25,13 @@ declare const _default: {
21
25
  checksums: {
22
26
  type: "boolean";
23
27
  };
28
+ sign: {
29
+ type: "boolean";
30
+ };
31
+ 'sign-format': {
32
+ type: "string";
33
+ multiple: true;
34
+ };
24
35
  force: {
25
36
  type: "boolean";
26
37
  };
@@ -4,9 +4,10 @@ import { resolveFileSelection } from "../file-selection.js";
4
4
  import { FILENAME_FORMAT, formatFilename } from "../filename.js";
5
5
  import { getPackageFiles } from "../package-deploy.js";
6
6
  import { readPackageMeta } from "../package-meta.js";
7
- import { zipPackage } from "../package-zip.js";
8
- import { getZipSummary } from "../summary.js";
9
- import { checkDirectory, fileSelectionArgs, getFileSelectionInput, getSourceDirArg, resolveOutput, sourceDirArgs, } from "./args.js";
7
+ import { getChecksumsPath, zipPackage } from "../package-zip.js";
8
+ import { SIGNING_KEY_ENV, checkSigningInput, signFiles } from "../signing.js";
9
+ import { getSignSummary, getZipSummary } from "../summary.js";
10
+ import { checkDirectory, fileSelectionArgs, getFileSelectionInput, getSourceDirArg, parseSignatureFormats, resolveOutput, sourceDirArgs, } from "./args.js";
10
11
  import { getBoolean, getString, getStrings } from "./command.js";
11
12
  /** Options of a source directory and its files, which --package takes from the config. */
12
13
  const SOURCE_FLAGS = [
@@ -134,6 +135,14 @@ Options:
134
135
  --checksums Also write the checksums manifest of the archived
135
136
  files next to the ZIP, with .checksums.json
136
137
  instead of .zip
138
+ --sign Also sign the ZIP and the checksums manifest with
139
+ the ${SIGNING_KEY_ENV} key, like
140
+ "wp-release sign", writing a WordPress .sig next
141
+ to each
142
+ --sign-format <names> Signature formats of --sign, comma-separated or
143
+ repeated, like wordpress,minisign,ssh, as the
144
+ --format of "wp-release sign" (default:
145
+ wordpress); implies --sign
137
146
  --force Overwrite existing output files
138
147
 
139
148
  Entries are sorted and use a fixed timestamp and file mode, so the same files
@@ -155,6 +164,8 @@ Examples:
155
164
  ...fileSelectionArgs,
156
165
  package: { type: 'string' },
157
166
  checksums: { type: 'boolean' },
167
+ sign: { type: 'boolean' },
168
+ 'sign-format': { type: 'string', multiple: true },
158
169
  force: { type: 'boolean' },
159
170
  },
160
171
  async run({ values, positionals, cwd, configFile, logger, }) {
@@ -163,14 +174,31 @@ Examples:
163
174
  ? await getPackageSource(values, cwd, name, configFile)
164
175
  : await getDirectorySource(values, positionals, cwd);
165
176
  const filename = formatFilename(getString(values, 'filename-format') ?? FILENAME_FORMAT, { slug: source.slug, version: source.version });
177
+ const zipPath = resolveOutput({
178
+ cwd,
179
+ output: getString(values, 'output'),
180
+ filename: `${filename}.zip`,
181
+ });
182
+ const signFormats = getStrings(values, 'sign-format');
183
+ const signing = getBoolean(values, 'sign') || signFormats.length
184
+ ? {
185
+ paths: [
186
+ zipPath,
187
+ ...(getBoolean(values, 'checksums')
188
+ ? [getChecksumsPath(zipPath)]
189
+ : []),
190
+ ],
191
+ formats: parseSignatureFormats(signFormats),
192
+ force: getBoolean(values, 'force'),
193
+ }
194
+ : undefined;
195
+ if (signing) {
196
+ checkSigningInput(signing);
197
+ }
166
198
  const zipped = await zipPackage({
167
199
  sourceDir: source.sourceDir,
168
200
  selection: source.selection,
169
- zipPath: resolveOutput({
170
- cwd,
171
- output: getString(values, 'output'),
172
- filename: `${filename}.zip`,
173
- }),
201
+ zipPath,
174
202
  rootFolder: getBoolean(values, 'no-root-folder')
175
203
  ? null
176
204
  : getString(values, 'root-folder') ?? source.slug,
@@ -185,13 +213,26 @@ Examples:
185
213
  if (zipped.checksumsPath) {
186
214
  logger.info(zipped.checksumsPath);
187
215
  }
216
+ const signed = signing
217
+ ? { ...(await signFiles(signing)), formats: signing.formats }
218
+ : undefined;
219
+ signed?.signatures.forEach((path) => logger.info(path));
188
220
  const result = {
189
221
  ...zipped,
222
+ ...(signed
223
+ ? {
224
+ signatures: signed.signatures,
225
+ publicKey: signed.publicKey,
226
+ }
227
+ : {}),
190
228
  slug: source.slug,
191
229
  version: source.version,
192
230
  ...(source.package ? { package: source.package } : {}),
193
231
  };
194
232
  logger.summary((context) => getZipSummary(context, result));
233
+ if (signed) {
234
+ logger.summary((context) => getSignSummary(context, signed));
235
+ }
195
236
  return { exitCode: 0, result };
196
237
  },
197
238
  };
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules,
9
9
  export { FILENAME_FORMAT, formatFilename } from './filename.ts';
10
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from './checksums.ts';
11
11
  export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from './package-meta.ts';
12
- export { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_EXTENSION, SIGNATURE_FORMATS, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getPublicKeys, getFileDigest, getSignaturePath, isSignatureFormat, signFile, writeSignature, verifyFileSignature, } from './signing.ts';
12
+ export { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_EXTENSION, SIGNATURE_FORMATS, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getPublicKeys, getFileDigest, getSignaturePath, isSignatureFormat, signFile, writeSignature, signFiles, verifyFileSignature, } from './signing.ts';
13
13
  export type { SignatureFormat, SignatureFormatHandler } from './signing.ts';
14
14
  export { MINISIGN_EXTENSION, createMinisignSignature, getMinisignPublicKey, parseMinisignPublicKey, verifyMinisignSignature, } from './minisign.ts';
15
15
  export { SSH_SIGNATURE_EXTENSION, SSH_SIGNATURE_NAMESPACE, createSshSignature, getSshPublicKey, parseSshPublicKey, verifySshSignature, } from './ssh-signature.ts';
@@ -34,10 +34,11 @@ export type { PackageDeployOutcome } from './commands/package-deploys.ts';
34
34
  export type { CliIo, CommandResults } from './cli.ts';
35
35
  export type { ChecksumsResult } from './commands/checksums.ts';
36
36
  export type { SignResult } from './commands/sign.ts';
37
+ export type { SignFilesInput, SignFilesResult } from './signing.ts';
37
38
  export type { KeygenResult } from './commands/keygen.ts';
38
39
  export type { ZipResult } from './commands/zip.ts';
39
40
  export type { Command, CommandContext, CommandOutcome, ParsedValues, } from './commands/command.ts';
40
- export type { DeployArgsInput } from './commands/deploy-svn.ts';
41
+ export type { DeployArgsInput, DeploySvnResult, } from './commands/deploy-svn.ts';
41
42
  export type { Annotation, LogEntry, LogLevel, LogWriter, LoggerOptions, OutputStream, SummaryBuilder, TextWriterOptions, } from './logger.ts';
42
43
  export type { GitHubActionsWriterOptions } from './github-actions-writer.ts';
43
44
  export type { Problem } from './errors.ts';
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules,
9
9
  export { FILENAME_FORMAT, formatFilename } from "./filename.js";
10
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from "./checksums.js";
11
11
  export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from "./package-meta.js";
12
- export { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_EXTENSION, SIGNATURE_FORMATS, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getPublicKeys, getFileDigest, getSignaturePath, isSignatureFormat, signFile, writeSignature, verifyFileSignature, } from "./signing.js";
12
+ export { DEFAULT_SIGNATURE_FORMAT, SIGNATURE_EXTENSION, SIGNATURE_FORMATS, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, generateSigningKeyPair, getPublicKey, getPublicKeys, getFileDigest, getSignaturePath, isSignatureFormat, signFile, writeSignature, signFiles, verifyFileSignature, } from "./signing.js";
13
13
  export { MINISIGN_EXTENSION, createMinisignSignature, getMinisignPublicKey, parseMinisignPublicKey, verifyMinisignSignature, } from "./minisign.js";
14
14
  export { SSH_SIGNATURE_EXTENSION, SSH_SIGNATURE_NAMESPACE, createSshSignature, getSshPublicKey, parseSshPublicKey, verifySshSignature, } from "./ssh-signature.js";
15
15
  export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from "./zip.js";
package/dist/signing.d.ts CHANGED
@@ -119,6 +119,42 @@ export declare function getPublicKeys(key: KeyObject): Record<SignatureFormat, s
119
119
  * @throws {ValidationError} If the signature file exists and force isn't set.
120
120
  */
121
121
  export declare function writeSignature(path: string, privateKey: KeyObject, force?: boolean, format?: SignatureFormat): Promise<string>;
122
+ export interface SignFilesInput {
123
+ /** Absolute paths of the files to sign. */
124
+ paths: string[];
125
+ /** Signature formats to write, the WordPress .sig by default. */
126
+ formats?: SignatureFormat[];
127
+ /** Base64 signing key, from the WP_RELEASE_SIGNING_KEY environment variable by default. */
128
+ key?: string;
129
+ /** Overwrite existing signature files. */
130
+ force?: boolean;
131
+ }
132
+ export interface SignFilesResult {
133
+ /** Absolute paths of the written signature files. */
134
+ signatures: string[];
135
+ /** Base64 public key of the signing key. */
136
+ publicKey: string;
137
+ /** Public key of the signing key in the form of each signature format. */
138
+ publicKeys: Record<SignatureFormat, string>;
139
+ }
140
+ /**
141
+ * Validate the signing key and destinations before writing files. The input
142
+ * files need not exist yet, so zip can check before creating its artifacts.
143
+ *
144
+ * @param input Files, formats, key and whether to overwrite.
145
+ * @return Parsed signing key.
146
+ * @throws {ValidationError} Without a valid key or when a signature already exists without force.
147
+ */
148
+ export declare function checkSigningInput(input: SignFilesInput): KeyObject;
149
+ /**
150
+ * Sign files with a signature file of each format next to each, in order,
151
+ * so that a failure leaves a predictable set of files.
152
+ *
153
+ * @param input Files, formats, key and whether to overwrite.
154
+ * @return The written signature files and the public keys.
155
+ * @throws {ValidationError} Without a key, for an invalid key, a missing file or an existing signature file without force.
156
+ */
157
+ export declare function signFiles(input: SignFilesInput): Promise<SignFilesResult>;
122
158
  /**
123
159
  * Check a file against a signature file. A wordpress .sig file can hold one
124
160
  * signature per line, and is valid when any of them match, so that a file
package/dist/signing.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createPrivateKey, generateKeyPairSync, sign, verify, } from 'node:crypto';
2
- import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile, stat, writeFile } from 'node:fs/promises';
3
4
  import { PUBLIC_KEY_BYTES, createRawPublicKey, digestFile, getRawPublicKey, } from "./ed25519.js";
4
5
  import { ValidationError } from "./errors.js";
5
6
  import { MINISIGN_EXTENSION, createMinisignSignature, getMinisignPublicKey, parseMinisignPublicKey, verifyMinisignSignature, } from "./minisign.js";
@@ -214,6 +215,56 @@ export async function writeSignature(path, privateKey, force = false, format = D
214
215
  });
215
216
  return signaturePath;
216
217
  }
218
+ /**
219
+ * Validate the signing key and destinations before writing files. The input
220
+ * files need not exist yet, so zip can check before creating its artifacts.
221
+ *
222
+ * @param input Files, formats, key and whether to overwrite.
223
+ * @return Parsed signing key.
224
+ * @throws {ValidationError} Without a valid key or when a signature already exists without force.
225
+ */
226
+ export function checkSigningInput(input) {
227
+ const encoded = input.key ?? process.env[SIGNING_KEY_ENV];
228
+ const formats = input.formats ?? [DEFAULT_SIGNATURE_FORMAT];
229
+ if (!encoded?.trim()) {
230
+ throw new ValidationError(`Set the ${SIGNING_KEY_ENV} environment variable to the base64 Ed25519 signing key.`);
231
+ }
232
+ const privateKey = parseSigningKey(encoded);
233
+ const existing = input.paths
234
+ .flatMap((path) => formats.map((format) => getSignaturePath(path, format)))
235
+ .filter((path) => !input.force && existsSync(path));
236
+ if (existing.length) {
237
+ throw new ValidationError(`${existing.join(' and ')} already exists, pass --force to overwrite it.`);
238
+ }
239
+ return privateKey;
240
+ }
241
+ /**
242
+ * Sign files with a signature file of each format next to each, in order,
243
+ * so that a failure leaves a predictable set of files.
244
+ *
245
+ * @param input Files, formats, key and whether to overwrite.
246
+ * @return The written signature files and the public keys.
247
+ * @throws {ValidationError} Without a key, for an invalid key, a missing file or an existing signature file without force.
248
+ */
249
+ export async function signFiles(input) {
250
+ const formats = input.formats ?? [DEFAULT_SIGNATURE_FORMAT];
251
+ const privateKey = checkSigningInput(input);
252
+ await Promise.all(input.paths.map(async (path) => {
253
+ if (!(await stat(path).catch(() => null))?.isFile()) {
254
+ throw new ValidationError(`${path} is not a file.`);
255
+ }
256
+ }));
257
+ const signatures = [];
258
+ const jobs = input.paths.flatMap((path) => formats.map((format) => ({ path, format })));
259
+ for (const { path, format } of jobs) {
260
+ signatures.push(await writeSignature(path, privateKey, input.force, format));
261
+ }
262
+ return {
263
+ signatures,
264
+ publicKey: getPublicKey(privateKey),
265
+ publicKeys: getPublicKeys(privateKey),
266
+ };
267
+ }
217
268
  /**
218
269
  * Check a file against a signature file. A wordpress .sig file can hold one
219
270
  * signature per line, and is valid when any of them match, so that a file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": {