@marcusrbrown/infra 0.12.1 → 0.12.3

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.3",
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,20 @@ 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_VPC_IP: process.env.GATEWAY_VPC_IP ?? '',
72
+ DASHBOARD_VPC_IP: process.env.DASHBOARD_VPC_IP ?? '',
73
+ GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? '',
74
+ WORKSPACE_IMAGE_DIGEST: process.env.WORKSPACE_IMAGE_DIGEST ?? '',
61
75
  }
62
76
  }
63
77
 
@@ -440,6 +440,35 @@ describe('per-app invariants', () => {
440
440
  // SHA verification is wired in and was not accidentally stripped.
441
441
  expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
442
442
  })
443
+
444
+ it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
445
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
446
+ // The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
447
+ // The deploy job must NOT install doctl — it no longer needs it.
448
+ expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
449
+ expect(text).not.toContain('digitalocean/action-doctl')
450
+ })
451
+
452
+ it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
453
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
454
+ // DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
455
+ // Verify it is not forwarded to the deploy step env block.
456
+ const deployStepIndex = text.indexOf('name: Deploy gateway')
457
+ expect(deployStepIndex).toBeGreaterThan(-1)
458
+ const afterDeployStep = text.slice(deployStepIndex)
459
+ expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
460
+ })
461
+
462
+ it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
463
+ const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
464
+ // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
465
+ // publish and the DOCKER-USER iptables rule.
466
+ const deployStepIndex = text.indexOf('name: Deploy gateway')
467
+ expect(deployStepIndex).toBeGreaterThan(-1)
468
+ const afterDeployStep = text.slice(deployStepIndex)
469
+ expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
470
+ expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
471
+ })
443
472
  })
444
473
 
445
474
  describe('findCrossOrgSecretsInherit', () => {
@@ -644,3 +673,508 @@ describe('dorny/paths-filter quantifier guard', () => {
644
673
  expect(violations).toEqual([])
645
674
  })
646
675
  })
676
+
677
+ // ─── operator auth/config secrets and tuning vars ────────────────────────────
678
+ //
679
+ // These assert the four operator auth/config secrets and three optional tuning vars are
680
+ // wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
681
+ // deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
682
+
683
+ const OPERATOR_AUTH_SECRETS = [
684
+ 'GATEWAY_OPERATOR_GITHUB_CLIENT_ID',
685
+ 'GATEWAY_OPERATOR_GITHUB_CLIENT_SECRET',
686
+ 'GATEWAY_OPERATOR_CSRF_SECRET',
687
+ 'GATEWAY_OPERATOR_ALLOWLIST',
688
+ ] as const
689
+
690
+ const OPERATOR_TUNING_SECRETS = [
691
+ 'GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS',
692
+ 'GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS',
693
+ 'GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS',
694
+ ] as const
695
+
696
+ describe('deploy-gateway.yaml: operator auth/config secrets in workflow_call.secrets', () => {
697
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
698
+
699
+ for (const secret of OPERATOR_AUTH_SECRETS) {
700
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
701
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
702
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
703
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
704
+ expect(secrets).toHaveProperty(secret)
705
+ expect(secrets[secret]?.required).toBe(false)
706
+ })
707
+ }
708
+ })
709
+
710
+ describe('deploy-gateway.yaml: operator tuning vars in workflow_call.secrets', () => {
711
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
712
+
713
+ for (const secret of OPERATOR_TUNING_SECRETS) {
714
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
715
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
716
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
717
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
718
+ expect(secrets).toHaveProperty(secret)
719
+ expect(secrets[secret]?.required).toBe(false)
720
+ })
721
+ }
722
+
723
+ it('workflow_call.inputs does NOT declare any GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
724
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
725
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {inputs?: Record<string, unknown>}}}
726
+ const inputs = parsed?.on?.workflow_call?.inputs ?? {}
727
+ for (const secret of OPERATOR_TUNING_SECRETS) {
728
+ expect(inputs).not.toHaveProperty(secret)
729
+ }
730
+ })
731
+ })
732
+
733
+ describe('deploy-gateway.yaml: deploy step env forwards operator auth/config vars', () => {
734
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
735
+
736
+ for (const secret of OPERATOR_AUTH_SECRETS) {
737
+ it(`Deploy gateway step env forwards ${secret}`, async () => {
738
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
739
+ const parsed = parseYaml(text) as {
740
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
741
+ }
742
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
743
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
744
+ expect(deployStep).toBeDefined()
745
+ expect(deployStep?.env).toHaveProperty(secret)
746
+ expect(deployStep?.env?.[secret]).toContain(secret)
747
+ })
748
+ }
749
+
750
+ for (const secret of OPERATOR_TUNING_SECRETS) {
751
+ it(`Deploy gateway step env forwards ${secret} from secrets context (not inputs)`, async () => {
752
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
753
+ const parsed = parseYaml(text) as {
754
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
755
+ }
756
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
757
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
758
+ expect(deployStep).toBeDefined()
759
+ expect(deployStep?.env).toHaveProperty(secret)
760
+ // Must source from secrets context, not inputs context
761
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
762
+ })
763
+ }
764
+ })
765
+
766
+ describe('deploy.yaml: fan-out passes operator auth/config secrets to deploy-gateway job', () => {
767
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
768
+
769
+ for (const secret of OPERATOR_AUTH_SECRETS) {
770
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
771
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
772
+ const parsed = parseYaml(text) as {
773
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
774
+ }
775
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
776
+ expect(secrets).toHaveProperty(secret)
777
+ expect(secrets[secret]).toContain(secret)
778
+ })
779
+ }
780
+ })
781
+
782
+ describe('deploy.yaml: fan-out passes operator tuning vars via secrets: to deploy-gateway job', () => {
783
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
784
+
785
+ for (const secret of OPERATOR_TUNING_SECRETS) {
786
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
787
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
788
+ const parsed = parseYaml(text) as {
789
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
790
+ }
791
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
792
+ expect(secrets).toHaveProperty(secret)
793
+ expect(secrets[secret]).toContain(secret)
794
+ })
795
+ }
796
+
797
+ it('deploy-gateway job does NOT have a with: block for GATEWAY_OPERATOR_OAUTH_ tuning vars', async () => {
798
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
799
+ const parsed = parseYaml(text) as {
800
+ jobs?: {'deploy-gateway'?: {with?: Record<string, unknown>}}
801
+ }
802
+ const withBlock = parsed?.jobs?.['deploy-gateway']?.with ?? {}
803
+ for (const secret of OPERATOR_TUNING_SECRETS) {
804
+ expect(withBlock).not.toHaveProperty(secret)
805
+ }
806
+ })
807
+ })
808
+
809
+ describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
810
+ it('getGatewayDeployEnv includes all four operator auth/config secret vars', async () => {
811
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
812
+ // Provide required env vars
813
+ const origEnv = {...process.env}
814
+ process.env.PATH = '/usr/bin'
815
+ process.env.HOME = '/home/test'
816
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
817
+ try {
818
+ const env = getGatewayDeployEnv()
819
+ for (const secret of OPERATOR_AUTH_SECRETS) {
820
+ expect(env).toHaveProperty(secret)
821
+ }
822
+ } finally {
823
+ Object.assign(process.env, origEnv)
824
+ }
825
+ })
826
+
827
+ it('getGatewayDeployEnv includes all three operator tuning vars', async () => {
828
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
829
+ const origEnv = {...process.env}
830
+ process.env.PATH = '/usr/bin'
831
+ process.env.HOME = '/home/test'
832
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
833
+ try {
834
+ const env = getGatewayDeployEnv()
835
+ for (const secret of OPERATOR_TUNING_SECRETS) {
836
+ expect(env).toHaveProperty(secret)
837
+ }
838
+ } finally {
839
+ Object.assign(process.env, origEnv)
840
+ }
841
+ })
842
+
843
+ it('getGatewayDeployEnv includes GATEWAY_IMAGE_DIGEST', async () => {
844
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
845
+ const origEnv = {...process.env}
846
+ process.env.PATH = '/usr/bin'
847
+ process.env.HOME = '/home/test'
848
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
849
+ process.env.GATEWAY_IMAGE_DIGEST = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
850
+ try {
851
+ const env = getGatewayDeployEnv()
852
+ expect(env).toHaveProperty('GATEWAY_IMAGE_DIGEST')
853
+ expect(env.GATEWAY_IMAGE_DIGEST).toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
854
+ } finally {
855
+ Object.assign(process.env, origEnv)
856
+ }
857
+ })
858
+
859
+ it('getGatewayDeployEnv includes WORKSPACE_IMAGE_DIGEST', async () => {
860
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
861
+ const origEnv = {...process.env}
862
+ process.env.PATH = '/usr/bin'
863
+ process.env.HOME = '/home/test'
864
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
865
+ process.env.WORKSPACE_IMAGE_DIGEST = 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
866
+ try {
867
+ const env = getGatewayDeployEnv()
868
+ expect(env).toHaveProperty('WORKSPACE_IMAGE_DIGEST')
869
+ expect(env.WORKSPACE_IMAGE_DIGEST).toBe('sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
870
+ } finally {
871
+ Object.assign(process.env, origEnv)
872
+ }
873
+ })
874
+
875
+ it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
876
+ const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
877
+ const origEnv = {...process.env}
878
+ process.env.PATH = '/usr/bin'
879
+ process.env.HOME = '/home/test'
880
+ process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
881
+ try {
882
+ const env = getGatewayDeployEnv()
883
+ // VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
884
+ expect(env).toHaveProperty('GATEWAY_VPC_IP')
885
+ expect(env).toHaveProperty('DASHBOARD_VPC_IP')
886
+ // DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
887
+ expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
888
+ } finally {
889
+ Object.assign(process.env, origEnv)
890
+ }
891
+ })
892
+ })
893
+
894
+ // ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
895
+
896
+ describe('deploy.yaml: aggregate router forwards operator secrets to deploy-gateway job', () => {
897
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
898
+
899
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_HOST', async () => {
900
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
901
+ const parsed = parseYaml(text) as {
902
+ jobs?: {
903
+ 'deploy-gateway'?: {
904
+ secrets?: Record<string, string>
905
+ }
906
+ }
907
+ }
908
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
909
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
910
+ expect(secrets.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
911
+ })
912
+
913
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_BIND_PORT', async () => {
914
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
915
+ const parsed = parseYaml(text) as {
916
+ jobs?: {
917
+ 'deploy-gateway'?: {
918
+ secrets?: Record<string, string>
919
+ }
920
+ }
921
+ }
922
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
923
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
924
+ expect(secrets.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
925
+ })
926
+
927
+ it('deploy-gateway job secrets block passes GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
928
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
929
+ const parsed = parseYaml(text) as {
930
+ jobs?: {
931
+ 'deploy-gateway'?: {
932
+ secrets?: Record<string, string>
933
+ }
934
+ }
935
+ }
936
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
937
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
938
+ expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
939
+ })
940
+ })
941
+
942
+ // ─── deploy-gateway.yaml: optional operator secret declarations ───────────────
943
+
944
+ describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)', () => {
945
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
946
+
947
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_HOST as optional', async () => {
948
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
949
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
950
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
951
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
952
+ expect(secrets.GATEWAY_OPERATOR_BIND_HOST?.required).toBe(false)
953
+ })
954
+
955
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_BIND_PORT as optional', async () => {
956
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
957
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
958
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
959
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
960
+ expect(secrets.GATEWAY_OPERATOR_BIND_PORT?.required).toBe(false)
961
+ })
962
+
963
+ it('workflow_call.secrets declares GATEWAY_OPERATOR_PUBLIC_ORIGIN as optional', async () => {
964
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
965
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
966
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
967
+ expect(secrets).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
968
+ expect(secrets.GATEWAY_OPERATOR_PUBLIC_ORIGIN?.required).toBe(false)
969
+ })
970
+
971
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_HOST', async () => {
972
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
973
+ const parsed = parseYaml(text) as {
974
+ jobs?: {
975
+ 'deploy-gateway'?: {
976
+ steps?: {name?: string; env?: Record<string, string>}[]
977
+ }
978
+ }
979
+ }
980
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
981
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
982
+ expect(deployStep).toBeDefined()
983
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
984
+ expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_HOST).toContain('GATEWAY_OPERATOR_BIND_HOST')
985
+ })
986
+
987
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_BIND_PORT', async () => {
988
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
989
+ const parsed = parseYaml(text) as {
990
+ jobs?: {
991
+ 'deploy-gateway'?: {
992
+ steps?: {name?: string; env?: Record<string, string>}[]
993
+ }
994
+ }
995
+ }
996
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
997
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
998
+ expect(deployStep).toBeDefined()
999
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
1000
+ expect(deployStep?.env?.GATEWAY_OPERATOR_BIND_PORT).toContain('GATEWAY_OPERATOR_BIND_PORT')
1001
+ })
1002
+
1003
+ it('Deploy gateway step env forwards GATEWAY_OPERATOR_PUBLIC_ORIGIN', async () => {
1004
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1005
+ const parsed = parseYaml(text) as {
1006
+ jobs?: {
1007
+ 'deploy-gateway'?: {
1008
+ steps?: {name?: string; env?: Record<string, string>}[]
1009
+ }
1010
+ }
1011
+ }
1012
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1013
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
1014
+ expect(deployStep).toBeDefined()
1015
+ expect(deployStep?.env).toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1016
+ expect(deployStep?.env?.GATEWAY_OPERATOR_PUBLIC_ORIGIN).toContain('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1017
+ })
1018
+
1019
+ it('operator vars are NOT in the required-secret validation step env', async () => {
1020
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1021
+ const parsed = parseYaml(text) as {
1022
+ jobs?: {
1023
+ 'deploy-gateway'?: {
1024
+ steps?: {name?: string; env?: Record<string, string>; run?: string}[]
1025
+ }
1026
+ }
1027
+ }
1028
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1029
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
1030
+ expect(validateStep).toBeDefined()
1031
+ // Operator vars must NOT be in the validation step env (deploy script enforces all-or-none)
1032
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_HOST')
1033
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_BIND_PORT')
1034
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
1035
+ })
1036
+ })
1037
+
1038
+ // ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
1039
+ //
1040
+ // GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
1041
+ // but must be forwarded through CI so the operator VPC bridge is not silently disabled
1042
+ // when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
1043
+ // be forwarded to the deploy step.
1044
+ // These tests assert the end-to-end wiring without adding them to the required
1045
+ // validation preflight (they are opt-in, like the operator listener vars).
1046
+
1047
+ const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
1048
+ const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
1049
+
1050
+ describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
1051
+ const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
1052
+
1053
+ for (const secret of VPC_GATEWAY_SECRETS) {
1054
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1055
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1056
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1057
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1058
+ expect(secrets).toHaveProperty(secret)
1059
+ expect(secrets[secret]?.required).toBe(false)
1060
+ })
1061
+
1062
+ it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
1063
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1064
+ const parsed = parseYaml(text) as {
1065
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1066
+ }
1067
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1068
+ const deployStep = steps.find(s => s.name === 'Deploy gateway')
1069
+ expect(deployStep).toBeDefined()
1070
+ expect(deployStep?.env).toHaveProperty(secret)
1071
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1072
+ })
1073
+ }
1074
+
1075
+ it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
1076
+ const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
1077
+ const parsed = parseYaml(text) as {
1078
+ jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1079
+ }
1080
+ const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
1081
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
1082
+ expect(validateStep).toBeDefined()
1083
+ for (const secret of VPC_GATEWAY_SECRETS) {
1084
+ expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
1085
+ }
1086
+ })
1087
+ })
1088
+
1089
+ describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
1090
+ const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
1091
+
1092
+ for (const secret of VPC_DASHBOARD_SECRETS) {
1093
+ it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
1094
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1095
+ const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
1096
+ const secrets = parsed?.on?.workflow_call?.secrets ?? {}
1097
+ expect(secrets).toHaveProperty(secret)
1098
+ expect(secrets[secret]?.required).toBe(false)
1099
+ })
1100
+
1101
+ it(`Deploy step env forwards ${secret} from secrets context`, async () => {
1102
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1103
+ const parsed = parseYaml(text) as {
1104
+ jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
1105
+ }
1106
+ const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1107
+ const deployStep = steps.find(s => s.name === 'Deploy')
1108
+ expect(deployStep).toBeDefined()
1109
+ expect(deployStep?.env).toHaveProperty(secret)
1110
+ expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
1111
+ })
1112
+ }
1113
+
1114
+ it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
1115
+ const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
1116
+ const parsed = parseYaml(text) as {
1117
+ jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
1118
+ }
1119
+ const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
1120
+ const validateStep = steps.find(s => s.name === 'Validate required secrets')
1121
+ expect(validateStep).toBeDefined()
1122
+ expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
1123
+ })
1124
+ })
1125
+
1126
+ describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
1127
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1128
+
1129
+ for (const secret of VPC_GATEWAY_SECRETS) {
1130
+ it(`deploy-gateway job secrets block passes ${secret}`, async () => {
1131
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1132
+ const parsed = parseYaml(text) as {
1133
+ jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
1134
+ }
1135
+ const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
1136
+ expect(secrets).toHaveProperty(secret)
1137
+ expect(secrets[secret]).toContain(secret)
1138
+ })
1139
+ }
1140
+
1141
+ for (const secret of VPC_DASHBOARD_SECRETS) {
1142
+ it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
1143
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1144
+ const parsed = parseYaml(text) as {
1145
+ jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
1146
+ }
1147
+ const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
1148
+ expect(secrets).toHaveProperty(secret)
1149
+ expect(secrets[secret]).toContain(secret)
1150
+ })
1151
+ }
1152
+ })
1153
+
1154
+ // ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
1155
+ //
1156
+ // The dashboard is a standalone deployment. A gateway failure or slowness must
1157
+ // not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
1158
+ // and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
1159
+ // so it cannot silently regress.
1160
+
1161
+ describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
1162
+ const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
1163
+
1164
+ it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
1165
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1166
+ const parsed = parseYaml(text) as {
1167
+ jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
1168
+ }
1169
+ const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
1170
+ const needsArr = Array.isArray(needs) ? needs : [needs]
1171
+ expect(needsArr).not.toContain('deploy-gateway')
1172
+ })
1173
+
1174
+ it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
1175
+ const text = await Bun.file(DEPLOY_WORKFLOW).text()
1176
+ // The if: expression must not reference needs.deploy-gateway in any form —
1177
+ // gateway state must not gate the dashboard deploy.
1178
+ expect(text).not.toMatch(/needs\.deploy-gateway/)
1179
+ })
1180
+ })