authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:00:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:00:04-07:00
log204984bf8cc4c9b85ef4d100b355c6c0caf57a21
tree2ddbed702a92f2b0a2ca8cd2a9cfd6adec5a9538
parentbe4a38e57876d95f85a8cb12497a652a575cad0c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay deploy output errors (#3340)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 163 insertions(+), 58 deletions(-)

infra/relay/scripts/deploy.test.ts+44
...@@ -6,13 +6,57 @@ import * as Path from "effect/Path";...@@ -6,13 +6,57 @@ import * as Path from "effect/Path";
66
7import {7import {
8 hasDeployChanges,8 hasDeployChanges,
9 missingRelayPublicConfigFields,
9 publicConfigFromOutput,10 publicConfigFromOutput,
10 reconcileRootEnvPublicConfig,11 reconcileRootEnvPublicConfig,
11 reconcileRootEnvRelayUrl,12 reconcileRootEnvRelayUrl,
13 RelayDeployError,
14 RelayDeployPublicConfigUnavailableError,
12 serializeGithubOutput,15 serializeGithubOutput,
13 serializeRelayClientTracingEnvironment,16 serializeRelayClientTracingEnvironment,
14} from "./deploy.ts";17} from "./deploy.ts";
1518
19describe("RelayDeployError", () => {
20 it("reports the incomplete state source, stage, and missing fields", () => {
21 const missingFields = missingRelayPublicConfigFields({
22 url: "https://relay.example.test",
23 mobileTracingUrl: "https://api.axiom.co/v1/traces",
24 });
25 const error = new RelayDeployError({
26 source: "alchemy_state",
27 stage: "production",
28 missingFields,
29 });
30
31 expect(error).toMatchObject({
32 source: "alchemy_state",
33 stage: "production",
34 missingFields: [
35 "mobileTracingDataset",
36 "mobileTracingToken",
37 "clientTracingUrl",
38 "clientTracingDataset",
39 "clientTracingToken",
40 ],
41 });
42 expect(error.message).toBe(
43 "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken",
44 );
45 });
46
47 it("distinguishes deploy results that do not produce public config", () => {
48 const error = new RelayDeployPublicConfigUnavailableError({
49 result: "dry-run",
50 stage: "production",
51 outputPath: "/tmp/relay-client.env",
52 });
53
54 expect(error.message).toBe(
55 "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.",
56 );
57 });
58});
59
16describe("hasDeployChanges", () => {60describe("hasDeployChanges", () => {
17 it("detects resource, binding, and deletion changes", () => {61 it("detects resource, binding, and deletion changes", () => {
18 expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false);62 expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false);
infra/relay/scripts/deploy.ts+119-58
...@@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices";...@@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices";
19import * as Config from "effect/Config";19import * as Config from "effect/Config";
20import * as ConfigProvider from "effect/ConfigProvider";20import * as ConfigProvider from "effect/ConfigProvider";
21import * as Console from "effect/Console";21import * as Console from "effect/Console";
22import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";23import * as FileSystem from "effect/FileSystem";
25import * as Layer from "effect/Layer";24import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";25import * as Option from "effect/Option";
27import * as Path from "effect/Path";26import * as Path from "effect/Path";
28import * as Redacted from "effect/Redacted";27import * as Redacted from "effect/Redacted";
28import * as Schema from "effect/Schema";
29import { Command, Flag, Prompt } from "effect/unstable/cli";29import { Command, Flag, Prompt } from "effect/unstable/cli";
30import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";30import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
3131
32import RelayStack from "../alchemy.run.ts";32import RelayStack from "../alchemy.run.ts";
3333
34export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{34const relayDeployOutputFields = [
35 readonly message: string;35 "url",
36}> {}36 "mobileTracingUrl",
37 "mobileTracingDataset",
38 "mobileTracingToken",
39 "clientTracingUrl",
40 "clientTracingDataset",
41 "clientTracingToken",
42] as const;
43
44export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields);
45export type RelayDeployOutputField = typeof RelayDeployOutputField.Type;
46
47export const RelayDeployResult = Schema.Literals([
48 "applied",
49 "noop",
50 "dry-run",
51 "cancelled",
52 "state",
53]);
54export type RelayDeployResult = typeof RelayDeployResult.Type;
55
56export class RelayDeployError extends Schema.TaggedErrorClass<RelayDeployError>()(
57 "RelayDeployError",
58 {
59 source: Schema.Literals(["alchemy_state", "alchemy_apply"]),
60 stage: Schema.String,
61 missingFields: Schema.Array(RelayDeployOutputField),
62 },
63) {
64 override get message(): string {
65 return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`;
66 }
67}
68
69export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedErrorClass<RelayDeployPublicConfigUnavailableError>()(
70 "RelayDeployPublicConfigUnavailableError",
71 {
72 result: RelayDeployResult,
73 stage: Schema.String,
74 outputPath: Schema.String,
75 },
76) {
77 override get message(): string {
78 return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`;
79 }
80}
3781
38export interface RelayDeployOptions {82export interface RelayDeployOptions {
39 readonly dryRun: boolean;83 readonly dryRun: boolean;
...@@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {...@@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {
112 );156 );
113}157}
114158
115export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state";
116
117export interface RelayDeployOutcome {159export interface RelayDeployOutcome {
118 readonly result: RelayDeployResult;160 readonly result: RelayDeployResult;
119 readonly changed: boolean;161 readonly changed: boolean;
...@@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*...@@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*
199const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* (241const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* (
200 outcome: RelayDeployOutcome,242 outcome: RelayDeployOutcome,
201 outputPath: string,243 outputPath: string,
244 stage: string,
202) {245) {
203 if (Option.isNone(outcome.publicConfig)) {246 if (Option.isNone(outcome.publicConfig)) {
204 return yield* new RelayDeployError({247 return yield* new RelayDeployPublicConfigUnavailableError({
205 message: "Relay public client config is unavailable for the GitHub environment file",248 result: outcome.result,
249 stage,
250 outputPath,
206 });251 });
207 }252 }
208 const fs = yield* FileSystem.FileSystem;253 const fs = yield* FileSystem.FileSystem;
...@@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll(...@@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll(
224);269);
225const deployServices = deployBaseServices;270const deployServices = deployBaseServices;
226271
227export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null {272function relayPublicConfigValues(
273 output: unknown,
274): Readonly<Record<RelayDeployOutputField, string | undefined>> {
228 if (typeof output !== "object" || output === null) {275 if (typeof output !== "object" || output === null) {
229 return null;276 return {
277 url: undefined,
278 mobileTracingUrl: undefined,
279 mobileTracingDataset: undefined,
280 mobileTracingToken: undefined,
281 clientTracingUrl: undefined,
282 clientTracingDataset: undefined,
283 clientTracingToken: undefined,
284 };
230 }285 }
231 const value = output as Record<string, unknown>;286 const value = output as Record<string, unknown>;
232 const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined);287 const text = (name: string) => {
288 const candidate = value[name];
289 return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
290 };
233 const secret = (name: string): string | undefined => {291 const secret = (name: string): string | undefined => {
234 const candidate = value[name];292 const candidate = value[name];
235 if (!Redacted.isRedacted(candidate)) {293 if (!Redacted.isRedacted(candidate)) {
236 return text(name);294 return text(name);
237 }295 }
238 const redacted = Redacted.value(candidate);296 const redacted = Redacted.value(candidate);
239 return typeof redacted === "string" ? redacted : undefined;297 return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined;
298 };
299 return {
300 url: text("url"),
301 mobileTracingUrl: text("mobileTracingUrl"),
302 mobileTracingDataset: text("mobileTracingDataset"),
303 mobileTracingToken: secret("mobileTracingToken"),
304 clientTracingUrl: text("clientTracingUrl"),
305 clientTracingDataset: text("clientTracingDataset"),
306 clientTracingToken: secret("clientTracingToken"),
307 };
308}
309
310export function missingRelayPublicConfigFields(
311 output: unknown,
312): ReadonlyArray<RelayDeployOutputField> {
313 const values = relayPublicConfigValues(output);
314 return relayDeployOutputFields.filter((field) => values[field] === undefined);
315}
316
317function hasCompleteRelayPublicConfigValues(
318 values: Readonly<Record<RelayDeployOutputField, string | undefined>>,
319): values is Readonly<Record<RelayDeployOutputField, string>> {
320 return relayDeployOutputFields.every((field) => values[field] !== undefined);
321}
322
323export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null {
324 const values = relayPublicConfigValues(output);
325 if (!hasCompleteRelayPublicConfigValues(values)) {
326 return null;
327 }
328 return {
329 relayUrl: values.url,
330 mobileTracingUrl: values.mobileTracingUrl,
331 mobileTracingDataset: values.mobileTracingDataset,
332 mobileTracingToken: values.mobileTracingToken,
333 clientTracingUrl: values.clientTracingUrl,
334 clientTracingDataset: values.clientTracingDataset,
335 clientTracingToken: values.clientTracingToken,
240 };336 };
241 const relayUrl = text("url");
242 const mobileTracingUrl = text("mobileTracingUrl");
243 const mobileTracingDataset = text("mobileTracingDataset");
244 const mobileTracingToken = secret("mobileTracingToken");
245 const clientTracingUrl = text("clientTracingUrl");
246 const clientTracingDataset = text("clientTracingDataset");
247 const clientTracingToken = secret("clientTracingToken");
248 return relayUrl &&
249 mobileTracingUrl &&
250 mobileTracingDataset &&
251 mobileTracingToken &&
252 clientTracingUrl &&
253 clientTracingDataset &&
254 clientTracingToken
255 ? {
256 relayUrl,
257 mobileTracingUrl,
258 mobileTracingDataset,
259 mobileTracingToken,
260 clientTracingUrl,
261 clientTracingDataset,
262 clientTracingToken,
263 }
264 : null;
265}337}
266338
267const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) {339const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) {
...@@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta...@@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta
271 const publicConfig = publicConfigFromOutput(output);343 const publicConfig = publicConfigFromOutput(output);
272 if (publicConfig === null) {344 if (publicConfig === null) {
273 return yield* new RelayDeployError({345 return yield* new RelayDeployError({
274 message: `Alchemy relay state for stage ${stage} did not include complete public client config`,346 source: "alchemy_state",
347 stage,
348 missingFields: missingRelayPublicConfigFields(output),
275 });349 });
276 }350 }
277 return {351 return {
...@@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
285 function* (359 function* (
286 options: RelayDeployOptions,360 options: RelayDeployOptions,
287 _configProvider: ConfigProvider.ConfigProvider,361 _configProvider: ConfigProvider.ConfigProvider,
288 _stage: string,362 stage: string,
289 ) {363 ) {
290 const stack = yield* RelayStack;364 const stack = yield* RelayStack;
291 const cli = yield* Cli;365 const cli = yield* Cli;
...@@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
318 }392 }
319 }393 }
320 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));394 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));
321 if (395 const publicConfig = publicConfigFromOutput(output);
322 output.url === undefined ||396 if (publicConfig === null) {
323 output.mobileTracingUrl === undefined ||
324 output.mobileTracingDataset === undefined ||
325 output.mobileTracingToken === undefined ||
326 output.clientTracingUrl === undefined ||
327 output.clientTracingDataset === undefined ||
328 output.clientTracingToken === undefined
329 ) {
330 return yield* new RelayDeployError({397 return yield* new RelayDeployError({
331 message: "Alchemy relay deploy output did not include complete public client config",398 source: "alchemy_apply",
399 stage,
400 missingFields: missingRelayPublicConfigFields(output),
332 });401 });
333 }402 }
334 return {403 return {
335 result: changed ? "applied" : "noop",404 result: changed ? "applied" : "noop",
336 changed,405 changed,
337 publicConfig: Option.some({406 publicConfig: Option.some(publicConfig),
338 relayUrl: output.url,
339 mobileTracingUrl: output.mobileTracingUrl,
340 mobileTracingDataset: output.mobileTracingDataset,
341 mobileTracingToken: Redacted.value(output.mobileTracingToken),
342 clientTracingUrl: output.clientTracingUrl,
343 clientTracingDataset: output.clientTracingDataset,
344 clientTracingToken: Redacted.value(output.clientTracingToken),
345 }),
346 } satisfies RelayDeployOutcome;407 } satisfies RelayDeployOutcome;
347 },408 },
348 (effect, options, configProvider, stage) =>409 (effect, options, configProvider, stage) =>
...@@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO...@@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO
379 yield* writeGithubOutput(outcome);440 yield* writeGithubOutput(outcome);
380 }441 }
381 if (Option.isSome(options.githubEnvFile)) {442 if (Option.isSome(options.githubEnvFile)) {
382 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value);443 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage);
383 }444 }
384});445});
385446