@marcusrbrown/infra 0.12.1 → 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.1",
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)) {
@@ -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
+ })