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";
66
77import {
88 hasDeployChanges,
9 missingRelayPublicConfigFields,
910 publicConfigFromOutput,
1011 reconcileRootEnvPublicConfig,
1112 reconcileRootEnvRelayUrl,
13 RelayDeployError,
14 RelayDeployPublicConfigUnavailableError,
1215 serializeGithubOutput,
1316 serializeRelayClientTracingEnvironment,
1417} 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
1660describe("hasDeployChanges", () => {
1761 it("detects resource, binding, and deletion changes", () => {
1862 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";
1919import * as Config from "effect/Config";
2020import * as ConfigProvider from "effect/ConfigProvider";
2121import * as Console from "effect/Console";
22import * as Data from "effect/Data";
2322import * as Effect from "effect/Effect";
2423import * as FileSystem from "effect/FileSystem";
2524import * as Layer from "effect/Layer";
2625import * as Option from "effect/Option";
2726import * as Path from "effect/Path";
2827import * as Redacted from "effect/Redacted";
28import * as Schema from "effect/Schema";
2929import { Command, Flag, Prompt } from "effect/unstable/cli";
3030import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
3131
3232import RelayStack from "../alchemy.run.ts";
3333
34export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{
35 readonly message: string;
36}> {}
34const relayDeployOutputFields = [
35 "url",
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
3882export interface RelayDeployOptions {
3983 readonly dryRun: boolean;
......@@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {
112156 );
113157}
114158
115export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state";
116
117159export interface RelayDeployOutcome {
118160 readonly result: RelayDeployResult;
119161 readonly changed: boolean;
......@@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*
199241const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* (
200242 outcome: RelayDeployOutcome,
201243 outputPath: string,
244 stage: string,
202245) {
203246 if (Option.isNone(outcome.publicConfig)) {
204 return yield* new RelayDeployError({
205 message: "Relay public client config is unavailable for the GitHub environment file",
247 return yield* new RelayDeployPublicConfigUnavailableError({
248 result: outcome.result,
249 stage,
250 outputPath,
206251 });
207252 }
208253 const fs = yield* FileSystem.FileSystem;
......@@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll(
224269);
225270const deployServices = deployBaseServices;
226271
227export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null {
272function relayPublicConfigValues(
273 output: unknown,
274): Readonly<Record<RelayDeployOutputField, string | undefined>> {
228275 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 };
230285 }
231286 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 };
233291 const secret = (name: string): string | undefined => {
234292 const candidate = value[name];
235293 if (!Redacted.isRedacted(candidate)) {
236294 return text(name);
237295 }
238296 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,
240336 };
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;
265337}
266338
267339const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) {
......@@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta
271343 const publicConfig = publicConfigFromOutput(output);
272344 if (publicConfig === null) {
273345 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),
275349 });
276350 }
277351 return {
......@@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
285359 function* (
286360 options: RelayDeployOptions,
287361 _configProvider: ConfigProvider.ConfigProvider,
288 _stage: string,
362 stage: string,
289363 ) {
290364 const stack = yield* RelayStack;
291365 const cli = yield* Cli;
......@@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
318392 }
319393 }
320394 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));
321 if (
322 output.url === undefined ||
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 ) {
395 const publicConfig = publicConfigFromOutput(output);
396 if (publicConfig === null) {
330397 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),
332401 });
333402 }
334403 return {
335404 result: changed ? "applied" : "noop",
336405 changed,
337 publicConfig: Option.some({
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 }),
406 publicConfig: Option.some(publicConfig),
346407 } satisfies RelayDeployOutcome;
347408 },
348409 (effect, options, configProvider, stage) =>
......@@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO
379440 yield* writeGithubOutput(outcome);
380441 }
381442 if (Option.isSome(options.githubEnvFile)) {
382 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value);
443 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage);
383444 }
384445});
385446