@akash-chowdhury-24/deployhub 2.0.34 → 2.0.35

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
@@ -297,6 +297,10 @@ deployhub logs # last deployment logs
297
297
 
298
298
  `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.
299
299
 
300
+ **Docker and Kubernetes image rollback:** DeployHub first looks for that `buildId` tag in the local Docker cache. If it is missing (the normal case on a GitHub Actions runner), it **pulls the image from the registry**. Rebuild-from-artifact is only tried if that pull also fails. Interpreted backends (Python, Node, PHP, Rails) still cannot rebuild from the artifact alone — keep `pipeline.docker` enabled so the original deploy pushed the tag. After a Docker rollback starts the container, DeployHub runs the same port-publish check as a normal deploy.
301
+
302
+ For session notes on how these behaviors were proven, see `CONTEXT.md`.
303
+
300
304
  ### Rollback is scoped per environment
301
305
 
302
306
  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.
@@ -458,7 +462,7 @@ Best when you serve static files from your own VPS or cloud VM. DeployHub upload
458
462
  | Deploy type | You provide |
459
463
  |-------------|-------------|
460
464
  | **ssh** | `SSH_HOST`, `SSH_USER`, `SSH_KEY`, deploy path |
461
- | **docker** | Docker host access / image registry per your setup |
465
+ | **docker** | Image registry; then **Where should the container run?** — local (this machine / CI), SSH (remote Linux `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` like **ec2**), or raw `DOCKER_HOST` |
462
466
  | **ec2** | SSH credentials to EC2 instance |
463
467
  | **azure-vm** / **gcp-vm** | SSH to VM |
464
468
  | **kubernetes** | Cluster credentials (via env / kubeconfig) |
@@ -523,6 +527,7 @@ Backends always deploy to a **self-hosted target** (SSH, Docker, EC2, Azure VM,
523
527
  | Storage | At least one provider |
524
528
  | Configure deployment? | Yes for storage + deploy |
525
529
  | Deployment type | ssh (most common), docker, ec2, kubernetes, … |
530
+ | Where should the container run? | Docker only: local / SSH (same `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` as **ec2**) / raw `DOCKER_HOST` — see [Docker](#docker) |
526
531
  | App name | PM2 process name on server |
527
532
  | Health check URL | e.g. `https://api.example.com/health` |
528
533
 
@@ -700,6 +705,8 @@ The deploy workflow (`deployhub.yml`) installs the correct language runtime (Nod
700
705
 
701
706
  When an environment uses Kubernetes, the workflow installs `kubectl` and writes kubeconfig from secrets — but only when that run actually needs cluster access (push with a push-triggered k8s env, or workflow_dispatch / rollback targeting a k8s env, `all`, or blank). Plain pushes that only auto-deploy non-k8s environments (e.g. EC2 development) skip those steps.
702
707
 
708
+ Map a git branch per environment during `init` / `env add` (`environments.<env>.branch`). The workflow then triggers only on those branches (for example `main` → production, `dev` → staging); other branches never run the pipeline. `workflow_dispatch` still lets you pick an environment by name. If you rename or delete a branch on GitHub, update `environments.<env>.branch` and run `deployhub sync-workflows` — doctor cannot see remote branch changes.
709
+
703
710
  To run a deploy manually: **Actions → DeployHub → Run workflow**.
704
711
 
705
712
  ### CI rollback (`deployhub-rollback.yml`)
@@ -712,6 +719,8 @@ To run a deploy manually: **Actions → DeployHub → Run workflow**.
712
719
 
713
720
  Already-initialized projects that only have `deployhub.yml` will not get the rollback workflow from a re-run of unrelated commands — run **`deployhub sync-workflows`** once to regenerate both workflow files from your current `deployhub.config.json`, then commit and push.
714
721
 
722
+ Docker and Kubernetes CI rollback **pulls** the restored `buildId` image from the registry when it is not cached on the runner (the usual case). Rebuild-from-artifact is only the last fallback — see [Rollback behavior](#rollback-behavior).
723
+
715
724
  ---
716
725
 
717
726
  ## Choosing storage providers
@@ -751,7 +760,7 @@ DeployHub supports six deployment targets. Pick based on what infrastructure you
751
760
  | Method | Best for | You need already |
752
761
  |--------|----------|------------------|
753
762
  | **ssh** | Any Linux VPS or bare-metal server you control | Server with SSH, key pair, app runtime |
754
- | **docker** | Containerized apps (Dockerfile or docker-compose.yml) | Docker locally or on a remote host |
763
+ | **docker** | Containerized apps (Dockerfile or docker-compose.yml) | Docker locally, a remote Linux host via SSH, or a raw `DOCKER_HOST` URI |
755
764
  | **ec2** | AWS users with an existing EC2 instance | Running EC2 instance, security group, key pair |
756
765
  | **azure-vm** | Azure users with an existing virtual machine | Running Azure VM, NSG allowing SSH |
757
766
  | **gcp-vm** | GCP users with an existing Compute Engine VM | Running VM, firewall rule for SSH, metadata SSH key |
@@ -791,6 +800,14 @@ Install Nginx if it is not already present:
791
800
 
792
801
  DeployHub detects whether the server uses Debian-style `sites-available` or RHEL-style `conf.d` at deploy time and writes a **uniquely named** config file for your project only — it does not overwrite unrelated Nginx configs.
793
802
 
803
+ **SSH-mode Docker** (`remote.mode: "ssh"`) does not use the deploy-path or Nginx steps above. The remote Linux host needs **Docker installed** and the SSH user in the `docker` group:
804
+
805
+ ```bash
806
+ sudo usermod -aG docker your-ssh-user
807
+ ```
808
+
809
+ Then **reconnect** (group membership applies on the next login). `deployhub doctor` reports this if missing (it prints the exact `usermod` line). See [Docker](#docker) below.
810
+
794
811
  ### SSH
795
812
 
796
813
  **Verification:** Real-world verified DEPLOY and ROLLBACK.
@@ -834,29 +851,41 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
834
851
 
835
852
  ### Docker
836
853
 
837
- **Verification:** Real-world verified DEPLOY and ROLLBACK (local and CI).
854
+ **Verification:** Real-world verified DEPLOY and ROLLBACK (local, SSH remote, and CI).
838
855
 
839
856
  **Prerequisites:**
840
- - [ ] Docker installed (`docker --version` works)
857
+ - [ ] Docker installed (`docker --version` works) — on this machine / CI for **local** and **raw**; on the remote Linux host for **ssh**
841
858
  - [ ] Registry account if pushing private images
842
859
  - [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
860
+ - [ ] **SSH mode only:** [one-time server setup](#one-time-server-setup-before-your-first-deploy) — Docker on the host and the SSH user in the `docker` group (`sudo usermod -aG docker <user>`, then reconnect)
861
+
862
+ `init` and `env add` ask **Where should the container run?**
863
+
864
+ | Choice | Mode | Meaning |
865
+ |--------|------|---------|
866
+ | Locally (this machine or CI runner) | `local` | Same machine as the CLI or GitHub Actions runner |
867
+ | Remote Linux server via SSH (recommended for production) | `ssh` | DeployHub validates the connection and runs `docker pull`/`run` over SSH. Set `SSH_HOST`, `SSH_USER`, and `SSH_KEY_PATH` (same names as the **ec2** method) |
868
+ | Advanced: raw Docker host URI | `raw` | You set `DOCKER_HOST` (`tcp://` or a custom `ssh://` setup you already manage) |
869
+
870
+ **Port (SSH mode):** a published port is required (asked as **Default port** during init / `env add`). Deploy fails clearly if it is missing, rather than starting an unreachable container. `deployhub doctor` and `deployhub verify` check that the running container has the mapping.
843
871
 
844
872
  **What DeployHub automates:**
845
873
  - Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
846
874
  - `.dockerignore` when missing (never overwrites an existing one)
847
- - `.env.example` for image name, registry, remote `DOCKER_HOST`
848
- - Docker daemon connectivity test during `init`
875
+ - `.env.example` for image name, registry, remote `DOCKER_HOST`, and SSH vars when `remote.mode` is `ssh`
876
+ - Docker daemon connectivity test during `init` (local / raw); SSH key, host reachability, remote daemon, and `docker` group permission when mode is `ssh`
849
877
  - Reuses the image built in the pipeline `docker` stage when present; otherwise builds from the artifact
850
878
  - Registry login + push when `DOCKER_REGISTRY_USERNAME` / `DOCKER_REGISTRY_TOKEN` are set
851
879
  - Auto-generates a unique image tag per build when `DOCKER_IMAGE_TAG` is unset (git SHA → CI run id → timestamp)
852
- - `docker compose up` or build/push/run during deploy
880
+ - `docker compose up` or build/push/run during deploy (`-p` for SSH-mode `docker run`)
853
881
 
854
882
  **After `init`:**
855
883
  1. Set `DOCKER_IMAGE_NAME` in `.env` (e.g. `myuser/myapp` for Docker Hub)
856
884
  2. For private registries (or any push): set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
857
885
  3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you intentionally want a fixed tag
858
- 4. For a remote Linux host, choose **Remote Linux server via SSH** at init (`remote.mode: "ssh"`) and set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` DeployHub runs `docker pull`/`run` over node-ssh. Use `DOCKER_HOST` only for the advanced raw CLI transport (`tcp://` or a custom `ssh://` setup you already manage)
859
- 5. Run `deployhub doctor`, then `git push origin main`
886
+ 4. For **ssh** mode: set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` (same names as the **ec2** method). Add GitHub Secrets `SSH_HOST`, `SSH_USER`, `SSH_KEY` for CI
887
+ 5. For **raw** mode only: set `DOCKER_HOST` to a URI you already manage (`tcp://` or custom `ssh://`)
888
+ 6. Run `deployhub doctor`, then `git push origin main`
860
889
 
861
890
  | Variable | Description | Example | Where to get it |
862
891
  |----------|-------------|---------|-----------------|
@@ -867,6 +896,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
867
896
  | `DOCKER_REGISTRY_TOKEN` | Registry password/token | *(secret)* | Docker Hub / GHCR PAT |
868
897
  | `DOCKER_HOST` | Advanced raw daemon URI (optional) | `tcp://host:2376` | Only if you manage TLS/ssh:// yourself |
869
898
  | `SSH_HOST` / `SSH_USER` / `SSH_KEY_PATH` | Remote Linux via SSH (`remote.mode: ssh`) | `203.0.113.10` / `ubuntu` / `~/.ssh/key.pem` | Same names as EC2 |
899
+ | `SSH_KEY` | Private key contents (CI only) | `-----BEGIN...` | Same key as `SSH_KEY_PATH` |
870
900
 
871
901
  ### AWS EC2
872
902
 
@@ -1013,7 +1043,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
1013
1043
  - Cluster connectivity test during `init`
1014
1044
  - 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
1015
1045
 
1016
- > **Limitation — interpreted backends (Node / Python / PHP / Rails) + rollback:** Kubernetes rollback forces a rebuild from the restored artifact when the exact `buildId` image is not already local. Those artifacts ship source/`composer.json` (etc.) but **not** installed deps (`vendor/`, `node_modules`, …), so DeployHub **refuses** with a clear error instead of a confusing Docker build failure. Successful rollback needs the restored image tag already present locally (or a prior `pipeline.docker` build that produced it). Same rule as standalone Docker deploy fallback.
1046
+ > **Limitation — interpreted backends (Node / Python / PHP / Rails) + rollback:** Kubernetes rollback uses the same image-resolution helper as Docker: local cache first, then **pull from the registry** (the normal case on a GitHub Actions runner). Rebuild-from-artifact is only the last fallback. Those artifacts ship source/`composer.json` (etc.) but **not** installed deps (`vendor/`, `node_modules`, …), so DeployHub **refuses** with a clear error instead of a confusing Docker build failure. Keep `pipeline.docker` so the original deploy pushed that tag. Same rule as standalone Docker.
1017
1047
 
1018
1048
  > **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.
1019
1049
 
@@ -1203,7 +1233,7 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
1203
1233
  | `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
1204
1234
  | `KUBECONFIG`, `KUBE_CONTEXT`, `KUBE_NAMESPACE`, `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_IMAGE_TAG`, `KUBE_IMAGE_PULL_SECRET` | Kubernetes — **`KUBECONFIG` in GitHub Secrets must be the kubeconfig file contents (or base64), not a filesystem path**. `DOCKER_IMAGE_TAG`, `KUBE_NAMESPACE`, `DOCKER_REGISTRY_URL`, and `KUBE_IMAGE_PULL_SECRET` are optional |
1205
1235
 
1206
- **Kubernetes rollback vs deploy — registry credentials:** Kubernetes **rollback** specifically **requires** `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`. Rollback rebuilds and must push a fresh image tagged with the restored `buildId`; without registry credentials it fails loudly and early (a rollback that cannot push can never succeed against a real cluster). This is stricter than a normal Kubernetes **deploy**, which may still allow local-only / no-push flows in some setups. If deploy worked without those secrets but rollback fails asking for them, that asymmetry is intentional.
1236
+ **Kubernetes rollback vs deploy — registry credentials:** Kubernetes **rollback** specifically **requires** `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`. Rollback pulls the restored `buildId` tag when it is not local, then must still push that image for the cluster; without registry credentials it fails loudly and early (a rollback that cannot push can never succeed against a real cluster). Rebuild-from-artifact is only the last fallback (interpreted backends refuse). This is stricter than a normal Kubernetes **deploy**, which may still allow local-only / no-push flows in some setups. If deploy worked without those secrets but rollback fails asking for them, that asymmetry is intentional.
1207
1237
 
1208
1238
  See [Deployment method guides](#deployment-method-guides) for full per-method variable tables with examples.
1209
1239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.34",
3
+ "version": "2.0.35",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -13,7 +13,7 @@ import {
13
13
  import { loadEnvArtifactHistory } from '../storage/index.js';
14
14
  import { testProvider } from '../storage/index.js';
15
15
  import { getDeploymentProvider } from '../deployment/index.js';
16
- import { PROVIDER_ENV_MAP, STORAGE_PROVIDER_IDS, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
16
+ import { PROVIDER_ENV_MAP, STORAGE_PROVIDER_IDS, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks, getBranchMappingDoctorCheck } from '../utils/github-actions.js';
17
17
  import { printDoctorFooter } from '../utils/author.js';
18
18
  import { createLocalProvider } from '../storage/providers/local.js';
19
19
  import {
@@ -1404,6 +1404,11 @@ export function registerDoctorCommand(program) {
1404
1404
  }
1405
1405
 
1406
1406
  if (config) {
1407
+ const branchCheck = getBranchMappingDoctorCheck(config);
1408
+ if (branchCheck) {
1409
+ informationalCheckNames.add(branchCheck.name);
1410
+ results.push(await runCheck(branchCheck.name, async () => branchCheck));
1411
+ }
1407
1412
  const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
1408
1413
  for (const check of driftChecks) {
1409
1414
  results.push(await runCheck(check.name, async () => check));
@@ -28,7 +28,6 @@ import {
28
28
  promptServerDeployment,
29
29
  buildServerEnvEntry,
30
30
  getDockerEnvSecrets,
31
- applyInitTriggerDefaults,
32
31
  formatMultiEnvTriggerReminder,
33
32
  SSH_BASED,
34
33
  } from '../deployment/init-prompts.js';
@@ -37,6 +36,11 @@ import {
37
36
  formatSecretChecklistLine,
38
37
  } from '../deployment/deployment-env.js';
39
38
  import { confirmValueIfContainsSpaces, normalizeInitHealthCheckUrl } from '../deployment/init-helpers.js';
39
+ import {
40
+ getWorkflowPushBranches,
41
+ formatBranchMappingSummary,
42
+ getEnvTrigger,
43
+ } from '../core/environments.js';
40
44
 
41
45
  const FRONTEND_CHOICES = [
42
46
  { name: 'React', value: 'react' },
@@ -383,6 +387,8 @@ export function registerInitCommand(program) {
383
387
  ...(opts.envName ? { envName: opts.envName } : {}),
384
388
  existingEnvNames: Object.keys(environments),
385
389
  portDefault: singleConfig?.port ?? backendConfig?.port,
390
+ defaultTrigger: deploy.length === 0 ? 'push' : 'manual',
391
+ defaultBranch: 'main',
386
392
  }
387
393
  );
388
394
  primaryDeployType = deployAnswers.deployType;
@@ -463,7 +469,8 @@ export function registerInitCommand(program) {
463
469
  defaultEnvironment = picked.defaultEnvironment;
464
470
  }
465
471
 
466
- applyInitTriggerDefaults(environments, deploy, defaultEnvironment);
472
+ // Trigger + branch come from per-env prompts (default: first env push/main,
473
+ // additional envs manual). Do not clobber those answers here.
467
474
 
468
475
  const version = await getProjectVersion(cwd);
469
476
  let hasDocker =
@@ -585,7 +592,14 @@ export function registerInitCommand(program) {
585
592
  console.log(' • .env.example');
586
593
  console.log('');
587
594
 
588
- if (deploy.length >= 2 && defaultEnvironment) {
595
+ if (deploy.length > 0) {
596
+ const mapped = getWorkflowPushBranches(config);
597
+ console.log(chalk.cyan(formatBranchMappingSummary(mapped)));
598
+ console.log('');
599
+ }
600
+
601
+ const hasManualEnv = deploy.some((name) => getEnvTrigger(environments[name]) === 'manual');
602
+ if (deploy.length >= 2 && defaultEnvironment && hasManualEnv) {
589
603
  console.log(
590
604
  chalk.yellow(
591
605
  formatMultiEnvTriggerReminder(String(defaultEnvironment), deploy)
@@ -15,6 +15,7 @@ import {
15
15
  buildEnvironmentEntry,
16
16
  resolveEnvTargets,
17
17
  mergeMethodSettingsIntoEnv,
18
+ normalizeGitBranchName,
18
19
  } from './environments.js';
19
20
 
20
21
  const SideConfigSchema = z.object({
@@ -83,6 +84,8 @@ const EnvironmentSchema = z.object({
83
84
  enabled: z.boolean().default(true),
84
85
  method: z.string(),
85
86
  trigger: z.enum(['push', 'manual']).default('manual'),
87
+ /** Git branch that auto-deploys this environment on push. Omitted = grandfathered main-only. */
88
+ branch: z.string().min(1).optional(),
86
89
  config: MethodConfigSchema.default({}),
87
90
  });
88
91
 
@@ -206,6 +209,7 @@ function extractMethodConfig(entry) {
206
209
  key === 'enabled' ||
207
210
  key === 'method' ||
208
211
  key === 'trigger' ||
212
+ key === 'branch' ||
209
213
  key === 'config' ||
210
214
  key === 'type'
211
215
  ) {
@@ -218,6 +222,16 @@ function extractMethodConfig(entry) {
218
222
  return config;
219
223
  }
220
224
 
225
+ /**
226
+ * Preserve `environments.<env>.branch` across migration without inventing one.
227
+ * @param {Record<string, unknown>} entry
228
+ * @returns {{ branch?: string }}
229
+ */
230
+ function copyEnvBranch(entry) {
231
+ const parsed = normalizeGitBranchName(entry.branch);
232
+ return parsed.ok ? { branch: parsed.name } : {};
233
+ }
234
+
221
235
  /**
222
236
  * @param {Record<string, unknown>} raw
223
237
  * @returns {boolean}
@@ -280,6 +294,7 @@ export function migrateConfigToEnvironments(raw) {
280
294
  enabled: entry.enabled !== false,
281
295
  method: String(entry.method),
282
296
  trigger: entry.trigger === 'push' ? 'push' : 'manual',
297
+ ...copyEnvBranch(entry),
283
298
  config: extractMethodConfig(entry),
284
299
  };
285
300
  continue;
@@ -292,6 +307,7 @@ export function migrateConfigToEnvironments(raw) {
292
307
  enabled,
293
308
  method,
294
309
  trigger: entry.trigger === 'push' ? 'push' : 'manual',
310
+ ...copyEnvBranch(entry),
295
311
  config: extractMethodConfig(entry),
296
312
  };
297
313
  }
@@ -139,6 +139,120 @@ export function getEnvTrigger(envEntry) {
139
139
  return e.trigger === 'push' ? 'push' : 'manual';
140
140
  }
141
141
 
142
+ /**
143
+ * Normalize a git branch name from config / prompts.
144
+ * Strips a leading `refs/heads/` if the user pasted a full ref.
145
+ *
146
+ * @param {unknown} input
147
+ * @returns {{ ok: true, name: string } | { ok: false, error: string }}
148
+ */
149
+ export function normalizeGitBranchName(input) {
150
+ const trimmed = typeof input === 'string' ? input.trim() : '';
151
+ if (!trimmed) {
152
+ return { ok: false, error: 'Branch name cannot be empty.' };
153
+ }
154
+ const stripped = trimmed.replace(/^refs\/heads\//, '');
155
+ if (!stripped) {
156
+ return { ok: false, error: 'Branch name cannot be empty.' };
157
+ }
158
+ if (/\s/.test(stripped)) {
159
+ return { ok: false, error: 'Branch name cannot contain whitespace.' };
160
+ }
161
+ return { ok: true, name: stripped };
162
+ }
163
+
164
+ /**
165
+ * @param {unknown} envEntry
166
+ * @returns {string|null}
167
+ */
168
+ export function getEnvBranch(envEntry) {
169
+ if (!envEntry || typeof envEntry !== 'object') return null;
170
+ const parsed = normalizeGitBranchName(
171
+ /** @type {Record<string, unknown>} */ (envEntry).branch
172
+ );
173
+ return parsed.ok ? parsed.name : null;
174
+ }
175
+
176
+ /**
177
+ * True when at least one environment has opted into `branch` mapping.
178
+ * Absence of every `branch` field = grandfathered main-only push trigger.
179
+ *
180
+ * @param {Record<string, unknown>} [config]
181
+ * @returns {boolean}
182
+ */
183
+ export function configHasBranchMapping(config) {
184
+ const envs = /** @type {Record<string, unknown>} */ (config?.environments || {});
185
+ return Object.values(envs).some((entry) => getEnvBranch(entry) != null);
186
+ }
187
+
188
+ /**
189
+ * Current git branch for a GitHub Actions push run.
190
+ * Tags and missing refs return null (no environment matches).
191
+ *
192
+ * @param {Record<string, string|undefined>} [env]
193
+ * @returns {string|null}
194
+ */
195
+ export function resolvePushBranchName(env = process.env) {
196
+ const ref = env.GITHUB_REF;
197
+ if (typeof ref === 'string' && ref.startsWith('refs/heads/')) {
198
+ return ref.slice('refs/heads/'.length) || null;
199
+ }
200
+ // Some harnesses set GITHUB_REF_NAME without GITHUB_REF.
201
+ if (typeof env.GITHUB_REF_NAME === 'string' && env.GITHUB_REF_NAME.trim()) {
202
+ if (typeof ref === 'string' && ref.startsWith('refs/tags/')) return null;
203
+ return env.GITHUB_REF_NAME.trim();
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Unique `on.push.branches` list for the generated workflow.
210
+ *
211
+ * - No environment has `branch` → `['main']` (today's hardcoded trigger).
212
+ * - Otherwise: unique branches of enabled `trigger: push` environments
213
+ * (an enabled push env without `branch` defaults to `main`).
214
+ * `main` is listed first when present. Empty if nothing should auto-trigger.
215
+ *
216
+ * @param {Record<string, unknown>} [config]
217
+ * @returns {string[]}
218
+ */
219
+ export function getWorkflowPushBranches(config) {
220
+ const envs = /** @type {Record<string, unknown>} */ (config?.environments || {});
221
+ if (!configHasBranchMapping(config || {})) {
222
+ return ['main'];
223
+ }
224
+
225
+ /** @type {string[]} */
226
+ const collected = [];
227
+ const seen = new Set();
228
+ for (const entry of Object.values(envs)) {
229
+ if (!isEnvEnabled(entry) || getEnvTrigger(entry) !== 'push') continue;
230
+ const branch = getEnvBranch(entry) || 'main';
231
+ if (seen.has(branch)) continue;
232
+ seen.add(branch);
233
+ collected.push(branch);
234
+ }
235
+
236
+ if (collected.includes('main')) {
237
+ return ['main', ...collected.filter((b) => b !== 'main')];
238
+ }
239
+ return collected;
240
+ }
241
+
242
+ /**
243
+ * Init / doctor copy. The second line makes the exclusion explicit.
244
+ *
245
+ * @param {string[]} branches
246
+ * @returns {string}
247
+ */
248
+ export function formatBranchMappingSummary(branches) {
249
+ const list = branches.length > 0 ? branches.join(', ') : '(none)';
250
+ return [
251
+ `Branches mapped to an environment: ${list}`,
252
+ 'Pushes to any other branch will not trigger DeployHub.',
253
+ ].join('\n');
254
+ }
255
+
142
256
  /**
143
257
  * @param {Record<string, unknown>} config
144
258
  * @returns {string|null}
@@ -196,7 +310,7 @@ export function getEnabledEnvironmentNames(config) {
196
310
  *
197
311
  * @param {string} method
198
312
  * @param {Record<string, unknown>} [settings]
199
- * @param {{ enabled?: boolean, trigger?: 'push'|'manual' }} [meta]
313
+ * @param {{ enabled?: boolean, trigger?: 'push'|'manual', branch?: string }} [meta]
200
314
  */
201
315
  export function buildEnvironmentEntry(method, settings = {}, meta = {}) {
202
316
  const cleaned = { ...settings };
@@ -204,11 +318,16 @@ export function buildEnvironmentEntry(method, settings = {}, meta = {}) {
204
318
  delete cleaned.method;
205
319
  delete cleaned.enabled;
206
320
  delete cleaned.trigger;
321
+ delete cleaned.branch;
207
322
  delete cleaned.config;
323
+ const trigger = meta.trigger === 'push' ? 'push' : 'manual';
324
+ const branchParsed =
325
+ trigger === 'push' && meta.branch != null ? normalizeGitBranchName(meta.branch) : null;
208
326
  return {
209
327
  enabled: meta.enabled !== false,
210
328
  method,
211
- trigger: meta.trigger === 'push' ? 'push' : 'manual',
329
+ trigger,
330
+ ...(branchParsed?.ok ? { branch: branchParsed.name } : {}),
212
331
  config: cleaned,
213
332
  };
214
333
  }
@@ -311,6 +430,12 @@ export default {
311
430
  getEnvSettings,
312
431
  isEnvEnabled,
313
432
  getEnvTrigger,
433
+ normalizeGitBranchName,
434
+ getEnvBranch,
435
+ configHasBranchMapping,
436
+ resolvePushBranchName,
437
+ getWorkflowPushBranches,
438
+ formatBranchMappingSummary,
314
439
  resolveDefaultEnvironmentName,
315
440
  getEnabledEnvironmentNames,
316
441
  buildEnvironmentEntry,
@@ -21,12 +21,17 @@ import {
21
21
  resolveDefaultEnvironmentName,
22
22
  isEnvEnabled,
23
23
  getEnvTrigger,
24
+ configHasBranchMapping,
25
+ getEnvBranch,
26
+ resolvePushBranchName,
24
27
  } from './environments.js';
25
28
 
26
29
  /**
27
30
  * Environments to deploy during `deployhub build` (pipeline.deploy).
28
31
  * - Local: default environment only (promote elsewhere via `deploy --env`).
29
- * - GitHub Actions push: every enabled env with trigger "push".
32
+ * - GitHub Actions push: every enabled env with trigger "push" whose `branch`
33
+ * matches the push ref. Configs with no `branch` on any environment keep
34
+ * today's behavior (all push-triggered envs, regardless of ref).
30
35
  * - workflow_dispatch: none here (explicit deploy step handles --env).
31
36
  *
32
37
  * Separate from workflow secret injection: CI may inject secrets for all
@@ -41,8 +46,16 @@ export function pipelineDeployTargets(config, env = process.env) {
41
46
  return [];
42
47
  }
43
48
  if (env.GITHUB_ACTIONS === 'true' || env.GITHUB_ACTIONS === '1') {
44
- return Object.entries(config.environments || {})
45
- .filter(([, entry]) => isEnvEnabled(entry) && getEnvTrigger(entry) === 'push')
49
+ const pushEnvs = Object.entries(config.environments || {}).filter(
50
+ ([, entry]) => isEnvEnabled(entry) && getEnvTrigger(entry) === 'push'
51
+ );
52
+ if (!configHasBranchMapping(config)) {
53
+ return pushEnvs.map(([name]) => name);
54
+ }
55
+ const branch = resolvePushBranchName(env);
56
+ if (!branch) return [];
57
+ return pushEnvs
58
+ .filter(([, entry]) => (getEnvBranch(entry) || 'main') === branch)
46
59
  .map(([name]) => name);
47
60
  }
48
61
  const def = resolveDefaultEnvironmentName(config);
@@ -1,6 +1,6 @@
1
1
  import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
- import { createEnvNamePromptValidate } from '../core/environments.js';
3
+ import { createEnvNamePromptValidate, normalizeGitBranchName } from '../core/environments.js';
4
4
  import {
5
5
  suggestSshUser,
6
6
  listKubeContexts,
@@ -58,15 +58,18 @@ export function backendProcessNamePromptMessage(framework, projectName, projectT
58
58
  * @param {'frontend'|'backend'|'both'} projectType
59
59
  * @param {Record<string, unknown>|null} backendConfig
60
60
  * @param {{
61
- * envName?: string,
62
- * existingEnvNames?: string[],
63
- * deployType?: string,
64
- * nonInteractive?: boolean,
65
- * portDefault?: number,
66
- * }} [options]
67
- * when envName is set, skip the name prompt; existingEnvNames blocks in-session / config duplicates
68
- * deployType skips the method list; nonInteractive uses defaults (requires deployType)
69
- * — portDefault seeds the docker "Default port" prompt (init / env add)
61
+ * envName?: string,
62
+ * existingEnvNames?: string[],
63
+ * deployType?: string,
64
+ * nonInteractive?: boolean,
65
+ * portDefault?: number,
66
+ * defaultTrigger?: 'push'|'manual',
67
+ * defaultBranch?: string,
68
+ * }} [options]
69
+ * — when envName is set, skip the name prompt; existingEnvNames blocks in-session / config duplicates
70
+ * — deployType skips the method list; nonInteractive uses defaults (requires deployType)
71
+ * — portDefault seeds the docker "Default port" prompt (init / env add)
72
+ * — defaultTrigger / defaultBranch seed the trigger-type and branch prompts
70
73
  */
71
74
  export async function promptServerDeployment(
72
75
  projectName,
@@ -128,20 +131,79 @@ export async function promptServerDeployment(
128
131
 
129
132
  const deployType = base.deployType;
130
133
 
134
+ /** @type {Record<string, unknown>} */
135
+ let methodAnswers;
131
136
  if (deployType === 'kubernetes') {
132
- return promptKubernetesDeployment(base, projectName, projectType, {
137
+ methodAnswers = await promptKubernetesDeployment(base, projectName, projectType, {
133
138
  existingEnvNames,
134
139
  });
135
- }
136
-
137
- if (deployType === 'docker') {
138
- return promptDockerDeployment(base, projectName, projectType, {
140
+ } else if (deployType === 'docker') {
141
+ methodAnswers = await promptDockerDeployment(base, projectName, projectType, {
139
142
  backendConfig,
140
143
  portDefault: options.portDefault,
141
144
  });
145
+ } else {
146
+ methodAnswers = await promptSshBasedDeployment(
147
+ base,
148
+ projectName,
149
+ projectType,
150
+ backendConfig,
151
+ deployType
152
+ );
153
+ }
154
+
155
+ const triggerMeta = await promptTriggerAndBranch(options);
156
+ return { ...methodAnswers, ...triggerMeta };
157
+ }
158
+
159
+ /**
160
+ * Trigger type + (when push) which branch fires this environment.
161
+ * `--yes` skips this entirely so existing non-interactive env add stays branch-less.
162
+ *
163
+ * @param {{ defaultTrigger?: 'push'|'manual', defaultBranch?: string }} [options]
164
+ */
165
+ export async function promptTriggerAndBranch(options = {}) {
166
+ const defaultTrigger = options.defaultTrigger === 'push' ? 'push' : 'manual';
167
+ const defaultBranch =
168
+ typeof options.defaultBranch === 'string' && options.defaultBranch.trim()
169
+ ? options.defaultBranch.trim()
170
+ : 'main';
171
+
172
+ const { trigger } = await inquirer.prompt([
173
+ {
174
+ type: 'list',
175
+ name: 'trigger',
176
+ message: 'When should this environment deploy?',
177
+ choices: [
178
+ { name: 'On git push to a branch', value: 'push' },
179
+ { name: 'Manually (GitHub Actions → Run workflow)', value: 'manual' },
180
+ ],
181
+ default: defaultTrigger,
182
+ },
183
+ ]);
184
+
185
+ if (trigger !== 'push') {
186
+ return { trigger: 'manual' };
142
187
  }
143
188
 
144
- return promptSshBasedDeployment(base, projectName, projectType, backendConfig, deployType);
189
+ const { branch } = await inquirer.prompt([
190
+ {
191
+ type: 'input',
192
+ name: 'branch',
193
+ message: 'Which branch triggers this environment?',
194
+ default: defaultBranch,
195
+ validate: (input) => {
196
+ const result = normalizeGitBranchName(input);
197
+ return result.ok ? true : result.error;
198
+ },
199
+ },
200
+ ]);
201
+
202
+ const parsed = normalizeGitBranchName(branch);
203
+ return {
204
+ trigger: 'push',
205
+ branch: parsed.ok ? parsed.name : defaultBranch,
206
+ };
145
207
  }
146
208
 
147
209
  /**
@@ -658,12 +720,15 @@ export function buildServerEnvEntry(
658
720
  settings.kubeNamespace = deployAnswers.kubeNamespace || projectName;
659
721
  settings.dockerImageName = deployAnswers.dockerImageName || projectName;
660
722
  settings.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
661
- return {
662
- enabled: true,
663
- method: 'kubernetes',
664
- trigger: 'manual',
665
- config: settings,
666
- };
723
+ return withTriggerAndBranch(
724
+ {
725
+ enabled: true,
726
+ method: 'kubernetes',
727
+ trigger: 'manual',
728
+ config: settings,
729
+ },
730
+ deployAnswers
731
+ );
667
732
  }
668
733
 
669
734
  if (deployAnswers.deployType === 'docker') {
@@ -689,12 +754,15 @@ export function buildServerEnvEntry(
689
754
  if (Number.isInteger(n) && n >= 1 && n <= 65535) {
690
755
  settings.port = n;
691
756
  }
692
- return {
693
- enabled: true,
694
- method: 'docker',
695
- trigger: 'manual',
696
- config: settings,
697
- };
757
+ return withTriggerAndBranch(
758
+ {
759
+ enabled: true,
760
+ method: 'docker',
761
+ trigger: 'manual',
762
+ config: settings,
763
+ },
764
+ deployAnswers
765
+ );
698
766
  }
699
767
 
700
768
  settings.host = deployAnswers.host || '';
@@ -731,12 +799,34 @@ export function buildServerEnvEntry(
731
799
  settings.path = settings.deployPath;
732
800
  }
733
801
 
734
- return {
735
- enabled: true,
736
- method: deployAnswers.deployType,
737
- trigger: 'manual',
738
- config: settings,
739
- };
802
+ return withTriggerAndBranch(
803
+ {
804
+ enabled: true,
805
+ method: deployAnswers.deployType,
806
+ trigger: 'manual',
807
+ config: settings,
808
+ },
809
+ deployAnswers
810
+ );
811
+ }
812
+
813
+ /**
814
+ * Overlay prompt answers onto the env entry. `--yes` / missing answers keep
815
+ * trigger `manual` and omit `branch` (backward compatible).
816
+ *
817
+ * @param {{ enabled: boolean, method: string, trigger: string, config: Record<string, unknown>, branch?: string }} entry
818
+ * @param {Record<string, unknown>} deployAnswers
819
+ */
820
+ function withTriggerAndBranch(entry, deployAnswers) {
821
+ const trigger = deployAnswers.trigger === 'push' ? 'push' : 'manual';
822
+ entry.trigger = trigger;
823
+ if (trigger === 'push' && deployAnswers.branch != null) {
824
+ const parsed = normalizeGitBranchName(deployAnswers.branch);
825
+ if (parsed.ok) entry.branch = parsed.name;
826
+ } else {
827
+ delete entry.branch;
828
+ }
829
+ return entry;
740
830
  }
741
831
 
742
832
  /**
@@ -22,6 +22,8 @@ import {
22
22
  getEnabledEnvironmentNames,
23
23
  isEnvEnabled,
24
24
  getEnvSettings,
25
+ getWorkflowPushBranches,
26
+ formatBranchMappingSummary,
25
27
  } from '../core/environments.js';
26
28
  import { resolvePhpVersion } from './php-version.js';
27
29
 
@@ -573,6 +575,33 @@ function formatEnvironmentChoiceOptions(environments) {
573
575
  return options.map((n) => ` - ${n}`).join('\n');
574
576
  }
575
577
 
578
+ /**
579
+ * YAML token for a git branch in `on.push.branches`.
580
+ * Unquoted when the name is a simple identifier; JSON-quoted otherwise.
581
+ *
582
+ * @param {string} branch
583
+ * @returns {string}
584
+ */
585
+ function formatYamlBranchToken(branch) {
586
+ if (/^[A-Za-z0-9._-]+$/.test(branch)) return branch;
587
+ return JSON.stringify(branch);
588
+ }
589
+
590
+ /**
591
+ * `on.push` block listing exactly the mapped trigger branches — or omitted
592
+ * when mapping mode has no enabled push environments (dispatch-only).
593
+ *
594
+ * @param {string[]} branches
595
+ * @returns {string}
596
+ */
597
+ export function formatPushTriggerYaml(branches) {
598
+ if (!branches || branches.length === 0) return '';
599
+ const list = branches.map(formatYamlBranchToken).join(', ');
600
+ return ` push:
601
+ branches: [${list}]
602
+ `;
603
+ }
604
+
576
605
  /**
577
606
  * @param {Set<string>} envVars
578
607
  * @param {string} [indent]
@@ -678,6 +707,9 @@ export function generateWorkflowYaml(
678
707
 
679
708
  const envChoiceOptions = formatEnvironmentChoiceOptions(environments);
680
709
  const hasEnvs = envNames.length > 0;
710
+ const cfgForBranches = { ...(config || {}), environments };
711
+ const pushBranches = getWorkflowPushBranches(cfgForBranches);
712
+ const pushTriggerYaml = formatPushTriggerYaml(pushBranches);
681
713
  const dispatchInputs = hasEnvs
682
714
  ? ` workflow_dispatch:
683
715
  inputs:
@@ -708,9 +740,7 @@ ${envBlock}
708
740
 
709
741
  const workflow = `${getWorkflowHeaderComment()}name: DeployHub
710
742
  on:
711
- push:
712
- branches: [main]
713
- ${dispatchInputs}jobs:
743
+ ${pushTriggerYaml}${dispatchInputs}jobs:
714
744
  deploy:
715
745
  runs-on: ubuntu-latest
716
746
  steps:
@@ -1036,13 +1066,17 @@ export function expectedWorkflowSecretKeysFromConfig(config, kind = 'rollback')
1036
1066
  * @param {string} yamlText
1037
1067
  * @param {import('../core/config.js').DeployHubConfig} config
1038
1068
  * @param {string} [filename]
1039
- * @returns {{ drifted: boolean, missingEnvs: string[], missingSecrets: string[], summary: string }}
1069
+ * @returns {{ drifted: boolean, missingEnvs: string[], missingSecrets: string[], missingBranches: string[], extraBranches: string[], summary: string }}
1040
1070
  */
1041
1071
  export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WORKFLOW_FILENAME) {
1042
1072
  /** @type {string[]} */
1043
1073
  const missingEnvs = [];
1044
1074
  /** @type {string[]} */
1045
1075
  const missingSecrets = [];
1076
+ /** @type {string[]} */
1077
+ const missingBranches = [];
1078
+ /** @type {string[]} */
1079
+ const extraBranches = [];
1046
1080
 
1047
1081
  let parsed;
1048
1082
  try {
@@ -1052,12 +1086,21 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
1052
1086
  drifted: false,
1053
1087
  missingEnvs,
1054
1088
  missingSecrets,
1089
+ missingBranches,
1090
+ extraBranches,
1055
1091
  summary: '',
1056
1092
  };
1057
1093
  }
1058
1094
 
1059
1095
  if (!parsed || typeof parsed !== 'object') {
1060
- return { drifted: false, missingEnvs, missingSecrets, summary: '' };
1096
+ return {
1097
+ drifted: false,
1098
+ missingEnvs,
1099
+ missingSecrets,
1100
+ missingBranches,
1101
+ extraBranches,
1102
+ summary: '',
1103
+ };
1061
1104
  }
1062
1105
 
1063
1106
  const envNames = getEnabledEnvironmentNames(config);
@@ -1081,7 +1124,30 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
1081
1124
  if (!fileSecrets.has(key)) missingSecrets.push(key);
1082
1125
  }
1083
1126
 
1084
- const drifted = missingEnvs.length > 0 || missingSecrets.length > 0;
1127
+ if (kind === 'deploy') {
1128
+ const expectedBranches = getWorkflowPushBranches(config);
1129
+ const actualRaw = root?.on?.push?.branches;
1130
+ /** @type {string[]} */
1131
+ const actualBranches = Array.isArray(actualRaw)
1132
+ ? actualRaw.map(String)
1133
+ : typeof actualRaw === 'string'
1134
+ ? [actualRaw]
1135
+ : [];
1136
+ const actualSet = new Set(actualBranches);
1137
+ const expectedSet = new Set(expectedBranches);
1138
+ for (const b of expectedBranches) {
1139
+ if (!actualSet.has(b)) missingBranches.push(b);
1140
+ }
1141
+ for (const b of actualBranches) {
1142
+ if (!expectedSet.has(b)) extraBranches.push(b);
1143
+ }
1144
+ }
1145
+
1146
+ const drifted =
1147
+ missingEnvs.length > 0 ||
1148
+ missingSecrets.length > 0 ||
1149
+ missingBranches.length > 0 ||
1150
+ extraBranches.length > 0;
1085
1151
  /** @type {string[]} */
1086
1152
  const parts = [];
1087
1153
  if (missingEnvs.length > 0) {
@@ -1095,11 +1161,23 @@ export function detectWorkflowConfigDrift(yamlText, config, filename = DEPLOY_WO
1095
1161
  `missing secret(s): ${shown.join(', ')}${missingSecrets.length > 4 ? ', …' : ''}`
1096
1162
  );
1097
1163
  }
1164
+ if (missingBranches.length > 0 || extraBranches.length > 0) {
1165
+ const bits = [];
1166
+ if (missingBranches.length > 0) {
1167
+ bits.push(`missing ${missingBranches.map((b) => `"${b}"`).join(', ')}`);
1168
+ }
1169
+ if (extraBranches.length > 0) {
1170
+ bits.push(`extra ${extraBranches.map((b) => `"${b}"`).join(', ')}`);
1171
+ }
1172
+ parts.push(`${bits.join('; ')} in on.push.branches`);
1173
+ }
1098
1174
 
1099
1175
  return {
1100
1176
  drifted,
1101
1177
  missingEnvs,
1102
1178
  missingSecrets,
1179
+ missingBranches,
1180
+ extraBranches,
1103
1181
  summary: parts.join('; '),
1104
1182
  };
1105
1183
  }
@@ -1148,6 +1226,24 @@ export async function getWorkflowDriftDoctorChecks(cwd, config) {
1148
1226
  return checks;
1149
1227
  }
1150
1228
 
1229
+ /**
1230
+ * Doctor helper: informational line listing which branches invoke the workflow.
1231
+ * Always pass: true — same pattern as workflow-drift (never blocks doctor exit).
1232
+ *
1233
+ * @param {import('../core/config.js').DeployHubConfig} config
1234
+ * @returns {{ name: string, pass: boolean, message: string } | null}
1235
+ */
1236
+ export function getBranchMappingDoctorCheck(config) {
1237
+ const envCount = Object.keys(config.environments || {}).length;
1238
+ if (envCount === 0) return null;
1239
+ const branches = getWorkflowPushBranches(config);
1240
+ return {
1241
+ name: 'Branch mapping',
1242
+ pass: true,
1243
+ message: formatBranchMappingSummary(branches).replace(/\n/g, ' '),
1244
+ };
1245
+ }
1246
+
1151
1247
  /**
1152
1248
  * Extract a comparable base semver from a package.json dependency value
1153
1249
  * (`^2.0.19`, `~2.0.19`, `2.0.19`). Returns null for `latest`, git URLs,