@akash-chowdhury-24/deployhub 2.0.17 → 2.0.19
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 +67 -2
- package/package.json +1 -1
- package/src/core/environments.js +6 -1
- package/src/core/stages.js +4 -1
- package/src/storage/providers/local.js +5 -1
- package/src/utils/github-actions.js +242 -75
package/README.md
CHANGED
|
@@ -257,15 +257,78 @@ deployhub logs # last deployment logs
|
|
|
257
257
|
| Argument | Behavior |
|
|
258
258
|
|----------|----------|
|
|
259
259
|
| *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
|
|
260
|
-
| Exact `buildId` | Rolls back to that specific build |
|
|
260
|
+
| Exact `buildId` | Rolls back to that specific build **if it appears in that env's history** |
|
|
261
261
|
| Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
|
|
262
262
|
|
|
263
263
|
`buildId` looks like `{semver}-{stamp}` where the stamp is a short git SHA when available, otherwise a CI run id, otherwise a high-resolution timestamp. When `DOCKER_IMAGE_TAG` is left unset, Docker and Kubernetes use that same `buildId` as the image tag — so you can correlate an artifact in storage with the image that was pushed.
|
|
264
264
|
|
|
265
|
+
### Rollback is scoped per environment
|
|
266
|
+
|
|
267
|
+
Each environment maintains its own independent deploy history. When you roll back an environment, DeployHub only considers builds that were actually deployed **to that environment** — never builds deployed to a different environment, even if they're more recent or share the same project.
|
|
268
|
+
|
|
269
|
+
For example: if `development` has deployed builds A, C, and D (in that order), and `production` has separately deployed builds B and D, then rolling back `production` moves it from D to B — **not** to C, even though C is technically a more recent build overall, because C was never deployed to `production` in the first place.
|
|
270
|
+
|
|
271
|
+
You also cannot roll back an environment to a specific `buildId` that was never deployed to that environment — even with `deployhub rollback <buildId> --env <name>`, DeployHub will refuse if that buildId doesn't appear in that environment's own history. This guarantees every rollback returns an environment to a build that was genuinely running there before, never a build it never actually had.
|
|
272
|
+
|
|
273
|
+
Storage layout (per project):
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
{project}/builds/{buildId}/artifact.zip # immutable build blobs (shared)
|
|
277
|
+
{project}/envs/{env}/history.json # that env's deploy history only
|
|
278
|
+
{project}/envs/{env}/latest/artifact.zip # last successful deploy to that env
|
|
279
|
+
{project}/history.json # build catalog / legacy default-env history
|
|
280
|
+
```
|
|
281
|
+
|
|
265
282
|
For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
|
|
266
283
|
|
|
267
284
|
---
|
|
268
285
|
|
|
286
|
+
## Multi-environment deployments
|
|
287
|
+
|
|
288
|
+
DeployHub supports multiple named environments in one project (e.g. `development` + `production`), each with its own method, secrets, trigger, and deploy history.
|
|
289
|
+
|
|
290
|
+
### Commands
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
deployhub env list
|
|
294
|
+
deployhub env add staging --method ssh # or omit --method for interactive prompts
|
|
295
|
+
deployhub env enable staging
|
|
296
|
+
deployhub env disable staging
|
|
297
|
+
deployhub env remove staging
|
|
298
|
+
|
|
299
|
+
deployhub deploy --env staging
|
|
300
|
+
deployhub deploy --env all # every enabled environment
|
|
301
|
+
|
|
302
|
+
deployhub rollback --env production
|
|
303
|
+
deployhub rollback 1.2.3-abc1234 --env staging
|
|
304
|
+
deployhub rollback --env all # each env rolls back independently
|
|
305
|
+
|
|
306
|
+
deployhub sync-workflows # regenerate deployhub.yml + deployhub-rollback.yml
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Trigger defaults
|
|
310
|
+
|
|
311
|
+
| Situation | Default `trigger` |
|
|
312
|
+
|-----------|-------------------|
|
|
313
|
+
| Single-environment `init` | `"push"` — auto-deploy on push to main (original DeployHub behavior) |
|
|
314
|
+
| First / grandfathered env in multi-env `init` | `"push"` |
|
|
315
|
+
| Additional environments | `"manual"` — deploy only via Actions → Run workflow or `deployhub deploy --env` |
|
|
316
|
+
|
|
317
|
+
Multi-env `init` prints a reminder naming which environments are push vs manual and how to edit `deployhub.config.json` (`environments.<name>.trigger`) if you want a different mix. After changing triggers or envs, run `deployhub sync-workflows` and commit the regenerated YAML.
|
|
318
|
+
|
|
319
|
+
On a GitHub Actions **push**, `deployhub build` only auto-deploys environments with `trigger: "push"`. Environments with `trigger: "manual"` are never deployed on push — even though their secrets are present in the job for dispatch/rollback.
|
|
320
|
+
|
|
321
|
+
### Secret naming
|
|
322
|
+
|
|
323
|
+
| Environment | GitHub Secret / env var style |
|
|
324
|
+
|-------------|-------------------------------|
|
|
325
|
+
| Grandfathered / original (`unprefixedSecretEnvironment`) | Unprefixed: `SSH_HOST`, `SSH_KEY`, `DOCKER_IMAGE_NAME`, … |
|
|
326
|
+
| Every additional environment | Prefixed: `PRODUCTION_SSH_HOST`, `STAGING_DOCKER_IMAGE_NAME`, … |
|
|
327
|
+
|
|
328
|
+
Build and Dispatch workflow steps share the **same** secret set (all enabled environments). Trigger only controls which environments are deployed on push — not which secrets are injected.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
269
332
|
## Walkthrough: Storage only
|
|
270
333
|
|
|
271
334
|
Use this when you want **versioned build artifacts in the cloud** but deploy manually (or add deployment later).
|
|
@@ -909,6 +972,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
|
|
|
909
972
|
- Cluster connectivity test during `init`
|
|
910
973
|
- 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
974
|
|
|
975
|
+
> **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.
|
|
976
|
+
|
|
912
977
|
**After `init`:**
|
|
913
978
|
1. Verify context: `kubectl config get-contexts`
|
|
914
979
|
2. Copy `.env.example` → `.env`; set `DOCKER_IMAGE_NAME`, registry username/token, and (for local deploys) `KUBECONFIG` / `KUBE_CONTEXT` / `KUBE_NAMESPACE` as needed
|
|
@@ -1029,7 +1094,7 @@ Run `deployhub doctor` after any config change.
|
|
|
1029
1094
|
| `deployhub clean` | Remove old local artifacts |
|
|
1030
1095
|
| `deployhub update` | Check for CLI updates |
|
|
1031
1096
|
|
|
1032
|
-
**Tests:** `npm test` — currently **
|
|
1097
|
+
**Tests:** `npm test` — currently **320 passing** across the Jest suites.
|
|
1033
1098
|
|
|
1034
1099
|
## GitHub Secrets
|
|
1035
1100
|
|
package/package.json
CHANGED
package/src/core/environments.js
CHANGED
|
@@ -161,8 +161,13 @@ export function resolveDefaultEnvironmentName(config) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Enabled environment names
|
|
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[]}
|
package/src/core/stages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import semver from 'semver';
|
|
4
6
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { getWorkflowHeaderComment } from './author.js';
|
|
7
|
+
import { getWorkflowHeaderComment, getDeployHubVersion } from './author.js';
|
|
6
8
|
import {
|
|
7
9
|
generateDeploymentEnvSection,
|
|
8
10
|
getDeploymentWorkflowSecretKeys,
|
|
@@ -14,7 +16,11 @@ import {
|
|
|
14
16
|
envUsesPrefixedSecrets,
|
|
15
17
|
DEPLOYMENT_ENV_KEYS,
|
|
16
18
|
} from '../deployment/deployment-env.js';
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
getEnvMethod,
|
|
21
|
+
getEnabledEnvironmentNames,
|
|
22
|
+
isEnvEnabled,
|
|
23
|
+
} from '../core/environments.js';
|
|
18
24
|
|
|
19
25
|
/** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
|
|
20
26
|
|
|
@@ -140,28 +146,63 @@ export function getCliInstallSpec(cliSource) {
|
|
|
140
146
|
* Version/range suitable as a package.json dependency VALUE for this CLI
|
|
141
147
|
* (key is already NPM_PACKAGE — do not embed the package name again).
|
|
142
148
|
*
|
|
149
|
+
* Reads the running CLI's package version dynamically. Never hardcode a
|
|
150
|
+
* specific semver fallback (e.g. "1.0.6") — if resolution fails, use "latest".
|
|
151
|
+
*
|
|
143
152
|
* @param {string} [cliSource]
|
|
153
|
+
* @param {{ packageJsonPath?: string }} [opts] — test override: read this package.json
|
|
144
154
|
* @returns {string}
|
|
145
155
|
*/
|
|
146
|
-
export function getCliPackageJsonDependencyVersion(cliSource) {
|
|
156
|
+
export function getCliPackageJsonDependencyVersion(cliSource, opts = {}) {
|
|
147
157
|
const normalized = normalizeCliSource(cliSource);
|
|
148
158
|
if (normalized === DEFAULT_NPM_CLI_SOURCE) {
|
|
159
|
+
const resolved = readCliPackageVersion(opts.packageJsonPath);
|
|
160
|
+
if (resolved) return `^${resolved}`;
|
|
161
|
+
return 'latest';
|
|
162
|
+
}
|
|
163
|
+
// github: / file: specs are valid package.json dependency values as-is
|
|
164
|
+
return getCliInstallSpec(cliSource);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the running CLI package version for dependency ranges.
|
|
169
|
+
* Order: explicit package.json path (tests) → package.json next to this
|
|
170
|
+
* package root → getDeployHubVersion() (covers __DEPLOYHUB_VERSION__ in
|
|
171
|
+
* pkg binaries). Never returns a hardcoded stale semver.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} [packageJsonPath]
|
|
174
|
+
* @returns {string|null}
|
|
175
|
+
*/
|
|
176
|
+
function readCliPackageVersion(packageJsonPath) {
|
|
177
|
+
const candidates = [];
|
|
178
|
+
if (packageJsonPath) {
|
|
179
|
+
candidates.push(packageJsonPath);
|
|
180
|
+
} else {
|
|
181
|
+
candidates.push(
|
|
182
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json')
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const pkgPath of candidates) {
|
|
149
187
|
try {
|
|
150
|
-
const pkgPath = path.join(
|
|
151
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
152
|
-
'../../package.json'
|
|
153
|
-
);
|
|
154
188
|
const pkg = fs.readJsonSync(pkgPath);
|
|
155
|
-
if (typeof pkg.version === 'string' && pkg.version.trim()) {
|
|
156
|
-
return
|
|
189
|
+
if (typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version.trim())) {
|
|
190
|
+
return pkg.version.trim();
|
|
157
191
|
}
|
|
158
192
|
} catch {
|
|
159
|
-
//
|
|
193
|
+
// try next
|
|
160
194
|
}
|
|
161
|
-
return 'latest';
|
|
162
195
|
}
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const v = getDeployHubVersion();
|
|
199
|
+
if (typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v.trim())) {
|
|
200
|
+
return v.trim();
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
165
206
|
}
|
|
166
207
|
|
|
167
208
|
/**
|
|
@@ -259,9 +300,41 @@ function hasKubernetesDeploy(deployEnvironments, environments) {
|
|
|
259
300
|
}
|
|
260
301
|
|
|
261
302
|
/**
|
|
303
|
+
* Secret name for the Configure-kubeconfig setup step.
|
|
304
|
+
* Must match the env that actually uses kubernetes — when the only k8s env is
|
|
305
|
+
* non-grandfathered (e.g. production), that is PRODUCTION_KUBECONFIG, not the
|
|
306
|
+
* unprefixed KUBECONFIG (which would never be injected and leave the file empty).
|
|
307
|
+
*
|
|
308
|
+
* LIMITATION (follow-up): GitHub Actions writes exactly ONE kubeconfig file per
|
|
309
|
+
* job. Multiple Kubernetes environments that target DIFFERENT clusters in the
|
|
310
|
+
* same workflow run are not yet fully supported — only the first/grandfathered
|
|
311
|
+
* k8s env's secret is used for the setup step. Track as a product follow-up
|
|
312
|
+
* before advertising multi-cluster multi-env CI.
|
|
313
|
+
*
|
|
314
|
+
* @param {string[]} deployEnvironments
|
|
315
|
+
* @param {Record<string, { type?: string, method?: string }>} environments
|
|
316
|
+
* @param {import('../core/config.js').DeployHubConfig|null} config
|
|
317
|
+
* @returns {string}
|
|
318
|
+
*/
|
|
319
|
+
function resolveKubeconfigWorkflowSecretName(deployEnvironments, environments, config) {
|
|
320
|
+
const cfg = { ...(config || {}), environments };
|
|
321
|
+
const k8sNames = deployEnvironments.filter(
|
|
322
|
+
(n) => getEnvMethod(environments[n]) === 'kubernetes'
|
|
323
|
+
);
|
|
324
|
+
if (k8sNames.length === 0) return 'KUBECONFIG';
|
|
325
|
+
const unprefixed = k8sNames.find((n) => !envUsesPrefixedSecrets(n, cfg));
|
|
326
|
+
const chosen = unprefixed || k8sNames[0];
|
|
327
|
+
return envUsesPrefixedSecrets(chosen, cfg)
|
|
328
|
+
? prefixSecretKey(chosen, 'KUBECONFIG')
|
|
329
|
+
: 'KUBECONFIG';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {string} [kubeconfigSecretName]
|
|
262
334
|
* @returns {string}
|
|
263
335
|
*/
|
|
264
|
-
function getKubernetesSetupSteps() {
|
|
336
|
+
function getKubernetesSetupSteps(kubeconfigSecretName = 'KUBECONFIG') {
|
|
337
|
+
// One kubeconfig file per job — see resolveKubeconfigWorkflowSecretName LIMITATION.
|
|
265
338
|
return ` - name: Setup kubectl
|
|
266
339
|
uses: azure/setup-kubectl@v4
|
|
267
340
|
with:
|
|
@@ -269,7 +342,7 @@ function getKubernetesSetupSteps() {
|
|
|
269
342
|
|
|
270
343
|
- name: Configure kubeconfig
|
|
271
344
|
env:
|
|
272
|
-
KUBECONFIG_SECRET: \${{ secrets
|
|
345
|
+
KUBECONFIG_SECRET: \${{ secrets.${kubeconfigSecretName} }}
|
|
273
346
|
run: |
|
|
274
347
|
mkdir -p "$GITHUB_WORKSPACE/.kube"
|
|
275
348
|
if echo "$KUBECONFIG_SECRET" | base64 -d > "$GITHUB_WORKSPACE/.kube/config" 2>/dev/null; then
|
|
@@ -316,27 +389,6 @@ function upsertWorkflowEnvLine(envVars, lhs, valueExpr) {
|
|
|
316
389
|
envVars.add(`${linePrefix}${valueExpr}`);
|
|
317
390
|
}
|
|
318
391
|
|
|
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
392
|
/**
|
|
341
393
|
* Shared env entries for deploy and rollback workflows (same secret resolution).
|
|
342
394
|
* Multi-env: each environment contributes its own CI secret names (grandfathered
|
|
@@ -400,9 +452,8 @@ export function buildWorkflowEnvEntries(
|
|
|
400
452
|
* @returns {string}
|
|
401
453
|
*/
|
|
402
454
|
function formatEnvironmentChoiceOptions(environments) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
);
|
|
455
|
+
// Single source of truth: getEnabledEnvironmentNames (do not re-filter here).
|
|
456
|
+
const names = getEnabledEnvironmentNames({ environments });
|
|
406
457
|
const options = [...names, 'all'];
|
|
407
458
|
return options.map((n) => ` - ${n}`).join('\n');
|
|
408
459
|
}
|
|
@@ -457,11 +508,10 @@ export function generateWorkflowYaml(
|
|
|
457
508
|
config = null
|
|
458
509
|
) {
|
|
459
510
|
const envNames = Object.keys(environments || {});
|
|
460
|
-
// Secret injection
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
const enabledEnvs =
|
|
464
|
-
const pushEnvs = listPushEnvironmentNames(environments);
|
|
511
|
+
// Secret injection uses ALL enabled environments (not push-only / pipelineDeployTargets).
|
|
512
|
+
// Prefer live enabled names over a possibly-stale deploy[] argument so no enabled
|
|
513
|
+
// env is missing from the job env block. Canonical helper: getEnabledEnvironmentNames.
|
|
514
|
+
const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
|
|
465
515
|
const allDeployNames =
|
|
466
516
|
enabledEnvs.length > 0
|
|
467
517
|
? enabledEnvs
|
|
@@ -469,28 +519,21 @@ export function generateWorkflowYaml(
|
|
|
469
519
|
? deployEnvironments
|
|
470
520
|
: envNames;
|
|
471
521
|
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
const
|
|
522
|
+
// CRITICAL: Build and Deploy (workflow_dispatch) MUST share the same secret union —
|
|
523
|
+
// every enabled environment. Filtering Build to push-only caused a live regression:
|
|
524
|
+
// Dispatch correctly got PRODUCTION_* while Build did not, so `deployhub build`'s
|
|
525
|
+
// push deploy stage failed on production with a missing SSH key. Trigger only
|
|
526
|
+
// controls which envs the CLI deploys; it must not control which secrets are injected.
|
|
527
|
+
const secretEnvs =
|
|
528
|
+
allDeployNames.length > 0 ? allDeployNames : envNames.slice(0, 1);
|
|
478
529
|
|
|
479
|
-
const
|
|
480
|
-
storageProviders,
|
|
481
|
-
buildSecretEnvs,
|
|
482
|
-
environments,
|
|
483
|
-
config
|
|
484
|
-
);
|
|
485
|
-
const buildEnvBlock = formatWorkflowEnvBlock(buildEnvVars);
|
|
486
|
-
|
|
487
|
-
const dispatchEnvVars = buildWorkflowEnvEntries(
|
|
530
|
+
const envVars = buildWorkflowEnvEntries(
|
|
488
531
|
storageProviders,
|
|
489
|
-
|
|
532
|
+
secretEnvs,
|
|
490
533
|
environments,
|
|
491
534
|
config
|
|
492
535
|
);
|
|
493
|
-
const
|
|
536
|
+
const envBlock = formatWorkflowEnvBlock(envVars);
|
|
494
537
|
|
|
495
538
|
const installSpec = getCliInstallSpec(cliSource);
|
|
496
539
|
const backendSteps = getBackendSetupSteps(config);
|
|
@@ -498,8 +541,13 @@ export function generateWorkflowYaml(
|
|
|
498
541
|
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
499
542
|
? `${getGithubGitConfigStep()}\n`
|
|
500
543
|
: '';
|
|
544
|
+
const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
|
|
545
|
+
allDeployNames,
|
|
546
|
+
environments,
|
|
547
|
+
config
|
|
548
|
+
);
|
|
501
549
|
const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
|
|
502
|
-
? `${getKubernetesSetupSteps()}\n`
|
|
550
|
+
? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
|
|
503
551
|
: '';
|
|
504
552
|
|
|
505
553
|
const projectType = config?.projectType || 'frontend';
|
|
@@ -527,7 +575,7 @@ ${envChoiceOptions}
|
|
|
527
575
|
? ` - name: Deploy (workflow_dispatch)
|
|
528
576
|
if: github.event_name == 'workflow_dispatch'
|
|
529
577
|
env:
|
|
530
|
-
${
|
|
578
|
+
${envBlock}
|
|
531
579
|
run: |
|
|
532
580
|
ENV_INPUT="\${{ inputs.environment }}"
|
|
533
581
|
if [ -z "$ENV_INPUT" ] || [ "$ENV_INPUT" = "all" ]; then
|
|
@@ -557,7 +605,7 @@ ${kubernetesSteps}${githubGitConfigStep} - name: Install project dependenci
|
|
|
557
605
|
- name: Build (and auto-deploy push-triggered envs)
|
|
558
606
|
run: ${getCliBuildCommand()}
|
|
559
607
|
env:
|
|
560
|
-
${
|
|
608
|
+
${envBlock}
|
|
561
609
|
${manualDeployStep}`;
|
|
562
610
|
|
|
563
611
|
return workflow;
|
|
@@ -589,7 +637,7 @@ export function generateRollbackWorkflowYaml(
|
|
|
589
637
|
config = null
|
|
590
638
|
) {
|
|
591
639
|
const envNames = Object.keys(environments || {});
|
|
592
|
-
const enabledEnvs =
|
|
640
|
+
const enabledEnvs = getEnabledEnvironmentNames({ ...(config || {}), environments });
|
|
593
641
|
const allDeployNames =
|
|
594
642
|
enabledEnvs.length > 0
|
|
595
643
|
? enabledEnvs
|
|
@@ -609,8 +657,13 @@ export function generateRollbackWorkflowYaml(
|
|
|
609
657
|
const githubGitConfigStep = isGithubCliSource(cliSource)
|
|
610
658
|
? `${getGithubGitConfigStep()}\n`
|
|
611
659
|
: '';
|
|
660
|
+
const kubeconfigSecret = resolveKubeconfigWorkflowSecretName(
|
|
661
|
+
allDeployNames,
|
|
662
|
+
environments,
|
|
663
|
+
config
|
|
664
|
+
);
|
|
612
665
|
const kubernetesSteps = hasKubernetesDeploy(allDeployNames, environments)
|
|
613
|
-
? `${getKubernetesSetupSteps()}\n`
|
|
666
|
+
? `${getKubernetesSetupSteps(kubeconfigSecret)}\n`
|
|
614
667
|
: '';
|
|
615
668
|
|
|
616
669
|
const rollbackCmd = getCliRollbackCommand();
|
|
@@ -790,13 +843,14 @@ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback')
|
|
|
790
843
|
/** @type {string[]} */
|
|
791
844
|
let targets;
|
|
792
845
|
if (kind === 'deploy') {
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
// secrets by using the broader enabled set via kind === 'rollback' / checklist.
|
|
797
|
-
targets = pushEnvs.length > 0 ? pushEnvs : enabled.slice(0, 1);
|
|
846
|
+
const enabled = getEnabledEnvironmentNames({ ...config, environments });
|
|
847
|
+
// Same union as generateWorkflowYaml Build + dispatch steps (all enabled).
|
|
848
|
+
targets = enabled.length > 0 ? enabled : allNames.slice(0, 1);
|
|
798
849
|
} else {
|
|
799
|
-
targets =
|
|
850
|
+
targets =
|
|
851
|
+
allNames.length > 0
|
|
852
|
+
? getEnabledEnvironmentNames({ ...config, environments })
|
|
853
|
+
: allNames;
|
|
800
854
|
if (targets.length === 0) targets = allNames;
|
|
801
855
|
}
|
|
802
856
|
|
|
@@ -851,7 +905,7 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
|
|
|
851
905
|
return { drifted: false, missingEnvs, missingSecrets, summary: '' };
|
|
852
906
|
}
|
|
853
907
|
|
|
854
|
-
const envNames =
|
|
908
|
+
const envNames = getEnabledEnvironmentNames(config);
|
|
855
909
|
const root = /** @type {Record<string, any>} */ (parsed);
|
|
856
910
|
const options = root?.on?.workflow_dispatch?.inputs?.environment?.options;
|
|
857
911
|
|
|
@@ -939,20 +993,133 @@ export async function getWorkflowDriftDoctorChecks(cwd, config) {
|
|
|
939
993
|
return checks;
|
|
940
994
|
}
|
|
941
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Extract a comparable base semver from a package.json dependency value
|
|
998
|
+
* (`^2.0.19`, `~2.0.19`, `2.0.19`). Returns null for `latest`, git URLs,
|
|
999
|
+
* or anything that is not a valid semver range/version.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {unknown} value
|
|
1002
|
+
* @returns {string|null}
|
|
1003
|
+
*/
|
|
1004
|
+
export function parseDependencyBaseVersion(value) {
|
|
1005
|
+
if (typeof value !== 'string') return null;
|
|
1006
|
+
const trimmed = value.trim();
|
|
1007
|
+
if (!trimmed) return null;
|
|
1008
|
+
if (
|
|
1009
|
+
trimmed === 'latest' ||
|
|
1010
|
+
trimmed.startsWith('github:') ||
|
|
1011
|
+
trimmed.startsWith('file:') ||
|
|
1012
|
+
trimmed.startsWith('git+') ||
|
|
1013
|
+
trimmed.includes('://')
|
|
1014
|
+
) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
const coerced = semver.coerce(trimmed);
|
|
1018
|
+
return coerced ? coerced.version : null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Decide whether to write a new DeployHub dependency version into a project
|
|
1023
|
+
* package.json. Never allows a lower semver to overwrite a higher existing pin.
|
|
1024
|
+
*
|
|
1025
|
+
* @param {string|null|undefined} existingValue
|
|
1026
|
+
* @param {string} proposedValue
|
|
1027
|
+
* @returns {{ write: boolean, value: string, warning: string|null }}
|
|
1028
|
+
*/
|
|
1029
|
+
export function decideDeployhubDependencyVersionWrite(existingValue, proposedValue) {
|
|
1030
|
+
const proposed = typeof proposedValue === 'string' ? proposedValue.trim() : '';
|
|
1031
|
+
if (!proposed) {
|
|
1032
|
+
return {
|
|
1033
|
+
write: false,
|
|
1034
|
+
value: typeof existingValue === 'string' ? existingValue : '',
|
|
1035
|
+
warning:
|
|
1036
|
+
'⚠ Skipped updating package.json dependency version: resolved DeployHub version was empty.',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (existingValue == null || existingValue === '') {
|
|
1041
|
+
return { write: true, value: proposed, warning: null };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const existingBase = parseDependencyBaseVersion(existingValue);
|
|
1045
|
+
const proposedBase = parseDependencyBaseVersion(proposed);
|
|
1046
|
+
|
|
1047
|
+
if (!existingBase || !proposedBase) {
|
|
1048
|
+
return {
|
|
1049
|
+
write: false,
|
|
1050
|
+
value: String(existingValue),
|
|
1051
|
+
warning:
|
|
1052
|
+
`⚠ Skipped updating package.json dependency version: could not compare ` +
|
|
1053
|
+
`existing "${existingValue}" with resolved "${proposed}" as semver. ` +
|
|
1054
|
+
`Leaving the existing entry untouched.`,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (semver.lt(proposedBase, existingBase)) {
|
|
1059
|
+
return {
|
|
1060
|
+
write: false,
|
|
1061
|
+
value: String(existingValue),
|
|
1062
|
+
warning:
|
|
1063
|
+
`⚠ Skipped updating package.json dependency version: the currently\n` +
|
|
1064
|
+
` resolved DeployHub CLI version (${proposedBase}) is lower than what's already\n` +
|
|
1065
|
+
` pinned (${existingBase}). Keeping the existing, newer version to avoid a\n` +
|
|
1066
|
+
` downgrade. If this is unexpected, check that you're running the\n` +
|
|
1067
|
+
` intended CLI version (which deployhub / npm ls -g @akash-chowdhury-24/deployhub).`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// existing <= proposed → write (first-time already handled; upgrade or same)
|
|
1072
|
+
return { write: true, value: proposed, warning: null };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
942
1075
|
/**
|
|
943
1076
|
* @param {string} cliSource
|
|
944
1077
|
* @param {string} [cwd]
|
|
1078
|
+
* @param {{ packageJsonPath?: string, proposedVersion?: string }} [opts]
|
|
1079
|
+
* `proposedVersion` / `packageJsonPath` are for tests (mock resolved CLI version).
|
|
945
1080
|
*/
|
|
946
|
-
export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd()) {
|
|
1081
|
+
export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd(), opts = {}) {
|
|
947
1082
|
const pkgPath = path.join(cwd, 'package.json');
|
|
948
1083
|
if (!(await fs.pathExists(pkgPath))) return;
|
|
949
1084
|
|
|
950
1085
|
const pkg = await fs.readJson(pkgPath);
|
|
951
1086
|
pkg.devDependencies = pkg.devDependencies || {};
|
|
1087
|
+
|
|
1088
|
+
const existingDev = pkg.devDependencies[NPM_PACKAGE];
|
|
1089
|
+
const existingProd =
|
|
1090
|
+
pkg.dependencies && typeof pkg.dependencies === 'object'
|
|
1091
|
+
? pkg.dependencies[NPM_PACKAGE]
|
|
1092
|
+
: undefined;
|
|
1093
|
+
const existing =
|
|
1094
|
+
existingDev != null && existingDev !== ''
|
|
1095
|
+
? existingDev
|
|
1096
|
+
: existingProd != null && existingProd !== ''
|
|
1097
|
+
? existingProd
|
|
1098
|
+
: null;
|
|
1099
|
+
|
|
952
1100
|
// package.json value must be a semver range / "latest" / git URL — never "name@version"
|
|
953
1101
|
// (that form is only for `npm install <spec>` via getCliInstallSpec).
|
|
954
|
-
|
|
1102
|
+
const proposed =
|
|
1103
|
+
typeof opts.proposedVersion === 'string' && opts.proposedVersion.trim()
|
|
1104
|
+
? opts.proposedVersion.trim()
|
|
1105
|
+
: getCliPackageJsonDependencyVersion(cliSource, opts);
|
|
1106
|
+
|
|
1107
|
+
const decision = decideDeployhubDependencyVersionWrite(existing, proposed);
|
|
1108
|
+
if (decision.warning) {
|
|
1109
|
+
console.log(chalk.yellow(decision.warning));
|
|
1110
|
+
}
|
|
1111
|
+
if (decision.write) {
|
|
1112
|
+
// Prefer updating whichever field already held the entry; default to devDependencies.
|
|
1113
|
+
if (existingProd != null && existingProd !== '' && (existingDev == null || existingDev === '')) {
|
|
1114
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
1115
|
+
pkg.dependencies[NPM_PACKAGE] = decision.value;
|
|
1116
|
+
} else {
|
|
1117
|
+
pkg.devDependencies[NPM_PACKAGE] = decision.value;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
955
1121
|
delete pkg.devDependencies.deployhub;
|
|
1122
|
+
if (pkg.dependencies) delete pkg.dependencies.deployhub;
|
|
956
1123
|
pkg.scripts = pkg.scripts || {};
|
|
957
1124
|
pkg.scripts['deployhub:build'] = 'deployhub build';
|
|
958
1125
|
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|