@akash-chowdhury-24/deployhub 2.0.18 → 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 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).
@@ -1031,7 +1094,7 @@ Run `deployhub doctor` after any config change.
1031
1094
  | `deployhub clean` | Remove old local artifacts |
1032
1095
  | `deployhub update` | Check for CLI updates |
1033
1096
 
1034
- **Tests:** `npm test` — currently **270 passing** across the Jest suites.
1097
+ **Tests:** `npm test` — currently **320 passing** across the Jest suites.
1035
1098
 
1036
1099
  ## GitHub Secrets
1037
1100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -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,
@@ -144,28 +146,63 @@ export function getCliInstallSpec(cliSource) {
144
146
  * Version/range suitable as a package.json dependency VALUE for this CLI
145
147
  * (key is already NPM_PACKAGE — do not embed the package name again).
146
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
+ *
147
152
  * @param {string} [cliSource]
153
+ * @param {{ packageJsonPath?: string }} [opts] — test override: read this package.json
148
154
  * @returns {string}
149
155
  */
150
- export function getCliPackageJsonDependencyVersion(cliSource) {
156
+ export function getCliPackageJsonDependencyVersion(cliSource, opts = {}) {
151
157
  const normalized = normalizeCliSource(cliSource);
152
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) {
153
187
  try {
154
- const pkgPath = path.join(
155
- path.dirname(fileURLToPath(import.meta.url)),
156
- '../../package.json'
157
- );
158
188
  const pkg = fs.readJsonSync(pkgPath);
159
- if (typeof pkg.version === 'string' && pkg.version.trim()) {
160
- return `^${pkg.version.trim()}`;
189
+ if (typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version.trim())) {
190
+ return pkg.version.trim();
161
191
  }
162
192
  } catch {
163
- // fall through
193
+ // try next
164
194
  }
165
- return 'latest';
166
195
  }
167
- // github: / file: specs are valid package.json dependency values as-is
168
- return getCliInstallSpec(cliSource);
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;
169
206
  }
170
207
 
171
208
  /**
@@ -956,20 +993,133 @@ export async function getWorkflowDriftDoctorChecks(cwd, config) {
956
993
  return checks;
957
994
  }
958
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
+
959
1075
  /**
960
1076
  * @param {string} cliSource
961
1077
  * @param {string} [cwd]
1078
+ * @param {{ packageJsonPath?: string, proposedVersion?: string }} [opts]
1079
+ * `proposedVersion` / `packageJsonPath` are for tests (mock resolved CLI version).
962
1080
  */
963
- export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd()) {
1081
+ export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd(), opts = {}) {
964
1082
  const pkgPath = path.join(cwd, 'package.json');
965
1083
  if (!(await fs.pathExists(pkgPath))) return;
966
1084
 
967
1085
  const pkg = await fs.readJson(pkgPath);
968
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
+
969
1100
  // package.json value must be a semver range / "latest" / git URL — never "name@version"
970
1101
  // (that form is only for `npm install <spec>` via getCliInstallSpec).
971
- pkg.devDependencies[NPM_PACKAGE] = getCliPackageJsonDependencyVersion(cliSource);
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
+
972
1121
  delete pkg.devDependencies.deployhub;
1122
+ if (pkg.dependencies) delete pkg.dependencies.deployhub;
973
1123
  pkg.scripts = pkg.scripts || {};
974
1124
  pkg.scripts['deployhub:build'] = 'deployhub build';
975
1125
  await fs.writeJson(pkgPath, pkg, { spaces: 2 });