@marcusrbrown/infra 0.12.0 → 0.12.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -11,7 +11,6 @@ const envKeys = [
11
11
  'SSH_AUTH_SOCK',
12
12
  'DASHBOARD_DOMAIN',
13
13
  'DASHBOARD_SSH_KEY',
14
- 'DASHBOARD_IMAGE_DIGEST',
15
14
  'DASHBOARD_GITHUB_APP_ID',
16
15
  'DASHBOARD_GITHUB_APP_KEY',
17
16
  'DASHBOARD_OAUTH_CLIENT_ID',
@@ -135,7 +134,6 @@ describe('getDashboardDeployEnv', () => {
135
134
  HOME: '/home/user',
136
135
  SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
137
136
  DASHBOARD_DOMAIN: 'dashboard.fro.bot',
138
- DASHBOARD_IMAGE_DIGEST: `sha256:${'a'.repeat(64)}`,
139
137
  DASHBOARD_GITHUB_APP_ID: '123456',
140
138
  DASHBOARD_GITHUB_APP_KEY: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----',
141
139
  DASHBOARD_OAUTH_CLIENT_ID: 'Iv1.abc123',
@@ -148,7 +146,6 @@ describe('getDashboardDeployEnv', () => {
148
146
 
149
147
  // All DASHBOARD_* vars must be present in the returned env
150
148
  expect(env.DASHBOARD_DOMAIN).toBe('dashboard.fro.bot')
151
- expect(env.DASHBOARD_IMAGE_DIGEST).toBe(`sha256:${'a'.repeat(64)}`)
152
149
  expect(env.DASHBOARD_GITHUB_APP_ID).toBe('123456')
153
150
  expect(env.DASHBOARD_OAUTH_CLIENT_ID).toBe('Iv1.abc123')
154
151
  expect(env.DASHBOARD_OAUTH_CLIENT_SECRET).toBe('oauthsecret')
@@ -35,9 +35,9 @@ export function getDashboardDeployEnv(): Record<string, string> {
35
35
  )
36
36
  }
37
37
 
38
- // Spread all of process.env so DASHBOARD_* vars (IMAGE_DIGEST, GITHUB_APP_ID, etc.)
38
+ // Spread all of process.env so DASHBOARD_* vars (GITHUB_APP_ID, OAUTH_CLIENT_ID, etc.)
39
39
  // are forwarded to the spawned deploy.ts process. validateEnv in deploy.ts requires
40
- // all DASHBOARD_* vars to be present — a selective subset would cause it to fail.
40
+ // all DASHBOARD_* credential vars to be present — a selective subset would cause it to fail.
41
41
  // Filter out undefined values (Record<string, string> does not allow undefined).
42
42
  const fullEnv: Record<string, string> = {}
43
43
  for (const [k, v] of Object.entries(process.env)) {
@@ -167,6 +167,30 @@ describe('logs command', () => {
167
167
  })
168
168
  })
169
169
 
170
+ // ─── SSH command includes ConnectTimeout ─────────────────────────────────────
171
+
172
+ describe('streamDashboardLogs — SSH command includes ConnectTimeout', () => {
173
+ it('passes -o ConnectTimeout=10 to ssh', async () => {
174
+ delete process.env.CI
175
+
176
+ let capturedCmd: string[] = []
177
+ const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
178
+ capturedCmd = cmd
179
+ return {exited: Promise.resolve(0)}
180
+ }
181
+
182
+ await streamDashboardLogs(
183
+ {host: 'dashboard.fro.bot', service: 'dashboard', tail: 100, allowCi: false},
184
+ spawnCapture,
185
+ )
186
+
187
+ const connectTimeoutIdx = capturedCmd.findIndex(arg => arg.startsWith('ConnectTimeout='))
188
+ expect(connectTimeoutIdx).toBeGreaterThan(-1)
189
+ expect(capturedCmd[connectTimeoutIdx - 1]).toBe('-o')
190
+ expect(capturedCmd[connectTimeoutIdx]).toBe('ConnectTimeout=10')
191
+ })
192
+ })
193
+
170
194
  // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
171
195
 
172
196
  describe('streamDashboardLogs — SSH command includes UserKnownHostsFile', () => {
@@ -96,6 +96,8 @@ export async function streamDashboardLogs(
96
96
  '-o',
97
97
  'BatchMode=yes',
98
98
  '-o',
99
+ 'ConnectTimeout=10',
100
+ '-o',
99
101
  'StrictHostKeyChecking=yes',
100
102
  ...buildKnownHostsArgs(),
101
103
  ...identityArgs,
@@ -133,6 +133,40 @@ function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
133
133
  }
134
134
  }
135
135
 
136
+ // ─── SSH command includes ConnectTimeout ─────────────────────────────────────
137
+
138
+ describe('getDashboardComposeStatus — SSH command includes ConnectTimeout', () => {
139
+ it('passes -o ConnectTimeout=10 to ssh', async () => {
140
+ let capturedCmd: string[] = []
141
+
142
+ const capturingSpawn: SpawnFn = (cmd, _opts) => {
143
+ capturedCmd = cmd
144
+ const enc = new TextEncoder()
145
+ return {
146
+ stdout: new ReadableStream({
147
+ start(c) {
148
+ c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
149
+ c.close()
150
+ },
151
+ }),
152
+ stderr: new ReadableStream({
153
+ start(c) {
154
+ c.close()
155
+ },
156
+ }),
157
+ exited: Promise.resolve(0),
158
+ }
159
+ }
160
+
161
+ await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
162
+
163
+ const connectTimeoutIdx = capturedCmd.findIndex(arg => arg.startsWith('ConnectTimeout='))
164
+ expect(connectTimeoutIdx).toBeGreaterThan(-1)
165
+ expect(capturedCmd[connectTimeoutIdx - 1]).toBe('-o')
166
+ expect(capturedCmd[connectTimeoutIdx]).toBe('ConnectTimeout=10')
167
+ })
168
+ })
169
+
136
170
  // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
137
171
 
138
172
  describe('getDashboardComposeStatus — SSH command includes UserKnownHostsFile', () => {
@@ -362,6 +396,28 @@ describe('status command', () => {
362
396
  expect(captured.exit?.code).toBe(1)
363
397
  })
364
398
 
399
+ it('prints the SSH error message (not a generic no-services message) when SSH fails', async () => {
400
+ process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
401
+
402
+ const {ctx, captured} = createCapturedCtx()
403
+
404
+ try {
405
+ await dashboardStatusAction(
406
+ {},
407
+ ctx,
408
+ makeSpawn('', 'ssh: connect to host dashboard.fro.bot port 22: Connection timed out', 255),
409
+ )
410
+ } catch (error) {
411
+ if (!(error instanceof MockProcessExit)) throw error
412
+ }
413
+
414
+ const stderrText = captured.stderr.join('')
415
+ // Must surface the SSH failure detail, not the generic no-services fallback
416
+ expect(stderrText).toContain('SSH command failed')
417
+ expect(stderrText).not.toContain('No services reported by docker compose ps')
418
+ expect(captured.exit?.code).toBe(1)
419
+ })
420
+
365
421
  it('calls ctx.console.error and exits when DASHBOARD_DOMAIN is not set', async () => {
366
422
  delete process.env.DASHBOARD_DOMAIN
367
423
 
@@ -109,6 +109,8 @@ export async function getDashboardComposeStatus(
109
109
  '-o',
110
110
  'BatchMode=yes',
111
111
  '-o',
112
+ 'ConnectTimeout=10',
113
+ '-o',
112
114
  'StrictHostKeyChecking=yes',
113
115
  ...knownHostsArgs,
114
116
  ...identityArgs,
@@ -250,7 +252,8 @@ export async function dashboardStatusAction(
250
252
  }
251
253
 
252
254
  if (result.services.length === 0) {
253
- ctx.console.error(`Error: No services reported by docker compose ps`)
255
+ const msg = result.error ?? 'No services reported by docker compose ps'
256
+ ctx.console.error(`Error: ${msg}`)
254
257
  ctx.process.exit(1)
255
258
  return
256
259
  }
@@ -58,6 +58,18 @@ export function getGatewayDeployEnv(): Record<string, string> {
58
58
  GATEWAY_TRIGGER_ROLE_ID: process.env.GATEWAY_TRIGGER_ROLE_ID ?? '',
59
59
  GATEWAY_WEBHOOK_SECRET: process.env.GATEWAY_WEBHOOK_SECRET ?? '',
60
60
  GATEWAY_PRESENCE_CHANNEL_ID: process.env.GATEWAY_PRESENCE_CHANNEL_ID ?? '',
61
+ GATEWAY_OPERATOR_BIND_HOST: process.env.GATEWAY_OPERATOR_BIND_HOST ?? '',
62
+ GATEWAY_OPERATOR_BIND_PORT: process.env.GATEWAY_OPERATOR_BIND_PORT ?? '',
63
+ GATEWAY_OPERATOR_PUBLIC_ORIGIN: process.env.GATEWAY_OPERATOR_PUBLIC_ORIGIN ?? '',
64
+ GATEWAY_OPERATOR_GITHUB_CLIENT_ID: process.env.GATEWAY_OPERATOR_GITHUB_CLIENT_ID ?? '',
65
+ GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET: process.env.GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET ?? '',
66
+ GATEWAY_OPERATOR_CSRF_SECRET: process.env.GATEWAY_OPERATOR_CSRF_SECRET ?? '',
67
+ GATEWAY_OPERATOR_ALLOWLIST: process.env.GATEWAY_OPERATOR_ALLOWLIST ?? '',
68
+ GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS: process.env.GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS ?? '',
69
+ GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS: process.env.GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS ?? '',
70
+ GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS: process.env.GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS ?? '',
71
+ GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? '',
72
+ WORKSPACE_IMAGE_DIGEST: process.env.WORKSPACE_IMAGE_DIGEST ?? '',
61
73
  }
62
74
  }
63
75
 
@@ -644,3 +644,347 @@ describe('dorny/paths-filter quantifier guard', () => {
644
644
  expect(violations).toEqual([])
645
645
  })
646
646
  })
647
+
648
+ // ─── Unit 5: operator auth/config secrets and tuning vars ────────────────────
649
+ //
650
+ // RED tests for Unit 5 of 2026-06-18-002-feat-gateway-operator-auth-config-plan.md
651
+ // These assert the four new auth/config secrets and three optional tuning vars are
652
+ // wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
653
+ // deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
654
+
655
+ const OPERATOR_AUTH_SECRETS = [
656
+ 'GATEWAY_OPERATOR_GITHUB_CLIENT_ID',
657
+ 'GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET',
658
+ 'GATEWAY_OPERATOR_CSRF_SECRET',
659
+ 'GATEWAY_OPERATOR_ALLOWLIST',
660
+ ] as const
661
+
662
+ const OPERATOR_TUNING_SECRETS = [
663
+ 'GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS',
664
+ 'GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS',
665
+ 'GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS',
666
+ ] as const
667
+
668
+ describe('deploy-gateway.yaml: operator auth/config secrets in workflow_call.secrets', () => {
669
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
670
+
671
+ for (const secret of OPERATOR_AUTH_SECRETS) {
672
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
673
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
674
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
675
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
676
+ expect(secrets).toHaveProperty(secret)
677
+ expect(secrets[secret]?.required).toBe(false)
678
+ })
679
+ }
680
+ })
681
+
682
+ describe('deploy-gateway.yaml: operator tuning vars in workflow_call.secrets', () => {
683
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
684
+
685
+ for (const secret of OPERATOR_TUNING_SECRETS) {
686
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
687
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
688
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
689
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
690
+ expect(secrets).toHaveProperty(secret)
691
+ expect(secrets[secret]?.required).toBe(false)
692
+ })
693
+ }
694
+
695
+ it('workflow_call.inputs does NOT declare any GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
696
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
697
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {inputs?: Record<string, unknown>}}}
698
+ const inputs = parsed?.on?.workflow_call?.inputs ?? {}
699
+ for (const secret of OPERATOR_TUNING_SECRETS) {
700
+ expect(inputs).not.toHaveProperty(secret)
701
+ }
702
+ })
703
+ })
704
+
705
+ describe('deploy-gateway.yaml: deploy step env forwards operator auth/config vars', () => {
706
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
707
+
708
+ for (const secret of OPERATOR_AUTH_SECRETS) {
709
+ it(`Deploy gateway step env forwards ${secret}`, async () => {
710
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
711
+ const parsed = parseYaml(text) as {
712
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
713
+ }
714
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
715
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
716
+ expect(deployStep).toBeDefined()
717
+ expect(deployStep?.env).toHaveProperty(secret)
718
+ expect(deployStep?.env?.[secret]).toContain(secret)
719
+ })
720
+ }
721
+
722
+ for (const secret of OPERATOR_TUNING_SECRETS) {
723
+ it(`Deploy gateway step env forwards ${secret} from secrets context (not inputs)`, async () => {
724
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
725
+ const parsed = parseYaml(text) as {
726
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
727
+ }
728
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
729
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
730
+ expect(deployStep).toBeDefined()
731
+ expect(deployStep?.env).toHaveProperty(secret)
732
+ // Must source from secrets context, not inputs context
733
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
734
+ })
735
+ }
736
+ })
737
+
738
+ describe('deploy.yaml: fan-out passes operator auth/config secrets to deploy-gateway job', () => {
739
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
740
+
741
+ for (const secret of OPERATOR_AUTH_SECRETS) {
742
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
743
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
744
+ const parsed = parseYaml(text) as {
745
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
746
+ }
747
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
748
+ expect(secrets).toHaveProperty(secret)
749
+ expect(secrets[secret]).toContain(secret)
750
+ })
751
+ }
752
+ })
753
+
754
+ describe('deploy.yaml: fan-out passes operator tuning vars via secrets: to deploy-gateway job', () => {
755
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
756
+
757
+ for (const secret of OPERATOR_TUNING_SECRETS) {
758
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
759
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
760
+ const parsed = parseYaml(text) as {
761
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
762
+ }
763
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
764
+ expect(secrets).toHaveProperty(secret)
765
+ expect(secrets[secret]).toContain(secret)
766
+ })
767
+ }
768
+
769
+ it('deploy-gateway job does NOT have a with: block for GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
770
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
771
+ const parsed = parseYaml(text) as {
772
+ jobs?: {'deploy-gateway'?: {with?: Record<string, unknown>}}
773
+ }
774
+ const withBlock = parsed?.jobs?.['deploy-gateway']?.with ?? {}
775
+ for (const secret of OPERATOR_TUNING_SECRETS) {
776
+ expect(withBlock).not.toHaveProperty(secret)
777
+ }
778
+ })
779
+ })
780
+
781
+ describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
782
+ it('getGatewayDeployEnv includes all four operator auth/config secret vars', async () => {
783
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
784
+ // Provide required env vars
785
+ const origEnv = {...process.env}
786
+ process.env.PATH = '/usr/bin'
787
+ process.env.HOME = '/home/test'
788
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
789
+ try {
790
+ const env = getGatewayDeployEnv()
791
+ for (const secret of OPERATOR_AUTH_SECRETS) {
792
+ expect(env).toHaveProperty(secret)
793
+ }
794
+ } finally {
795
+ Object.assign(process.env, origEnv)
796
+ }
797
+ })
798
+
799
+ it('getGatewayDeployEnv includes all three operator tuning vars', async () => {
800
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
801
+ const origEnv = {...process.env}
802
+ process.env.PATH = '/usr/bin'
803
+ process.env.HOME = '/home/test'
804
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
805
+ try {
806
+ const env = getGatewayDeployEnv()
807
+ for (const secret of OPERATOR_TUNING_SECRETS) {
808
+ expect(env).toHaveProperty(secret)
809
+ }
810
+ } finally {
811
+ Object.assign(process.env, origEnv)
812
+ }
813
+ })
814
+
815
+ it('getGatewayDeployEnv includes GATEWAY_IMAGE_DIGEST', async () => {
816
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
817
+ const origEnv = {...process.env}
818
+ process.env.PATH = '/usr/bin'
819
+ process.env.HOME = '/home/test'
820
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
821
+ process.env.GATEWAY_IMAGE_DIGEST = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
822
+ try {
823
+ const env = getGatewayDeployEnv()
824
+ expect(env).toHaveProperty('GATEWAY_IMAGE_DIGEST')
825
+ expect(env.GATEWAY_IMAGE_DIGEST).toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
826
+ } finally {
827
+ Object.assign(process.env, origEnv)
828
+ }
829
+ })
830
+
831
+ it('getGatewayDeployEnv includes WORKSPACE_IMAGE_DIGEST', async () => {
832
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
833
+ const origEnv = {...process.env}
834
+ process.env.PATH = '/usr/bin'
835
+ process.env.HOME = '/home/test'
836
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
837
+ process.env.WORKSPACE_IMAGE_DIGEST = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
838
+ try {
839
+ const env = getGatewayDeployEnv()
840
+ expect(env).toHaveProperty('WORKSPACE_IMAGE_DIGEST')
841
+ expect(env.WORKSPACE_IMAGE_DIGEST).toBe('sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
842
+ } finally {
843
+ Object.assign(process.env, origEnv)
844
+ }
845
+ })
846
+ })
847
+
848
+ // ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
849
+
850
+ describe('deploy.yaml: aggregate router forwards operator secrets to deploy-gateway job', () => {
851
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
852
+
853
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_HOST', async () => {
854
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
855
+ const parsed = parseYaml(text) as {
856
+ jobs?: {
857
+ 'deploy-gateway'?: {
858
+ secrets?: Record<string, string>
859
+ }
860
+ }
861
+ }
862
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
863
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
864
+ expect(secrets.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
865
+ })
866
+
867
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_PORT', async () => {
868
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
869
+ const parsed = parseYaml(text) as {
870
+ jobs?: {
871
+ 'deploy-gateway'?: {
872
+ secrets?: Record<string, string>
873
+ }
874
+ }
875
+ }
876
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
877
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
878
+ expect(secrets.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
879
+ })
880
+
881
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
882
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
883
+ const parsed = parseYaml(text) as {
884
+ jobs?: {
885
+ 'deploy-gateway'?: {
886
+ secrets?: Record<string, string>
887
+ }
888
+ }
889
+ }
890
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
891
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
892
+ expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
893
+ })
894
+ })
895
+
896
+ // ─── deploy-gateway.yaml: optional operator secret declarations ───────────────
897
+
898
+ describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)', () => {
899
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
900
+
901
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_HOST as optional', async () => {
902
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
903
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
904
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
905
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
906
+ expect(secrets.GATEWAY_OPERATOR_BIND_HOST?.required).toBe(false)
907
+ })
908
+
909
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_PORT as optional', async () => {
910
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
911
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
912
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
913
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
914
+ expect(secrets.GATEWAY_OPERATOR_BIND_PORT?.required).toBe(false)
915
+ })
916
+
917
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_PUBLIC_ORIGIN as optional', async () => {
918
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
919
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
920
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
921
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
922
+ expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN?.required).toBe(false)
923
+ })
924
+
925
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_HOST', async () => {
926
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
927
+ const parsed = parseYaml(text) as {
928
+ jobs?: {
929
+ 'deploy-gateway'?: {
930
+ steps?: {name?: string; env?: Record<string, string>}[]
931
+ }
932
+ }
933
+ }
934
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
935
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
936
+ expect(deployStep).toBeDefined()
937
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
938
+ expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
939
+ })
940
+
941
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_PORT', async () => {
942
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
943
+ const parsed = parseYaml(text) as {
944
+ jobs?: {
945
+ 'deploy-gateway'?: {
946
+ steps?: {name?: string; env?: Record<string, string>}[]
947
+ }
948
+ }
949
+ }
950
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
951
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
952
+ expect(deployStep).toBeDefined()
953
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
954
+ expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
955
+ })
956
+
957
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
958
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
959
+ const parsed = parseYaml(text) as {
960
+ jobs?: {
961
+ 'deploy-gateway'?: {
962
+ steps?: {name?: string; env?: Record<string, string>}[]
963
+ }
964
+ }
965
+ }
966
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
967
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
968
+ expect(deployStep).toBeDefined()
969
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
970
+ expect(deployStep?.env?.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
971
+ })
972
+
973
+ it('operator vars are NOT in the required-secret validation step env', async () => {
974
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
975
+ const parsed = parseYaml(text) as {
976
+ jobs?: {
977
+ 'deploy-gateway'?: {
978
+ steps?: {name?: string; env?: Record<string, string>; run?: string}[]
979
+ }
980
+ }
981
+ }
982
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
983
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
984
+ expect(validateStep).toBeDefined()
985
+ // Operator vars must NOT be in the validation step env (deploy script enforces all-or-none)
986
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
987
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
988
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
989
+ })
990
+ })