@akash-chowdhury-24/deployhub 2.0.17 → 2.0.18

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/README.md CHANGED
@@ -909,6 +909,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
909
909
  - Cluster connectivity test during `init`
910
910
  - On deploy: registry login → reuse or build image → push (unique tag unless `DOCKER_IMAGE_TAG` is set) → ensure namespace exists (prompt locally / auto-create in CI) → `kubectl apply` → `kubectl set image` with the full resolved image ref → `kubectl rollout restart` when that ref is unchanged so pods pick up a new digest
911
911
 
912
+ > **Limitation — multiple Kubernetes clusters:** A generated workflow writes **one** kubeconfig file per job. Multiple Kubernetes environments that target **different clusters** in the same workflow run are not yet fully supported (follow-up). Same-cluster multi-namespace / multi-env is fine.
913
+
912
914
  **After `init`:**
913
915
  1. Verify context: `kubectl config get-contexts`
914
916
  2. Copy `.env.example` → `.env`; set `DOCKER_IMAGE_NAME`, registry username/token, and (for local deploys) `KUBECONFIG` / `KUBE_CONTEXT` / `KUBE_NAMESPACE` as needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.17",
3
+ "version": "2.0.18",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -161,8 +161,13 @@ export function resolveDefaultEnvironmentName(config) {
161
161
  }
162
162
 
163
163
  /**
164
- * Enabled environment names (for `--env all` and pipeline defaults).
164
+ * Enabled environment names **the single source of truth** for “which envs
165
+ * apply” across CLI, pipeline gating, workflow secret injection, and dropdowns.
166
+ * Do not add a parallel helper that re-filters `environments` by `enabled`
167
+ * (that shape caused Build/Dispatch secret asymmetry when lists drifted).
168
+ *
165
169
  * Falls back to legacy `deploy[]` when present and environments lack `enabled`.
170
+ * Used for `--env all` and pipeline defaults.
166
171
  *
167
172
  * @param {Record<string, unknown>} config
168
173
  * @returns {string[]}
@@ -24,11 +24,14 @@ import {
24
24
  * - GitHub Actions push: every enabled env with trigger "push".
25
25
  * - workflow_dispatch: none here (explicit deploy step handles --env).
26
26
  *
27
+ * Separate from workflow secret injection: CI may inject secrets for all
28
+ * enabled envs, but only `trigger: "push"` envs are deployed on push.
29
+ *
27
30
  * @param {import('./config.js').DeployHubConfig} config
28
31
  * @param {Record<string, string|undefined>} [env]
29
32
  * @returns {string[]}
30
33
  */
31
- function pipelineDeployTargets(config, env = process.env) {
34
+ export function pipelineDeployTargets(config, env = process.env) {
32
35
  if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
33
36
  return [];
34
37
  }
@@ -5,7 +5,11 @@ import path from 'path';
5
5
  * @param {Record<string, string>} [_env]
6
6
  */
7
7
  export function createLocalProvider(_env = process.env) {
8
- const baseDir = path.join(process.cwd(), '.deployhub-storage');
8
+ // Tests / isolated runs may pin storage under a temp dir without process.chdir
9
+ // (chdir races when Jest runs suites in parallel).
10
+ const baseDir = _env.DEPLOYHUB_LOCAL_STORAGE_DIR
11
+ ? path.resolve(_env.DEPLOYHUB_LOCAL_STORAGE_DIR)
12
+ : path.join(process.cwd(), '.deployhub-storage');
9
13
 
10
14
  /**
11
15
  * @param {string} localPath
@@ -14,7 +14,11 @@ import {
14
14
  envUsesPrefixedSecrets,
15
15
  DEPLOYMENT_ENV_KEYS,
16
16
  } from '../deployment/deployment-env.js';
17
- import { getEnvMethod, getEnvTrigger, isEnvEnabled } from '../core/environments.js';
17
+ import {
18
+ getEnvMethod,
19
+ getEnabledEnvironmentNames,
20
+ isEnvEnabled,
21
+ } from '../core/environments.js';
18
22
 
19
23
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
20
24
 
@@ -259,9 +263,41 @@ function hasKubernetesDeploy(deployEnvironments, environments) {
259
263
  }
260
264
 
261
265
  /**
266
+ * Secret name for the Configure-kubeconfig setup step.
267
+ * Must match the env that actually uses kubernetes — when the only k8s env is
268
+ * non-grandfathered (e.g. production), that is PRODUCTION_KUBECONFIG, not the
269
+ * unprefixed KUBECONFIG (which would never be injected and leave the file empty).
270
+ *
271
+ * LIMITATION (follow-up): GitHub Actions writes exactly ONE kubeconfig file per
272
+ * job. Multiple Kubernetes environments that target DIFFERENT clusters in the
273
+ * same workflow run are not yet fully supported — only the first/grandfathered
274
+ * k8s env's secret is used for the setup step. Track as a product follow-up
275
+ * before advertising multi-cluster multi-env CI.
276
+ *
277
+ * @param {string[]} deployEnvironments
278
+ * @param {Record<string, { type?: string, method?: string }>} environments
279
+ * @param {import('../core/config.js').DeployHubConfig|null} config
280
+ * @returns {string}
281
+ */
282
+ function resolveKubeconfigWorkflowSecretName(deployEnvironments, environments, config) {
283
+ const cfg = { ...(config || {}), environments };
284
+ const k8sNames = deployEnvironments.filter(
285
+ (n) => getEnvMethod(environments[n]) === 'kubernetes'
286
+ );
287
+ if (k8sNames.length === 0) return 'KUBECONFIG';
288
+ const unprefixed = k8sNames.find((n) => !envUsesPrefixedSecrets(n, cfg));
289
+ const chosen = unprefixed || k8sNames[0];
290
+ return envUsesPrefixedSecrets(chosen, cfg)
291
+ ? prefixSecretKey(chosen, 'KUBECONFIG')
292
+ : 'KUBECONFIG';
293
+ }
294
+
295
+ /**
296
+ * @param {string} [kubeconfigSecretName]
262
297
  * @returns {string}
263
298
  */
264
- function getKubernetesSetupSteps() {
299
+ function getKubernetesSetupSteps(kubeconfigSecretName = 'KUBECONFIG') {
300
+ // One kubeconfig file per job — see resolveKubeconfigWorkflowSecretName LIMITATION.
265
301
  return ` - name: Setup kubectl
266
302
  uses: azure/setup-kubectl@v4
267
303
  with:
@@ -269,7 +305,7 @@ function getKubernetesSetupSteps() {
269
305
 
270
306
  - name: Configure kubeconfig
271
307
  env:
272
- KUBECONFIG_SECRET: \${{ secrets.KUBECONFIG }}
308
+ KUBECONFIG_SECRET: \${{ secrets.${kubeconfigSecretName} }}
273
309
  run: |
274
310
  mkdir -p "$GITHUB_WORKSPACE/.kube"
275
311
  if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
@@ -316,27 +352,6 @@ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
316
352
  envVars.add(`${linePrefix}${valueExpr}`);
317
353
  }
318
354
 
319
- /**
320
- * Enabled environment names from the live `environments` map (preferred over
321
- * a possibly-stale `deploy[]` argument passed into workflow generators).
322
- * @param {Record<string, { type?: string, method?: string }>} environments
323
- * @returns {string[]}
324
- */
325
- function listEnabledEnvironmentNames(environments) {
326
- return Object.keys(environments || {}).filter((n) => isEnvEnabled(environments[n]));
327
- }
328
-
329
- /**
330
- * Push-triggered enabled envs — same set `pipelineDeployTargets` deploys in GHA.
331
- * @param {Record<string, { type?: string, method?: string }>} environments
332
- * @returns {string[]}
333
- */
334
- function listPushEnvironmentNames(environments) {
335
- return listEnabledEnvironmentNames(environments).filter(
336
- (n) => getEnvTrigger(environments[n]) === 'push'
337
- );
338
- }
339
-
340
355
  /**
341
356
  * Shared env entries for deploy and rollback workflows (same secret resolution).
342
357
  * Multi-env: each environment contributes its own CI secret names (grandfathered
@@ -400,9 +415,8 @@ export function buildWorkflowEnvEntries(
400
415
  * @returns {string}
401
416
  */
402
417
  function formatEnvironmentChoiceOptions(environments) {
403
- const names = Object.keys(environments || {}).filter((n) =>
404
- isEnvEnabled(environments[n])
405
- );
418
+ // Single source of truth: getEnabledEnvironmentNames (do not re-filter here).
419
+ const names = getEnabledEnvironmentNames({ environments });
406
420
  const options = [...names, 'all'];
407
421
  return options.map((n) => ` - ${n}`).join('\n');
408
422
  }
@@ -457,11 +471,10 @@ export function generateWorkflowYaml(
457
471
  config = null
458
472
  ) {
459
473
  const envNames = Object.keys(environments || {});
460
- // Secret injection must follow live environments (same as pipelineDeployTargets),
461
- // not a possibly-stale deploy[] argument otherwise push-deployed envs missing
462
- // from deploy[] get no secrets in the job env block.
463
- const enabledEnvs = listEnabledEnvironmentNames(environments);
464
- const pushEnvs = listPushEnvironmentNames(environments);
474
+ // Secret injection uses ALL enabled environments (not push-only / pipelineDeployTargets).
475
+ // Prefer live enabled names over a possibly-stale deploy[] argument so no enabled
476
+ // env is missing from the job env block. Canonical helper: getEnabledEnvironmentNames.
477
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
465
478
  const allDeployNames =
466
479
  enabledEnvs.length > 0
467
480
  ? enabledEnvs
@@ -469,28 +482,21 @@ export function generateWorkflowYaml(
469
482
  ? deployEnvironments
470
483
  : envNames;
471
484
 
472
- // Build step: union of secrets for every push-triggered env (build deploys all of them).
473
- const buildSecretEnvs =
474
- pushEnvs.length > 0 ? pushEnvs : allDeployNames.slice(0, 1);
475
-
476
- // workflow_dispatch step: union for ALL enabled envs (dropdown can pick any / all).
477
- const dispatchSecretEnvs = allDeployNames;
485
+ // CRITICAL: Build and Deploy (workflow_dispatch) MUST share the same secret union
486
+ // every enabled environment. Filtering Build to push-only caused a live regression:
487
+ // Dispatch correctly got PRODUCTION_* while Build did not, so `deployhub build`'s
488
+ // push deploy stage failed on production with a missing SSH key. Trigger only
489
+ // controls which envs the CLI deploys; it must not control which secrets are injected.
490
+ const secretEnvs =
491
+ allDeployNames.length > 0 ? allDeployNames : envNames.slice(0, 1);
478
492
 
479
- const buildEnvVars = buildWorkflowEnvEntries(
480
- storageProviders,
481
- buildSecretEnvs,
482
- environments,
483
- config
484
- );
485
- const buildEnvBlock = formatWorkflowEnvBlock(buildEnvVars);
486
-
487
- const dispatchEnvVars = buildWorkflowEnvEntries(
493
+ const envVars = buildWorkflowEnvEntries(
488
494
  storageProviders,
489
- dispatchSecretEnvs,
495
+ secretEnvs,
490
496
  environments,
491
497
  config
492
498
  );
493
- const dispatchEnvBlock = formatWorkflowEnvBlock(dispatchEnvVars);
499
+ const envBlock = formatWorkflowEnvBlock(envVars);
494
500
 
495
501
  const installSpec = getCliInstallSpec(cliSource);
496
502
  const backendSteps = getBackendSetupSteps(config);
@@ -498,8 +504,13 @@ export function generateWorkflowYaml(
498
504
  const githubGitConfigStep = isGithubCliSource(cliSource)
499
505
  ? `${getGithubGitConfigStep()}\n`
500
506
  : '';
507
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
508
+ allDeployNames,
509
+ environments,
510
+ config
511
+ );
501
512
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
502
- ? `${getKubernetesSetupSteps()}\n`
513
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
503
514
  : '';
504
515
 
505
516
  const projectType = config?.projectType || 'frontend';
@@ -527,7 +538,7 @@ ${envChoiceOptions}
527
538
  ? ` - name: Deploy (workflow_dispatch)
528
539
  if: github.event_name == 'workflow_dispatch'
529
540
  env:
530
- ${dispatchEnvBlock}
541
+ ${envBlock}
531
542
  run: |
532
543
  ENV_INPUT="\${{ inputs.environment }}"
533
544
  if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
@@ -557,7 +568,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
557
568
  - name: Build (and auto-deploy push-triggered envs)
558
569
  run: ${getCliBuildCommand()}
559
570
  env:
560
- ${buildEnvBlock}
571
+ ${envBlock}
561
572
  ${manualDeployStep}`;
562
573
 
563
574
  return workflow;
@@ -589,7 +600,7 @@ export function generateRollbackWorkflowYaml(
589
600
  config = null
590
601
  ) {
591
602
  const envNames = Object.keys(environments || {});
592
- const enabledEnvs = listEnabledEnvironmentNames(environments);
603
+ const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
593
604
  const allDeployNames =
594
605
  enabledEnvs.length > 0
595
606
  ? enabledEnvs
@@ -609,8 +620,13 @@ export function generateRollbackWorkflowYaml(
609
620
  const githubGitConfigStep = isGithubCliSource(cliSource)
610
621
  ? `${getGithubGitConfigStep()}\n`
611
622
  : '';
623
+ const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
624
+ allDeployNames,
625
+ environments,
626
+ config
627
+ );
612
628
  const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
613
- ? `${getKubernetesSetupSteps()}\n`
629
+ ? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
614
630
  : '';
615
631
 
616
632
  const rollbackCmd = getCliRollbackCommand();
@@ -790,13 +806,14 @@ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback')
790
806
  /** @type {string[]} */
791
807
  let targets;
792
808
  if (kind === 'deploy') {
793
- const pushEnvs = listPushEnvironmentNames(environments);
794
- const enabled = listEnabledEnvironmentNames(environments);
795
- // Deploy workflow Build step uses push envs; doctor also flags dispatch-needed
796
- // secrets by using the broader enabled set via kind === 'rollback' / checklist.
797
- targets = pushEnvs.length > 0 ? pushEnvs : enabled.slice(0, 1);
809
+ const enabled = getEnabledEnvironmentNames({ ...config, environments });
810
+ // Same union as generateWorkflowYaml Build + dispatch steps (all enabled).
811
+ targets = enabled.length > 0 ? enabled : allNames.slice(0, 1);
798
812
  } else {
799
- targets = allNames.length > 0 ? listEnabledEnvironmentNames(environments) : allNames;
813
+ targets =
814
+ allNames.length > 0
815
+ ? getEnabledEnvironmentNames({ ...config, environments })
816
+ : allNames;
800
817
  if (targets.length === 0) targets = allNames;
801
818
  }
802
819
 
@@ -851,7 +868,7 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
851
868
  return { drifted: false, missingEnvs, missingSecrets, summary: '' };
852
869
  }
853
870
 
854
- const envNames = listEnabledEnvironmentNames(config.environments || {});
871
+ const envNames = getEnabledEnvironmentNames(config);
855
872
  const root = /** @type {Record<string, any>} */ (parsed);
856
873
  const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
857
874