@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 +41 -11
- package/package.json +1 -1
- package/src/commands/doctor.js +6 -1
- package/src/commands/init.js +17 -3
- package/src/core/config.js +16 -0
- package/src/core/environments.js +127 -2
- package/src/core/stages.js +16 -3
- package/src/deployment/init-prompts.js +124 -34
- package/src/utils/github-actions.js +102 -6
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** |
|
|
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
|
|
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
|
|
859
|
-
5.
|
|
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
|
|
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
|
|
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
package/src/commands/doctor.js
CHANGED
|
@@ -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));
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
package/src/core/config.js
CHANGED
|
@@ -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
|
}
|
package/src/core/environments.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/stages.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|