| ... | ... | @@ -39,6 +39,7 @@ export interface RelayDeployOptions { |
| 39 | 39 | readonly stage: Option.Option<string>; |
| 40 | 40 | readonly yes: boolean; |
| 41 | 41 | readonly adopt: boolean; |
| 42 | readonly githubOutput: boolean; |
| 42 | 43 | } |
| 43 | 44 | |
| 44 | 45 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { |
| ... | ... | @@ -62,6 +63,20 @@ export function hasDeployChanges(plan: Plan.Plan): boolean { |
| 62 | 63 | ); |
| 63 | 64 | } |
| 64 | 65 | |
| 66 | export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled"; |
| 67 | |
| 68 | export interface RelayDeployOutcome { |
| 69 | readonly result: RelayDeployResult; |
| 70 | readonly changed: boolean; |
| 71 | readonly relayUrl: Option.Option<string>; |
| 72 | } |
| 73 | |
| 74 | export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string { |
| 75 | return Object.entries(entries) |
| 76 | .map(([key, value]) => `${key}=${value}\n`) |
| 77 | .join(""); |
| 78 | } |
| 79 | |
| 65 | 80 | const relayRoot = Effect.service(Path.Path).pipe( |
| 66 | 81 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 67 | 82 | ); |
| ... | ... | @@ -102,6 +117,22 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (r |
| 102 | 117 | yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); |
| 103 | 118 | }); |
| 104 | 119 | |
| 120 | const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( |
| 121 | outcome: RelayDeployOutcome, |
| 122 | ) { |
| 123 | const fs = yield* FileSystem.FileSystem; |
| 124 | const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); |
| 125 | yield* fs.writeFileString( |
| 126 | githubOutputPath, |
| 127 | serializeGithubOutput({ |
| 128 | changed: outcome.changed, |
| 129 | result: outcome.result, |
| 130 | ...(Option.isSome(outcome.relayUrl) ? { relay_url: outcome.relayUrl.value } : {}), |
| 131 | }), |
| 132 | { flag: "a" }, |
| 133 | ); |
| 134 | }); |
| 135 | |
| 105 | 136 | const deployServices = Layer.mergeAll( |
| 106 | 137 | Layer.provideMerge(AlchemyContextLive, PlatformServices), |
| 107 | 138 | Layer.provide(ProfileLive, PlatformServices), |
| ... | ... | @@ -123,11 +154,16 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 123 | 154 | const plan = yield* Plan.make(stack, { force: options.force }).pipe( |
| 124 | 155 | Effect.provide(stack.services), |
| 125 | 156 | ); |
| 157 | const changed = hasDeployChanges(plan); |
| 126 | 158 | if (options.dryRun) { |
| 127 | 159 | yield* cli.displayPlan(plan); |
| 128 | | return Option.none<string>(); |
| 160 | return { |
| 161 | result: "dry-run", |
| 162 | changed, |
| 163 | relayUrl: Option.none<string>(), |
| 164 | } satisfies RelayDeployOutcome; |
| 129 | 165 | } |
| 130 | | if (!options.yes && hasDeployChanges(plan)) { |
| 166 | if (!options.yes && changed) { |
| 131 | 167 | yield* cli.displayPlan(plan); |
| 132 | 168 | const approved = yield* Prompt.run( |
| 133 | 169 | Prompt.confirm({ |
| ... | ... | @@ -136,7 +172,11 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 136 | 172 | ); |
| 137 | 173 | if (!approved) { |
| 138 | 174 | yield* Console.log("Deployment cancelled."); |
| 139 | | return Option.none<string>(); |
| 175 | return { |
| 176 | result: "cancelled", |
| 177 | changed, |
| 178 | relayUrl: Option.none<string>(), |
| 179 | } satisfies RelayDeployOutcome; |
| 140 | 180 | } |
| 141 | 181 | } |
| 142 | 182 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); |
| ... | ... | @@ -145,7 +185,11 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 145 | 185 | message: "Alchemy relay deploy output did not include a URL", |
| 146 | 186 | }); |
| 147 | 187 | } |
| 148 | | return Option.some(output.url); |
| 188 | return { |
| 189 | result: changed ? "applied" : "noop", |
| 190 | changed, |
| 191 | relayUrl: Option.some(output.url), |
| 192 | } satisfies RelayDeployOutcome; |
| 149 | 193 | }, |
| 150 | 194 | (effect, options, configProvider, stage) => |
| 151 | 195 | effect.pipe( |
| ... | ... | @@ -171,9 +215,12 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO |
| 171 | 215 | Effect.provide(ConfigProvider.layer(configProvider)), |
| 172 | 216 | ); |
| 173 | 217 | const stage = Option.getOrElse(options.stage, () => configuredStage); |
| 174 | | const relayUrl = yield* runRelayDeploy(options, configProvider, stage); |
| 175 | | if (Option.isSome(relayUrl)) { |
| 176 | | yield* reconcileRootEnv(relayUrl.value); |
| 218 | const outcome = yield* runRelayDeploy(options, configProvider, stage); |
| 219 | if (Option.isSome(outcome.relayUrl)) { |
| 220 | yield* reconcileRootEnv(outcome.relayUrl.value); |
| 221 | } |
| 222 | if (options.githubOutput) { |
| 223 | yield* writeGithubOutput(outcome); |
| 177 | 224 | } |
| 178 | 225 | }); |
| 179 | 226 | |
| ... | ... | @@ -206,6 +253,10 @@ export const relayDeployCommand = Command.make( |
| 206 | 253 | Flag.withDescription("Adopt pre-existing cloud resources that conflict with this stack."), |
| 207 | 254 | Flag.withDefault(false), |
| 208 | 255 | ), |
| 256 | githubOutput: Flag.boolean("github-output").pipe( |
| 257 | Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."), |
| 258 | Flag.withDefault(false), |
| 259 | ), |
| 209 | 260 | }, |
| 210 | 261 | deploy, |
| 211 | 262 | ).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); |