Skip to content

Commit 64ca5d7

Browse files
committed
fix: improve validation error messages using zod
- replace `microstruct` with `zod` - downgrade `aggregate-error` for `semantic-release` v19 or earlier
1 parent 4d1530d commit 64ca5d7

8 files changed

+100
-109
lines changed

package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
},
99
"dependencies": {
1010
"@semantic-release/error": "^3.0.0",
11-
"aggregate-error": "^4.0.1",
11+
"aggregate-error": "^3.1.0",
1212
"axios": "^1.2.2",
1313
"execa": "^6.1.0",
1414
"form-data": "^4.0.0",
1515
"jsonwebtoken": "^9.0.0",
1616
"lodash.template": "^4.5.0",
1717
"marked": "^4.2.5",
18-
"microstruct": "^2.0.4",
19-
"zip-dir": "^2.0.0"
18+
"zip-dir": "^2.0.0",
19+
"zod": "^3.20.2",
20+
"zod-validation-error": "^0.3.0"
2021
},
2122
"devDependencies": {
2223
"@types/jsonwebtoken": "^9.0.1",

src/common.ts

+27-16
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
import SemanticReleaseError from '@semantic-release/error';
22
import template from 'lodash.template';
3-
import * as S from 'microstruct';
43
import type { Context } from 'semantic-release';
4+
import { z } from 'zod';
55

6-
export function createError(message: string): SemanticReleaseError {
7-
return new SemanticReleaseError(message, '\u{1f98a}');
6+
export function createError(message: string, details?: string): SemanticReleaseError {
7+
return new SemanticReleaseError(message, '\u{1f98a}', details);
88
}
99

10-
export const pluginConfigStruct = S.type({
11-
addonId: S.string(),
12-
addonDirPath: S.string(),
13-
addonZipPath: S.optional(S.string()),
14-
channel: S.optional(S.enums(['unlisted', 'listed'] as const)),
15-
approvalNotes: S.optional(S.nullable(S.string())),
16-
compatibility: S.optional(S.array(S.enums(['android', 'firefox'] as const))),
17-
submitReleaseNotes: S.optional(S.boolean()),
18-
submitSource: S.optional(S.boolean()),
19-
sourceZipPath: S.optional(S.string()),
10+
export const pluginConfigSchema = z.object({
11+
addonId: z.string(),
12+
addonDirPath: z.string(),
13+
addonZipPath: z.string().optional(),
14+
channel: z.enum(['unlisted', 'listed']).optional(),
15+
approvalNotes: z.string().min(1).nullable().optional(),
16+
compatibility: z.enum(['android', 'firefox']).array().min(1).optional(),
17+
submitReleaseNotes: z.boolean().optional(),
18+
submitSource: z.boolean().optional(),
19+
sourceZipPath: z.string().optional(),
2020
});
2121

22-
export type PluginConfig = S.Infer<typeof pluginConfigStruct>;
22+
export type PluginConfig = NoUndefined<z.infer<typeof pluginConfigSchema>>;
2323

2424
export function applyDefaults(pluginConfig: Readonly<PluginConfig>): Required<PluginConfig> {
2525
return {
@@ -34,12 +34,23 @@ export function applyDefaults(pluginConfig: Readonly<PluginConfig>): Required<Pl
3434
};
3535
}
3636

37-
// For `prepare` and `publish` steps
38-
export type FullContext = { [K in keyof Context]-?: Exclude<Context[K], undefined> };
37+
export const envSchema = z.object({
38+
AMO_API_KEY: z.string(),
39+
AMO_API_SECRET: z.string(),
40+
AMO_BASE_URL: z.string().url().optional(),
41+
});
42+
43+
export type Env = NoUndefined<z.infer<typeof envSchema>>;
44+
45+
// For the `prepare` and `publish` steps
46+
export type FullContext = Required<NoUndefined<Context>> & { env: Env };
3947

4048
export function applyContext(
4149
temp: string,
4250
{ branch, lastRelease, nextRelease, commits }: Readonly<FullContext>,
4351
): string {
4452
return template(temp)({ branch, lastRelease, nextRelease, commits });
4553
}
54+
55+
// "exactOptionalPropertyTypes": true
56+
type NoUndefined<T> = { [K in keyof T]: Exclude<T[K], undefined> };

src/external.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
declare module '@semantic-release/error' {
22
class SemanticReleaseError extends Error {
3-
constructor(message?: string, code?: string);
3+
constructor(message?: string, code?: string, details?: string);
44
}
55
export = SemanticReleaseError;
66
}

src/prepare.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
3-
import * as S from 'microstruct';
43
import zipDir from 'zip-dir';
4+
import { z } from 'zod';
55
import { FullContext, PluginConfig, applyContext, applyDefaults, createError } from './common';
66

77
export async function prepare(
@@ -20,9 +20,11 @@ export async function prepare(
2020

2121
logger.log('Updating manifest.json...');
2222
const manifestJsonPath = path.join(addonDirPath, 'manifest.json');
23-
const mainfestJson = await fs.readFile(manifestJsonPath, 'utf8');
24-
const manifest = S.parse(mainfestJson, S.type({ version: S.optional(S.string()) }));
25-
if (!manifest) {
23+
const manifestJson = await fs.readFile(manifestJsonPath, 'utf8');
24+
let manifest;
25+
try {
26+
manifest = z.record(z.string(), z.unknown()).parse(JSON.parse(manifestJson));
27+
} catch {
2628
throw createError(`Invalid manifest.json: ${manifestJsonPath}`);
2729
}
2830
manifest.version = nextRelease.version;

src/publish.ts

+2-7
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,14 @@ export async function publish(
3030
const { env, logger, nextRelease } = context;
3131
const baseURL = env.AMO_BASE_URL ?? 'https://addons.mozilla.org/';
3232

33-
if (approvalNotes === '') {
34-
logger.warn('Approval notes are empty. Skipping submission of approval notes.');
35-
}
3633
if (submitReleaseNotes && !nextRelease.notes) {
3734
logger.warn('Release notes are empty. Skipping submission of release notes.');
3835
}
3936

4037
try {
4138
await updateAddon({
42-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
43-
apiKey: env.AMO_API_KEY!,
44-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
45-
apiSecret: env.AMO_API_SECRET!,
39+
apiKey: env.AMO_API_KEY,
40+
apiSecret: env.AMO_API_SECRET,
4641
baseURL,
4742
addonId,
4843
addonZipPath,

src/update-addon.ts

+20-19
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs';
22
import axios, { AxiosResponse } from 'axios';
33
import FormData from 'form-data';
44
import jwt from 'jsonwebtoken';
5-
import * as S from 'microstruct';
5+
import { z } from 'zod';
66

77
export class UpdateAddonError extends Error {
88
constructor(message: string) {
@@ -89,12 +89,12 @@ function throwBadResponse(path: string, response: AxiosResponse): never {
8989

9090
type APIParams = Pick<UpdateAddonParams, 'apiKey' | 'apiSecret' | 'baseURL'>;
9191

92-
async function apiFetch<T>(
92+
async function apiFetch<T, Schema extends z.ZodType<T>>(
9393
{ apiKey, apiSecret, baseURL }: Readonly<APIParams>,
9494
method: string,
9595
path: string,
9696
body: Readonly<Record<string, unknown>> | FormData | null,
97-
validator: S.Struct<T>,
97+
schema: Schema,
9898
): Promise<T> {
9999
try {
100100
const response = await axios({
@@ -105,10 +105,11 @@ async function apiFetch<T>(
105105
},
106106
data: body,
107107
});
108-
if (!S.is(response.data, validator)) {
108+
const result = schema.safeParse(response.data);
109+
if (!result.success) {
109110
throwBadResponse(path, response);
110111
}
111-
return response.data;
112+
return result.data;
112113
} catch (error: unknown) {
113114
if (axios.isAxiosError(error) && error.response) {
114115
throwBadResponse(path, error.response);
@@ -118,14 +119,14 @@ async function apiFetch<T>(
118119
}
119120
}
120121

121-
const uploadStruct = S.type({
122-
uuid: S.string(),
123-
processed: S.boolean(),
124-
valid: S.boolean(),
125-
validation: S.nullable(S.record(S.string(), S.unknown())),
122+
const uploadSchema = z.object({
123+
uuid: z.string(),
124+
processed: z.boolean(),
125+
valid: z.boolean(),
126+
validation: z.record(z.string(), z.unknown()).nullable(),
126127
});
127128

128-
type Upload = S.Infer<typeof uploadStruct>;
129+
type Upload = z.infer<typeof uploadSchema>;
129130

130131
function createUpload(
131132
apiParams: Readonly<APIParams>,
@@ -134,16 +135,16 @@ function createUpload(
134135
const formData = new FormData();
135136
formData.append('upload', upload);
136137
formData.append('channel', channel);
137-
return apiFetch(apiParams, 'POST', 'upload/', formData, uploadStruct);
138+
return apiFetch(apiParams, 'POST', 'upload/', formData, uploadSchema);
138139
}
139140

140141
function getUpload(apiParams: Readonly<APIParams>, uuid: string): Promise<Upload> {
141-
return apiFetch(apiParams, 'GET', `upload/${uuid}/`, null, uploadStruct);
142+
return apiFetch(apiParams, 'GET', `upload/${uuid}/`, null, uploadSchema);
142143
}
143144

144-
const versionStruct = S.type({ id: S.number() });
145+
const versionSchema = z.object({ id: z.number() });
145146

146-
type Version = S.Infer<typeof versionStruct>;
147+
type Version = z.infer<typeof versionSchema>;
147148

148149
function createVersion(
149150
apiParams: Readonly<APIParams>,
@@ -155,7 +156,7 @@ function createVersion(
155156
release_notes?: Readonly<Record<string, string>>;
156157
}>,
157158
): Promise<Version> {
158-
return apiFetch(apiParams, 'POST', `addon/${addonId}/versions/`, body, versionStruct);
159+
return apiFetch(apiParams, 'POST', `addon/${addonId}/versions/`, body, versionSchema);
159160
}
160161

161162
function patchVersion(
@@ -166,7 +167,7 @@ function patchVersion(
166167
): Promise<Version> {
167168
const formData = new FormData();
168169
formData.append('source', source);
169-
return apiFetch(apiParams, 'PATCH', `addon/${addonId}/versions/${id}/`, formData, versionStruct);
170+
return apiFetch(apiParams, 'PATCH', `addon/${addonId}/versions/${id}/`, formData, versionSchema);
170171
}
171172

172173
function waitForValidation(apiParams: Readonly<APIParams>, uuid: string): Promise<void> {
@@ -176,7 +177,7 @@ function waitForValidation(apiParams: Readonly<APIParams>, uuid: string): Promis
176177
if (intervalId) {
177178
clearTimeout(intervalId);
178179
}
179-
reject(new UpdateAddonError('Validation Timeout'));
180+
reject(new UpdateAddonError('Validation timeout'));
180181
}, 300000); // 5 minutes
181182
const poll = () =>
182183
void getUpload(apiParams, uuid)
@@ -186,7 +187,7 @@ function waitForValidation(apiParams: Readonly<APIParams>, uuid: string): Promis
186187
if (valid) {
187188
resolve();
188189
} else {
189-
reject(new UpdateAddonError(`Validation Error: ${JSON.stringify(validation)}`));
190+
reject(new UpdateAddonError(`Validation error: ${JSON.stringify(validation)}`));
190191
}
191192
} else {
192193
intervalId = setTimeout(poll, 1000); // 1 second

src/verify-conditions.ts

+20-14
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
3-
import * as S from 'microstruct';
43
import type { Context } from 'semantic-release';
5-
import { createError, pluginConfigStruct } from './common';
4+
import { fromZodError } from 'zod-validation-error';
5+
import { createError, envSchema, pluginConfigSchema } from './common';
66

77
async function exists(path: string): Promise<boolean> {
88
try {
@@ -25,22 +25,28 @@ export async function verifyConditions(
2525

2626
const errors = [];
2727

28-
if (!S.is(pluginConfig, pluginConfigStruct)) {
29-
errors.push(createError(`Invalid plugin config: ${JSON.stringify(pluginConfig)}`));
30-
} else if (!(await exists(pluginConfig.addonDirPath))) {
31-
errors.push(createError(`Missing add-on directory: ${pluginConfig.addonDirPath}`));
28+
const pluginConfigResult = pluginConfigSchema.safeParse(pluginConfig);
29+
if (!pluginConfigResult.success) {
30+
errors.push(
31+
createError('Invalid plugin config', fromZodError(pluginConfigResult.error).message),
32+
);
3233
} else {
33-
const mainfestJsonPath = path.join(pluginConfig.addonDirPath, 'manifest.json');
34-
if (!(await exists(mainfestJsonPath))) {
35-
errors.push(createError(`Missing manifest.json: ${mainfestJsonPath}`));
34+
const { addonDirPath } = pluginConfigResult.data;
35+
if (!(await exists(addonDirPath))) {
36+
errors.push(createError(`Missing add-on directory: ${addonDirPath}`));
37+
} else {
38+
const manifestJsonPath = path.join(addonDirPath, 'manifest.json');
39+
if (!(await exists(manifestJsonPath))) {
40+
errors.push(createError(`Missing manifest.json: ${manifestJsonPath}`));
41+
}
3642
}
3743
}
3844

39-
if (env.AMO_API_KEY == null) {
40-
errors.push(createError('Missing environment variable: AMO_API_KEY'));
41-
}
42-
if (env.AMO_API_SECRET == null) {
43-
errors.push(createError('Missing environment variable: AMO_API_SECRET'));
45+
const envResult = envSchema.safeParse(env);
46+
if (!envResult.success) {
47+
errors.push(
48+
createError('Invalid environment variables', fromZodError(envResult.error).message),
49+
);
4450
}
4551

4652
if (errors.length) {

0 commit comments

Comments
 (0)