rastack 0.0.62 → 0.0.63

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/src/deploy/ci.ts CHANGED
@@ -16,7 +16,12 @@
16
16
  * the same plan works before and after they are wired into the config.
17
17
  */
18
18
 
19
- import { DeployConfig } from "./config";
19
+ import {
20
+ DeployConfig,
21
+ DeployOutputs,
22
+ EnvName,
23
+ resolveEnvironment,
24
+ } from "./config";
20
25
 
21
26
  export type CiPhase = "check" | "build" | "deploy";
22
27
 
@@ -55,27 +60,47 @@ export interface CiPlanOptions {
55
60
  bin?: string;
56
61
  /** Skip the build steps in `deploy` (code already built this job). */
57
62
  skipBuild?: boolean;
58
- /** Override the function name (else from cfg.outputs, else resolved live). */
63
+ /** Override the function name (else from the env's outputs, else resolved live). */
59
64
  functionName?: string;
60
- /** Override the JWKS URL (else from cfg.outputs, else resolved live). */
65
+ /** Override the JWKS URL (else from the env's outputs, else resolved live). */
61
66
  jwksUrl?: string;
67
+ /**
68
+ * Which environment (dev/prod) to build+ship. When set, the stack name,
69
+ * outputs, and app label come from that environment — so `rastack ci deploy
70
+ * --env dev` targets the dev stack (dev's Cognito JWKS, dev's function). When
71
+ * unset the legacy single-stack fields (`cfg.stackName` / `cfg.outputs`) apply.
72
+ */
73
+ env?: EnvName;
74
+ }
75
+
76
+ /** The stack name + outputs + app label the plan targets, honouring `opts.env`. */
77
+ function ciTarget(
78
+ cfg: DeployConfig,
79
+ opts: CiPlanOptions,
80
+ ): { stackName: string; outputs?: DeployOutputs; app: string } {
81
+ if (opts.env) {
82
+ const e = resolveEnvironment(cfg, opts.env);
83
+ return { stackName: e.stackName, outputs: e.outputs, app: e.appName };
84
+ }
85
+ return { stackName: cfg.stackName, outputs: cfg.outputs, app: cfg.app };
62
86
  }
63
87
 
64
88
  /**
65
- * A stack Output value: the literal from `cfg.outputs` if wired, else a shell
66
- * command substitution that resolves it live from CloudFormation. Keeping the
67
- * fallback inline (rather than a separate step) means the plan is a flat list of
68
- * self-contained commands that read correctly in any order.
89
+ * A stack Output value: the literal if wired, else a shell command substitution
90
+ * that resolves it live from CloudFormation. Keeping the fallback inline (rather
91
+ * than a separate step) means the plan is a flat list of self-contained commands
92
+ * that read correctly in any order.
69
93
  */
70
94
  function stackOutput(
71
- cfg: DeployConfig,
95
+ stackName: string,
96
+ region: string,
72
97
  outputKey: string,
73
98
  literal?: string,
74
99
  ): string {
75
100
  if (literal) return literal;
76
101
  return (
77
- `$(aws cloudformation describe-stacks --stack-name ${cfg.stackName} ` +
78
- `--region ${cfg.region} ` +
102
+ `$(aws cloudformation describe-stacks --stack-name ${stackName} ` +
103
+ `--region ${region} ` +
79
104
  `--query "Stacks[0].Outputs[?OutputKey=='${outputKey}'].OutputValue" ` +
80
105
  `--output text)`
81
106
  );
@@ -107,11 +132,13 @@ export function buildSteps(
107
132
  opts: CiPlanOptions = {},
108
133
  ): CiStep[] {
109
134
  const stage = stageDir(cfg);
135
+ const target = ciTarget(cfg, opts);
110
136
  const archFlag = cfg.lambda.arch === "arm64" ? "--arm64" : "--x86-64";
111
137
  const jwks = stackOutput(
112
- cfg,
138
+ target.stackName,
139
+ cfg.region,
113
140
  "JwksUrl",
114
- opts.jwksUrl ?? cfg.outputs?.jwksUrl,
141
+ opts.jwksUrl ?? target.outputs?.jwksUrl,
115
142
  );
116
143
  return [
117
144
  compileStep(cfg),
@@ -146,12 +173,19 @@ export function deploySteps(
146
173
  ): CiStep[] {
147
174
  const stage = stageDir(cfg);
148
175
  const zip = `${stage}/bootstrap.zip`;
176
+ const target = ciTarget(cfg, opts);
149
177
  const fn = stackOutput(
150
- cfg,
178
+ target.stackName,
179
+ cfg.region,
151
180
  "FunctionName",
152
- opts.functionName ?? cfg.outputs?.functionName,
181
+ opts.functionName ?? target.outputs?.functionName,
182
+ );
183
+ const apiUrl = stackOutput(
184
+ target.stackName,
185
+ cfg.region,
186
+ "ApiUrl",
187
+ target.outputs?.apiUrl,
153
188
  );
154
- const apiUrl = stackOutput(cfg, "ApiUrl", cfg.outputs?.apiUrl);
155
189
  const steps: CiStep[] = opts.skipBuild ? [] : buildSteps(cfg, opts);
156
190
  steps.push(
157
191
  {
@@ -167,7 +201,7 @@ export function deploySteps(
167
201
  },
168
202
  {
169
203
  name: "Report the API URL",
170
- run: `echo "Deployed ${cfg.app} → ${apiUrl}"`,
204
+ run: `echo "Deployed ${target.app} → ${apiUrl}"`,
171
205
  },
172
206
  );
173
207
  return steps;
@@ -33,6 +33,12 @@ export interface DeployOutputs {
33
33
  jwksUrl?: string;
34
34
  /** S3 Iceberg warehouse bucket — the system of record. */
35
35
  warehouseBucket?: string;
36
+ /** Cognito Hosted UI domain (scheme-less), for browser sign-in. */
37
+ userPoolDomain?: string;
38
+ /** Full Cognito Hosted UI base URL (`https://…`). */
39
+ hostedUiUrl?: string;
40
+ /** Base URL of the external-API proxy route, when the env provisions one. */
41
+ proxyUrl?: string;
36
42
  }
37
43
 
38
44
  export interface LambdaBuild {
@@ -44,6 +50,36 @@ export interface LambdaBuild {
44
50
  features: string;
45
51
  }
46
52
 
53
+ /**
54
+ * Per-environment overrides. The **prod** environment defaults its values from
55
+ * the top-level config (its branch is `github.branch`, its stack is `stackName`,
56
+ * its outputs are the top-level `outputs`); the **dev** environment defaults to
57
+ * the `development` branch and a `-dev` stack. Anything set here wins over those
58
+ * defaults. Only the fields that actually differ per environment live here —
59
+ * `app`/`namePrefix`/`region`/`github.owner`/`github.repo` are shared.
60
+ */
61
+ export interface EnvOverride {
62
+ /** GitHub branch whose push deploys this environment. */
63
+ branch?: string;
64
+ /** GitHub Actions **Environment** name (scoped Variables + protection rules). */
65
+ ghEnvironment?: string;
66
+ /** CloudFormation stack name for this environment. */
67
+ stackName?: string;
68
+ /** Iceberg namespace this environment's warehouse writes under. */
69
+ namespace?: string;
70
+ /**
71
+ * Base URL of the upstream this environment's **external-API proxy** forwards
72
+ * to (typically a UAT or prod backend). When set, the stack provisions a
73
+ * Cognito-authorized `ANY /proxy/{proxy+}` route on the HTTP API that forwards
74
+ * to `${proxyUpstream}/{proxy}` — so a locally-running app (signed in against
75
+ * this environment's Cognito pool) reaches external APIs *only* through the
76
+ * proxy, never directly, and credentials stay server-side. Empty = no proxy.
77
+ */
78
+ proxyUpstream?: string;
79
+ /** Values resolved from this environment's stack after it is created. */
80
+ outputs?: DeployOutputs;
81
+ }
82
+
47
83
  export interface DeployConfig {
48
84
  /** App slug — namespaces buckets/roles/SSM. Cognito-safe: `^[a-z0-9]{1,20}$`. */
49
85
  app: string;
@@ -51,9 +87,16 @@ export interface DeployConfig {
51
87
  namePrefix: string;
52
88
  /** AWS region the stack (and CI) target. */
53
89
  region: string;
54
- /** CloudFormation stack name — defaults to `${namePrefix}-${app}`. */
90
+ /**
91
+ * CloudFormation stack name of the **prod** environment — defaults to
92
+ * `${namePrefix}-${app}`. Dev's stack defaults to this + `-dev`.
93
+ */
55
94
  stackName: string;
56
- /** The GitHub repo whose CI is trusted to ship the Lambda code. */
95
+ /**
96
+ * The GitHub repo whose CI is trusted to ship the Lambda code. `branch` is the
97
+ * **prod** deploy branch (default `master`); the dev branch defaults to
98
+ * `development` and is overridable under `environments.dev.branch`.
99
+ */
57
100
  github: { owner: string; repo: string; branch: string };
58
101
  /** Iceberg namespace the warehouse writes under. */
59
102
  namespace: string;
@@ -69,10 +112,130 @@ export interface DeployConfig {
69
112
  artifactsBucket?: string;
70
113
  /** Whether the function verifies the `access` or `id` token. */
71
114
  tokenUse: "access" | "id";
72
- /** Values resolved from the stack after it is created (optional). */
115
+ /** Values resolved from the (prod) stack after it is created (optional). */
116
+ outputs?: DeployOutputs;
117
+ /**
118
+ * Per-environment overrides. A `dev` + `prod` split is the default even with
119
+ * this omitted (see `resolveEnvironment`); this only records what a user
120
+ * changed (a custom dev branch, dev stack outputs, …).
121
+ */
122
+ environments?: Partial<Record<EnvName, EnvOverride>>;
123
+ }
124
+
125
+ /** The two deployment environments — dev (development branch) and prod (master). */
126
+ export type EnvName = "prod" | "dev";
127
+ export const ENV_NAMES: readonly EnvName[] = ["prod", "dev"] as const;
128
+
129
+ /** The fully-resolved shape of one environment (top-level + defaults + overrides). */
130
+ export interface EnvDef {
131
+ /** Which environment this is. */
132
+ name: EnvName;
133
+ /** Branch whose push deploys it (`master` for prod, `development` for dev). */
134
+ branch: string;
135
+ /** GitHub Actions Environment name (`production` / `development`). */
136
+ ghEnvironment: string;
137
+ /**
138
+ * The app.yaml `AppName` parameter — the infra namespace. Prod uses the app
139
+ * slug; dev uses `${app}dev` so its buckets/roles/pools/SSM never collide with
140
+ * prod's (AppName is Cognito-safe `^[a-z0-9]{1,20}$`, so the suffix is glued,
141
+ * not hyphenated).
142
+ */
143
+ appName: string;
144
+ /** CloudFormation stack name for this environment. */
145
+ stackName: string;
146
+ /** Iceberg namespace this environment writes under. */
147
+ namespace: string;
148
+ /** Upstream the external-API proxy forwards to (empty when no proxy). */
149
+ proxyUpstream: string;
150
+ /** Values resolved from this environment's stack, when known. */
73
151
  outputs?: DeployOutputs;
74
152
  }
75
153
 
154
+ /** Defaults for the dev environment when `environments.dev` is unset. */
155
+ export const DEV_DEFAULTS = {
156
+ branch: "development",
157
+ ghEnvironment: "development",
158
+ namespace: "development",
159
+ } as const;
160
+
161
+ /** The GitHub Actions Environment name the prod deploy uses. */
162
+ export const PROD_GH_ENVIRONMENT = "production";
163
+
164
+ /**
165
+ * The app.yaml `AppName` for an environment — the infra namespace. Dev glues a
166
+ * `dev` suffix on so its stack's buckets/roles/pools/SSM are physically distinct
167
+ * from prod's without a hyphen (Cognito `AppName`/`IdentityPoolName` forbid one).
168
+ */
169
+ export function envAppName(cfg: DeployConfig, env: EnvName): string {
170
+ return env === "dev" ? `${cfg.app}dev` : cfg.app;
171
+ }
172
+
173
+ /**
174
+ * Resolve one environment to its full definition: the prod environment inherits
175
+ * the top-level branch/stack/namespace/outputs, the dev environment defaults to
176
+ * the `development` branch and a `-dev` stack, and anything under
177
+ * `cfg.environments[env]` overrides those.
178
+ */
179
+ export function resolveEnvironment(cfg: DeployConfig, env: EnvName): EnvDef {
180
+ const o = cfg.environments?.[env] ?? {};
181
+ if (env === "prod") {
182
+ return {
183
+ name: "prod",
184
+ branch: o.branch ?? cfg.github.branch,
185
+ ghEnvironment: o.ghEnvironment ?? PROD_GH_ENVIRONMENT,
186
+ appName: cfg.app,
187
+ stackName: o.stackName ?? cfg.stackName,
188
+ namespace: o.namespace ?? cfg.namespace,
189
+ proxyUpstream: o.proxyUpstream ?? "",
190
+ outputs: o.outputs ?? cfg.outputs,
191
+ };
192
+ }
193
+ return {
194
+ name: "dev",
195
+ branch: o.branch ?? DEV_DEFAULTS.branch,
196
+ ghEnvironment: o.ghEnvironment ?? DEV_DEFAULTS.ghEnvironment,
197
+ appName: envAppName(cfg, "dev"),
198
+ stackName: o.stackName ?? (cfg.stackName ? `${cfg.stackName}-dev` : ""),
199
+ namespace: o.namespace ?? DEV_DEFAULTS.namespace,
200
+ proxyUpstream: o.proxyUpstream ?? "",
201
+ outputs: o.outputs,
202
+ };
203
+ }
204
+
205
+ /** Both environments, prod first, fully resolved. */
206
+ export function resolveEnvironments(cfg: DeployConfig): EnvDef[] {
207
+ return ENV_NAMES.map((e) => resolveEnvironment(cfg, e));
208
+ }
209
+
210
+ /**
211
+ * The browser-facing Cognito config for one environment — what `rastack dev`
212
+ * serves at `/__rastack/cognito.json` so a locally-running app can sign in
213
+ * against the real (dev) Cognito pool via the Hosted UI. Shaped to feed
214
+ * `cognitoRuntimeToConfig` in `rastack/auth`. Returns null until the
215
+ * environment's stack Outputs (client id + Hosted UI url) are known.
216
+ */
217
+ export interface CognitoRuntimeConfig {
218
+ userPoolId?: string;
219
+ clientId: string;
220
+ /** Hosted UI base URL (`https://…amazoncognito.com`). */
221
+ hostedUiUrl: string;
222
+ region: string;
223
+ }
224
+
225
+ export function cognitoRuntimeConfig(
226
+ cfg: DeployConfig,
227
+ env: EnvName,
228
+ ): CognitoRuntimeConfig | null {
229
+ const o = resolveEnvironment(cfg, env).outputs;
230
+ if (!o?.userPoolClientId || !o?.hostedUiUrl) return null;
231
+ return {
232
+ userPoolId: o.userPoolId,
233
+ clientId: o.userPoolClientId,
234
+ hostedUiUrl: o.hostedUiUrl,
235
+ region: cfg.region,
236
+ };
237
+ }
238
+
76
239
  export const DEFAULT_LAMBDA: LambdaBuild = {
77
240
  bin: "bootstrap",
78
241
  arch: "arm64",
@@ -132,9 +295,31 @@ export function normalizeConfig(
132
295
  artifactsBucket: partial.artifactsBucket?.trim() || undefined,
133
296
  tokenUse: partial.tokenUse ?? DEFAULTS.tokenUse,
134
297
  outputs: partial.outputs ? { ...partial.outputs } : undefined,
298
+ environments: normalizeEnvironments(partial.environments),
135
299
  };
136
300
  }
137
301
 
302
+ /** Copy through per-environment overrides, dropping an empty map to undefined. */
303
+ function normalizeEnvironments(
304
+ envs: PartialDeployConfig["environments"],
305
+ ): DeployConfig["environments"] {
306
+ if (!envs) return undefined;
307
+ const out: Partial<Record<EnvName, EnvOverride>> = {};
308
+ for (const name of ENV_NAMES) {
309
+ const o = envs[name];
310
+ if (!o) continue;
311
+ const entry: EnvOverride = {};
312
+ if (o.branch?.trim()) entry.branch = o.branch.trim();
313
+ if (o.ghEnvironment?.trim()) entry.ghEnvironment = o.ghEnvironment.trim();
314
+ if (o.stackName?.trim()) entry.stackName = o.stackName.trim();
315
+ if (o.namespace?.trim()) entry.namespace = o.namespace.trim();
316
+ if (o.proxyUpstream?.trim()) entry.proxyUpstream = o.proxyUpstream.trim();
317
+ if (o.outputs) entry.outputs = { ...o.outputs };
318
+ if (Object.keys(entry).length) out[name] = entry;
319
+ }
320
+ return Object.keys(out).length ? out : undefined;
321
+ }
322
+
138
323
  const SLUG = /^[a-z0-9]{1,20}$/;
139
324
  const REGION = /^[a-z]{2}-[a-z]+-\d+$/;
140
325
  const GH_NAME = /^[A-Za-z0-9_.-]+$/;
@@ -152,6 +337,14 @@ export function validateConfig(cfg: DeployConfig): string[] {
152
337
  errors.push(
153
338
  `app "${cfg.app}" must match ${SLUG} (lowercase letters/digits, 1–20).`,
154
339
  );
340
+ // The dev environment appends "dev" to the app slug for its infra namespace
341
+ // (buckets/roles/pools/SSM). That derived name must still be a valid slug, so
342
+ // the app slug is effectively capped at 17 chars once a dev env is in play.
343
+ else if (!SLUG.test(envAppName(cfg, "dev")))
344
+ errors.push(
345
+ `app "${cfg.app}" is too long: the dev environment uses "${envAppName(cfg, "dev")}" ` +
346
+ `for its infra names, which must still match ${SLUG} (so app ≤ 17 chars).`,
347
+ );
155
348
 
156
349
  if (!SLUG.test(cfg.namePrefix))
157
350
  errors.push(`namePrefix "${cfg.namePrefix}" must match ${SLUG}.`);
@@ -226,9 +419,31 @@ export function mergeConfig(
226
419
  github: { ...base.github, ...patch.github },
227
420
  lambda: { ...base.lambda, ...patch.lambda },
228
421
  outputs: { ...base.outputs, ...patch.outputs },
422
+ environments: mergeEnvironments(base.environments, patch.environments),
229
423
  });
230
424
  }
231
425
 
426
+ /** Deep-merge per-environment overrides (per env, and per env's outputs). */
427
+ function mergeEnvironments(
428
+ base: DeployConfig["environments"],
429
+ patch: PartialDeployConfig["environments"],
430
+ ): DeployConfig["environments"] {
431
+ if (!base && !patch) return undefined;
432
+ const out: Partial<Record<EnvName, EnvOverride>> = {};
433
+ for (const name of ENV_NAMES) {
434
+ const b = base?.[name];
435
+ const p = patch?.[name];
436
+ if (!b && !p) continue;
437
+ out[name] = {
438
+ ...b,
439
+ ...p,
440
+ outputs:
441
+ b?.outputs || p?.outputs ? { ...b?.outputs, ...p?.outputs } : undefined,
442
+ };
443
+ }
444
+ return Object.keys(out).length ? out : undefined;
445
+ }
446
+
232
447
  /**
233
448
  * Recover `{ owner, repo }` from a GitHub remote URL — both the SSH
234
449
  * (`git@github.com:owner/repo.git`) and HTTPS
@@ -314,6 +529,40 @@ export function stackParameters(cfg: DeployConfig): StackParameter[] {
314
529
  return params;
315
530
  }
316
531
 
532
+ /**
533
+ * The app.yaml `--parameter-overrides` for a **specific environment** (dev or
534
+ * prod). Same as `stackParameters` but the AppName/branch/namespace come from the
535
+ * resolved environment, and it also passes `GitHubEnvironment` so the stack's
536
+ * deploy-role trust is scoped to the GitHub Actions Environment (the OIDC subject
537
+ * for an environment-gated job is `…:environment:<env>`, not `…:ref:…`). Prod's
538
+ * AppName is the bare slug, so prod's resource names are byte-for-byte what the
539
+ * pre-environment template produced.
540
+ */
541
+ export function stackParametersForEnv(
542
+ cfg: DeployConfig,
543
+ env: EnvName,
544
+ ): StackParameter[] {
545
+ const e = resolveEnvironment(cfg, env);
546
+ const params: StackParameter[] = [
547
+ { key: "AppName", value: e.appName },
548
+ { key: "NamePrefix", value: cfg.namePrefix },
549
+ { key: "GitHubOwner", value: cfg.github.owner },
550
+ { key: "GitHubRepo", value: cfg.github.repo },
551
+ { key: "GitHubBranch", value: e.branch },
552
+ { key: "GitHubEnvironment", value: e.ghEnvironment },
553
+ { key: "IcebergNamespace", value: e.namespace },
554
+ ];
555
+ if (cfg.artifactsBucket)
556
+ params.push({ key: "ArtifactsBucket", value: cfg.artifactsBucket });
557
+ // The external-API proxy is provisioned only when an upstream is configured
558
+ // (typically dev → a UAT/prod backend). Empty leaves the route/authorizer out.
559
+ if (e.proxyUpstream)
560
+ params.push({ key: "ExternalApiUpstream", value: e.proxyUpstream });
561
+ if (!cognitoAdvancedSecuritySupported(cfg.region))
562
+ params.push({ key: "AdvancedSecurity", value: "OFF" });
563
+ return params;
564
+ }
565
+
317
566
  /**
318
567
  * The canonical repo-relative CloudFormation template paths. These are the paths
319
568
  * in the *framework* repo (theserverkid/reactapistack) and the default the argv
@@ -399,6 +648,29 @@ export function deployStackCommand(
399
648
  return awsCommand(deployStackArgs(cfg, templateFile));
400
649
  }
401
650
 
651
+ /** The `aws cloudformation deploy` argv for one environment's stack (dev/prod). */
652
+ export function deployStackArgsForEnv(
653
+ cfg: DeployConfig,
654
+ env: EnvName,
655
+ templateFile: string = APP_TEMPLATE_FILE,
656
+ ): string[] {
657
+ return cfnDeployArgs(
658
+ templateFile,
659
+ resolveEnvironment(cfg, env).stackName,
660
+ cfg.region,
661
+ stackParametersForEnv(cfg, env),
662
+ );
663
+ }
664
+
665
+ /** The single-line `aws cloudformation deploy` command for one environment. */
666
+ export function deployStackCommandForEnv(
667
+ cfg: DeployConfig,
668
+ env: EnvName,
669
+ templateFile: string = APP_TEMPLATE_FILE,
670
+ ): string {
671
+ return awsCommand(deployStackArgsForEnv(cfg, env, templateFile));
672
+ }
673
+
402
674
  /**
403
675
  * The tier-0 bootstrap stack (bootstrap.yaml) — the S3 buckets that hold the
404
676
  * Lambda placeholder + hosted templates, and the GitHub OIDC provider. One per
@@ -470,6 +742,9 @@ export const APP_STACK_OUTPUTS: { key: string; field: keyof DeployOutputs }[] =
470
742
  { key: "UserPoolClientId", field: "userPoolClientId" },
471
743
  { key: "JwksUrl", field: "jwksUrl" },
472
744
  { key: "WarehouseBucketName", field: "warehouseBucket" },
745
+ { key: "UserPoolDomain", field: "userPoolDomain" },
746
+ { key: "HostedUiUrl", field: "hostedUiUrl" },
747
+ { key: "ProxyUrl", field: "proxyUrl" },
473
748
  ];
474
749
 
475
750
  /** GitHub repo Variable to wire (name + resolved value, if known from outputs). */
@@ -477,13 +752,20 @@ export interface RepoVariable {
477
752
  name: string;
478
753
  value: string;
479
754
  from: string;
755
+ /**
756
+ * The GitHub Actions **Environment** this Variable is scoped to (`development`
757
+ * / `production`). When set, `init` wires it under that environment so the
758
+ * dev and prod deploys each read their own role/region.
759
+ */
760
+ ghEnvironment?: string;
480
761
  }
481
762
 
482
763
  /**
483
764
  * The GitHub repo **Variables** CI reads — `AWS_DEPLOY_ROLE_ARN` and
484
765
  * `AWS_REGION` (see docs/serverless-deploy.md §3). Values come from the stack
485
766
  * Outputs when already resolved, else an empty string to fill in from the
486
- * Console.
767
+ * Console. This is the legacy single-environment view (the prod stack); the
768
+ * dev/prod split wires them per GitHub Environment via `repoVariablesForEnv`.
487
769
  */
488
770
  export function repoVariables(cfg: DeployConfig): RepoVariable[] {
489
771
  return [
@@ -496,6 +778,33 @@ export function repoVariables(cfg: DeployConfig): RepoVariable[] {
496
778
  ];
497
779
  }
498
780
 
781
+ /**
782
+ * The same two Variables, scoped to one environment's GitHub Actions
783
+ * Environment. The dev deploy reads `development`'s copies, the prod deploy
784
+ * `production`'s — so the two never cross-assume each other's role. Values come
785
+ * from the environment's own stack Outputs.
786
+ */
787
+ export function repoVariablesForEnv(
788
+ cfg: DeployConfig,
789
+ env: EnvName,
790
+ ): RepoVariable[] {
791
+ const e = resolveEnvironment(cfg, env);
792
+ return [
793
+ {
794
+ name: "AWS_DEPLOY_ROLE_ARN",
795
+ value: e.outputs?.deployRoleArn ?? "",
796
+ from: "DeployRoleArn stack output",
797
+ ghEnvironment: e.ghEnvironment,
798
+ },
799
+ {
800
+ name: "AWS_REGION",
801
+ value: cfg.region,
802
+ from: "Region stack output",
803
+ ghEnvironment: e.ghEnvironment,
804
+ },
805
+ ];
806
+ }
807
+
499
808
  /**
500
809
  * The argv (after `gh`) that sets one GitHub repo **Variable** on the config's
501
810
  * repo. `init` runs this for each resolved `repoVariables` entry so the
@@ -509,15 +818,39 @@ export function ghVariableSetArgs(
509
818
  cfg: DeployConfig,
510
819
  name: string,
511
820
  value: string,
821
+ ghEnvironment?: string,
512
822
  ): string[] {
513
- return [
823
+ const args = [
514
824
  "variable",
515
825
  "set",
516
826
  name,
517
827
  "--repo",
518
828
  `${cfg.github.owner}/${cfg.github.repo}`,
519
- "--body",
520
- value,
829
+ ];
830
+ // Scope the Variable to a GitHub Actions Environment when one is given, so the
831
+ // dev and prod deploys each read their own role/region.
832
+ if (ghEnvironment) args.push("--env", ghEnvironment);
833
+ args.push("--body", value);
834
+ return args;
835
+ }
836
+
837
+ /**
838
+ * The argv (after `gh`) that creates (idempotently) a GitHub Actions
839
+ * **Environment** on the config's repo — `gh api --method PUT
840
+ * repos/{owner}/{repo}/environments/{env}`. `init` runs this for `development`
841
+ * and `production` before wiring their scoped Variables, since a Variable can
842
+ * only be set on an environment that already exists. PUT is idempotent, so
843
+ * re-running `init` is safe.
844
+ */
845
+ export function ghEnvironmentCreateArgs(
846
+ cfg: DeployConfig,
847
+ ghEnvironment: string,
848
+ ): string[] {
849
+ return [
850
+ "api",
851
+ "--method",
852
+ "PUT",
853
+ `repos/${cfg.github.owner}/${cfg.github.repo}/environments/${ghEnvironment}`,
521
854
  ];
522
855
  }
523
856
 
package/src/deploy/io.ts CHANGED
@@ -236,7 +236,23 @@ export function ghAuthLogin(cwd = process.cwd()): boolean {
236
236
  * `execFileSync` (no shell) so the ARN/region values are never interpolated
237
237
  * into a command line.
238
238
  */
239
- export function setRepoVariable(
239
+ export function setRepoVariable(args: string[], cwd = process.cwd()): boolean {
240
+ try {
241
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
242
+ execFileSync("gh", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Create (idempotently, via `gh api --method PUT`) a GitHub Actions Environment
251
+ * so its scoped Variables can be set. Returns true on success. Best-effort — a
252
+ * missing/unauthenticated `gh` or an API error degrades to the printed
253
+ * instructions, exactly like `setRepoVariable`.
254
+ */
255
+ export function ghEnvironmentCreate(
240
256
  args: string[],
241
257
  cwd = process.cwd(),
242
258
  ): boolean {