@akash-chowdhury-24/deployhub 2.0.40 → 2.0.43
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 +134 -15
- package/package.json +1 -1
- package/src/commands/doctor.js +10 -0
- package/src/core/config.js +10 -0
- package/src/deployment/deployment-env.js +46 -11
- package/src/deployment/env-file.js +344 -0
- package/src/deployment/hooks.js +16 -6
- package/src/deployment/index.js +3 -0
- package/src/deployment/init-prompts.js +99 -6
- package/src/deployment/providers/docker.js +30 -1
- package/src/deployment/providers/ssh.js +73 -10
- package/src/utils/docker-remote.js +7 -2
package/README.md
CHANGED
|
@@ -120,6 +120,9 @@ This interactive wizard will:
|
|
|
120
120
|
- Configure build commands and output directory
|
|
121
121
|
- Set up storage providers (AWS, Google Drive, Azure, GCP, Dropbox, Local)
|
|
122
122
|
- Optionally configure deployment targets (SSH, Docker, EC2, Azure VM, GCP VM, Kubernetes)
|
|
123
|
+
- For Docker: **Where should the container run?** (`local` / `ssh` / `raw`)
|
|
124
|
+
- For push-triggered environments: **Which branch triggers this environment?**
|
|
125
|
+
- Optional deploy hooks and optional DeployHub-managed `.env`
|
|
123
126
|
- Generate `deployhub.config.json`
|
|
124
127
|
- Generate `.github/workflows/deployhub.yml` and `.github/workflows/deployhub-rollback.yml`
|
|
125
128
|
- Generate `.env.example`
|
|
@@ -353,9 +356,25 @@ deployhub sync-workflows # regenerate deployhub.yml + dep
|
|
|
353
356
|
| First / grandfathered env in multi-env `init` | `"push"` |
|
|
354
357
|
| Additional environments | `"manual"` — deploy only via Actions → Run workflow or `deployhub deploy --env` |
|
|
355
358
|
|
|
356
|
-
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.
|
|
359
|
+
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, branches, or envs, run `deployhub sync-workflows` and commit the regenerated YAML.
|
|
357
360
|
|
|
358
|
-
On a GitHub Actions **push**, `deployhub build` only auto-deploys environments with `trigger: "push"
|
|
361
|
+
On a GitHub Actions **push**, `deployhub build` only auto-deploys environments with `trigger: "push"` whose `branch` matches the push ref. Environments with `trigger: "manual"` are never deployed on push — even though their secrets are present in the job for dispatch/rollback.
|
|
362
|
+
|
|
363
|
+
### Branch-to-environment mapping
|
|
364
|
+
|
|
365
|
+
Each environment can name the git branch that triggers it (`environments.<env>.branch`). `init` / `env add` ask when the trigger is push:
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
? Which branch triggers this environment?
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The generated workflow's `on.push.branches` lists **only** those mapped branches. A push to an unmapped branch (for example a personal working branch that feeds `dev` via PR) **never invokes the workflow at all** — not a skipped deploy, no pipeline run. `workflow_dispatch` (Actions → Run workflow) is unaffected: you still pick an environment by name.
|
|
372
|
+
|
|
373
|
+
Because storage history is per-environment and only written when a real pipeline run for that environment completes, an environment's rollback history can only ever contain builds that came from its own mapped branch.
|
|
374
|
+
|
|
375
|
+
Configs with **no** `branch` field anywhere keep today's behavior (push trigger is `main` only). If you rename or delete a branch on GitHub, edit `environments.<env>.branch` and run `deployhub sync-workflows` — doctor cannot see remote branch changes.
|
|
376
|
+
|
|
377
|
+
How branch mapping was proven (generated YAML, history isolation): `CONTEXT.md`.
|
|
359
378
|
|
|
360
379
|
### Secret naming
|
|
361
380
|
|
|
@@ -699,13 +718,13 @@ git commit -m "Add DeployHub CI"
|
|
|
699
718
|
|
|
700
719
|
1. Open **Settings → Secrets and variables → Actions** in your GitHub repo.
|
|
701
720
|
2. Add every secret listed at the end of `deployhub init` (storage + deployment).
|
|
702
|
-
3. Push to `main`
|
|
721
|
+
3. Push to a mapped branch (default `main` / `master`) — the deploy workflow triggers on push.
|
|
703
722
|
|
|
704
723
|
The deploy workflow (`deployhub.yml`) installs the correct language runtime (Node, Python, PHP, Java, Go, .NET, Ruby) based on your `deployhub.config.json`, installs DeployHub, runs `deployhub build`, and uses your secrets. For PHP projects, CI uses `shivammathur/setup-php` (default **8.4**; override with `phpVersion` / `backend.phpVersion`).
|
|
705
724
|
|
|
706
725
|
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.
|
|
707
726
|
|
|
708
|
-
|
|
727
|
+
Push triggers follow [branch-to-environment mapping](#branch-to-environment-mapping) (`environments.<env>.branch`). Example: `main` → production, `dev` → staging; a push to any other branch never starts this workflow. `workflow_dispatch` still lets you pick an environment by name.
|
|
709
728
|
|
|
710
729
|
To run a deploy manually: **Actions → DeployHub → Run workflow**.
|
|
711
730
|
|
|
@@ -755,17 +774,26 @@ You can enable **multiple providers** — DeployHub uploads to all of them in pa
|
|
|
755
774
|
|
|
756
775
|
## Custom deploy hooks
|
|
757
776
|
|
|
758
|
-
SSH-based deploys (`ssh`, `ec2`, `azure-vm`, `gcp-vm`, and `docker` with `remote.mode: "ssh"`) can run your own commands on the remote host as part of deploy and rollback. Use them for migrations, cache clearing, or a notification — anything you currently SSH in to do by hand.
|
|
777
|
+
SSH-based deploys (`ssh`, `ec2`, `azure-vm`, `gcp-vm`, and `docker` with `remote.mode: "ssh"`) can run your own commands on the remote host as part of deploy and rollback. Use them for host bootstrap, migrations, codegen, cache clearing, or a notification — anything you currently SSH in to do by hand.
|
|
778
|
+
|
|
779
|
+
Config shape: `environments.<env>.config.hooks` with four stage arrays. Each entry is `{ command, continueOnError, reconnect, timeoutMs }` (`command` is required; the rest are optional).
|
|
759
780
|
|
|
760
781
|
| Hook | When it runs | Typical use | Failure default |
|
|
761
782
|
|------|----------------|-------------|-----------------|
|
|
762
|
-
| `preDeploy` | After the artifact/image is ready and the host is reachable, **before**
|
|
763
|
-
| `
|
|
783
|
+
| `preDeploy` | After the artifact/image is ready and the host is reachable, **before** extract / `docker stop` | Host bootstrap (`dnf install -y docker`, `usermod`). **Not** for codegen that needs the new files | Abort the deploy (`continueOnError: false`) |
|
|
784
|
+
| `postInstall` | After the framework's dependency install (`pip install` / `npm install` / `composer install` / `bundle install`) and **before** the app process starts. Plain `ssh` / `ec2` / `azure-vm` / `gcp-vm` only | `prisma generate`, Django `collectstatic`, any codegen that needs installed deps **and** the extracted artifact | Abort (`continueOnError: false`) |
|
|
785
|
+
| `postDeploy` | After the app is up (SSH start / docker-ssh port publish). **Skipped on rollback** | Cache warm, Slack ping, non-critical cleanup | Continue (`continueOnError: true` when added via `init` / `env add`) |
|
|
764
786
|
| `rollback` | During `deployhub rollback`, in the same slot as `preDeploy` — before the restored version takes over | Your own down-migration. DeployHub does not reverse migrations for you | Abort the rollback (`continueOnError: false`) |
|
|
765
787
|
|
|
766
|
-
|
|
788
|
+
On rollback, `postInstall` **reuses the same stage name** (unlike `preDeploy`, which is replaced by the `rollback` stage). Codegen must run against the **restored** files, not leftover generated output from the version you are rolling back from.
|
|
789
|
+
|
|
790
|
+
`postInstall` is **not** asked or run for Docker `remote.mode: "ssh"`. Docker installs dependencies at **image build** time, not at deploy time, so there is no remote `pip install` / `npm install` slot between extract and `docker run`. Put `prisma generate` (and similar) in the Dockerfile. Kubernetes and Docker `local` / `raw` do not support hooks (no persistent remote shell session); configuring them there fails loudly at deploy/rollback.
|
|
791
|
+
|
|
792
|
+
> **Hooks do not `cd` for you.** Commands run in the SSH user's home directory unless you `cd` explicitly (`cd /var/www/app && …`). Loading a `.env` in the deploy path requires `set -a && . ./.env && set +a` **in the same command** — `cd` alone does not export those variables.
|
|
767
793
|
|
|
768
|
-
|
|
794
|
+
Commands run over the **existing** SSH session (not a second connection), unless a successful hook sets `"reconnect": true`. Optional `timeoutMs` overrides the session default (`DEPLOYHUB_SSH_EXEC_TIMEOUT_MS`, 120s) so a hung command fails instead of hanging CI.
|
|
795
|
+
|
|
796
|
+
On docker-ssh, `preDeploy` / `rollback` run **before** remote registry login and `docker stop`/`run`, so a hook can install Docker on a bare host. `postDeploy` runs **after** the port-publish inspect check (`docker inspect` confirms `0.0.0.0:<port>->`). A hook that curls the app's published port therefore sees a container DeployHub already treated as published. If that inspect fails, `postDeploy` does not run. `postInstall` is skipped entirely on this method.
|
|
769
797
|
|
|
770
798
|
Hook `command` strings are raw remote shell — there is **no** `{{buildId}}` / `{{containerName}}` / `{{port}}` / `{{environment}}` substitution. Hardcode values per environment (or read them from the remote environment). Extra Docker environments use an env-scoped container name (`{project}-{env}`; the first/grandfathered env stays `{project}`), so a hook that `docker exec myapp …` on staging will miss `myapp-staging`.
|
|
771
799
|
|
|
@@ -773,7 +801,9 @@ Hook commands that look like they embed a secret (`--password`, `-p secret`, `TO
|
|
|
773
801
|
|
|
774
802
|
Set `"reconnect": true` on a hook when the command only takes effect on a **new SSH login** — the usual case is `sudo usermod -aG docker $USER`. After that command succeeds, DeployHub closes the current session and opens a new one before the next hook or deploy step. Failed commands never reconnect. Omitted / `false` (the default) never reconnects.
|
|
775
803
|
|
|
776
|
-
`init` and `env add` ask optionally — default is skip. After each command they ask **Add another … command?** so one stage can collect several entries (no cap), and whether that command needs an SSH reconnect (`[y/N]`, default N). `--yes` / non-interactive env add still writes no hooks.
|
|
804
|
+
`init` and `env add` ask optionally — default is skip. After each command they ask **Add another … command?** so one stage can collect several entries (no cap), and whether that command needs an SSH reconnect (`[y/N]`, default N). `--yes` / non-interactive env add still writes no hooks.
|
|
805
|
+
|
|
806
|
+
Example — Amazon Linux bare host bootstrap (the docker-ssh `preDeploy` case) plus FastAPI + Prisma on SSH (the case that required `postInstall`):
|
|
777
807
|
|
|
778
808
|
```json
|
|
779
809
|
"environments": {
|
|
@@ -781,8 +811,11 @@ Set `"reconnect": true` on a hook when the command only takes effect on a **new
|
|
|
781
811
|
"config": {
|
|
782
812
|
"hooks": {
|
|
783
813
|
"preDeploy": [
|
|
784
|
-
{ "command": "sudo
|
|
785
|
-
{ "command": "
|
|
814
|
+
{ "command": "sudo dnf install -y docker && sudo systemctl enable --now docker", "timeoutMs": 180000 },
|
|
815
|
+
{ "command": "sudo usermod -aG docker ec2-user", "reconnect": true }
|
|
816
|
+
],
|
|
817
|
+
"postInstall": [
|
|
818
|
+
{ "command": "cd /var/www/app && python3.11 -m prisma generate", "continueOnError": false }
|
|
786
819
|
],
|
|
787
820
|
"postDeploy": [
|
|
788
821
|
{ "command": "curl -s https://hooks.slack.com/services/T000/B000/xxx -d deployed", "continueOnError": true }
|
|
@@ -796,6 +829,75 @@ Set `"reconnect": true` on a hook when the command only takes effect on a **new
|
|
|
796
829
|
}
|
|
797
830
|
```
|
|
798
831
|
|
|
832
|
+
When that `postInstall` also needs values from a DeployHub-managed `.env` (already on disk by then — see [DeployHub-managed `.env`](#deployhub-managed-env)):
|
|
833
|
+
|
|
834
|
+
```json
|
|
835
|
+
{ "command": "cd /var/www/app && set -a && . ./.env && set +a && prisma generate", "continueOnError": false }
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
`preDeploy` cannot run `prisma generate` on a fresh host: the artifact has not been unzipped yet, so `schema.prisma` is missing. `postDeploy` is too late: uvicorn already imported `from prisma import Prisma` and crashed with `Client hasn't been generated yet.` `postInstall` sits after `pip install` / `npm install` and before process start. The same `postInstall` command re-runs during rollback against the restored schema.
|
|
839
|
+
|
|
840
|
+
How hooks were proven on real hosts (including bare-host Docker install and Prisma codegen): `CONTEXT.md`.
|
|
841
|
+
|
|
842
|
+
---
|
|
843
|
+
|
|
844
|
+
## DeployHub-managed `.env`
|
|
845
|
+
|
|
846
|
+
> **Two ways to get a `.env` onto your server — don't mix them:**
|
|
847
|
+
> - **Managed** (`envFileSecretName` configured): DeployHub writes it for you from a GitHub Secret, and **OVERWRITES** it on every deploy to pick up secret updates.
|
|
848
|
+
> - **Hand-placed** (nothing configured): you put a `.env` on the server yourself once; DeployHub never touches it, on any future deploy or rollback.
|
|
849
|
+
|
|
850
|
+
| Mode | How it gets onto the server | Redeploy / rollback |
|
|
851
|
+
|------|-----------------------------|---------------------|
|
|
852
|
+
| **DeployHub-managed** (`environments.<env>.config.envFileSecretName` is set) | You paste the **full contents** of `.env` as a GitHub Secret. DeployHub SFTP-uploads it on every deploy. | **Overwritten every deploy** (and every rollback) with the **current** secret value. Secret updates take effect on the next deploy. `.env` is not versioned per `buildId`. |
|
|
853
|
+
| **Hand-placed** (no `envFileSecretName`) | You SSH in and create `.env` yourself under the deploy path. | **Left untouched.** `unzip -o` / `rsync -a` (no `--delete`) do not wipe extra files. This is the older behavior. |
|
|
854
|
+
|
|
855
|
+
> Rolling back **code** does not revert a secret change. Managed `.env` is not versioned per `buildId` — rollback re-delivers the **current** GitHub Secret. If you are rolling back because an env-var change broke the app, revert the secret as well (otherwise rollback still writes the broken value).
|
|
856
|
+
|
|
857
|
+
### Setup (GitHub Secret)
|
|
858
|
+
|
|
859
|
+
`init` / `env add` ask (SSH, EC2, Azure VM, GCP VM, and Docker `remote.mode: "ssh"` only; skipped for Kubernetes and Docker local/raw):
|
|
860
|
+
|
|
861
|
+
```
|
|
862
|
+
? Does this project have a .env file that should be deployed to the server? [y/N]
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
If yes, paste the **full contents** of the file as a GitHub Secret named:
|
|
866
|
+
|
|
867
|
+
- First / grandfathered environment: `ENV_FILE`
|
|
868
|
+
- Additional environments: `STAGING_ENV_FILE`, `PRODUCTION_ENV_FILE`, … (same `{ENV}_` prefix as `SSH_HOST`)
|
|
869
|
+
|
|
870
|
+
Then confirm. DeployHub **cannot** verify the secret exists ahead of time (GitHub Secrets are write-only). `deployhub doctor` reports that the **name** is referenced in the workflow, and says plainly that the **value** cannot be checked.
|
|
871
|
+
|
|
872
|
+
If `envFileSecretName` is set but the GitHub Secret was never created, is empty, or the name is misspelled, deploy **fails immediately** with:
|
|
873
|
+
|
|
874
|
+
```text
|
|
875
|
+
Managed .env is configured (GitHub Secret "ENV_FILE") but the value is missing or empty.
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
GitHub Actions injects an empty string for a missing secret, which hits this check. No empty `.env` is written to the target.
|
|
879
|
+
|
|
880
|
+
Config stores only the secret **name**:
|
|
881
|
+
|
|
882
|
+
```json
|
|
883
|
+
"config": {
|
|
884
|
+
"envFileSecretName": "ENV_FILE"
|
|
885
|
+
}
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
Never put the `.env` contents in `deployhub.config.json`. After adding the secret, run `deployhub sync-workflows` so `ENV_FILE` (or `STAGING_ENV_FILE`) is injected into the Actions job.
|
|
889
|
+
|
|
890
|
+
Transfer is binary SFTP (`ssh.putFile` — the same mechanism as the artifact zip), not `echo "$CONTENT" > .env`. Special characters (`$`, quotes, backticks, multi-line values) stay intact, and the secret never appears in `[ssh] $ …` / `[docker] $ …` command logs. The remote file is `chmod 600` and owned by the deploy user.
|
|
891
|
+
|
|
892
|
+
### Where the file lands
|
|
893
|
+
|
|
894
|
+
- **SSH / EC2 / Azure VM / GCP VM:** `{deployPath}/.env` (fullstack: both frontend and backend deploy paths). Apps that already load `.env` from the working directory (python-dotenv, etc.) pick it up with no extra flags. The file is written **after** unzip/rsync and **before** `postInstall` / process start, so a hook like `cd /var/www/app && set -a && . ./.env && set +a && prisma generate` sees the managed values. `preDeploy` runs before extract, so `.env` is not there yet on a fresh host.
|
|
895
|
+
- **Docker `remote.mode: "ssh"`:** `/opt/deployhub/envs/<project>-<env>/.env` on the **host**, then `docker run --env-file <that path> …` is wired in automatically. Project **and** environment are in the path so two apps (or staging + production) on one host never share a file. If `envFileSecretName` is unset, `docker run` is unchanged (no `--env-file`). `/opt/deployhub` is created automatically on first deploy. The one-time `sudo mkdir -p /opt/deployhub/envs && sudo chown $USER:$USER /opt/deployhub` step is a **rare fallback** — only if the host has neither a writable `/opt` nor passwordless sudo (see [one-time server setup](#one-time-server-setup-before-your-first-deploy)). Docker-ssh has no `postInstall`; bake codegen into the image.
|
|
896
|
+
|
|
897
|
+
Kubernetes is out of scope for this feature.
|
|
898
|
+
|
|
899
|
+
How managed `.env` was proven (byte-identical SFTP, empty-secret fail, postInstall sourcing): `CONTEXT.md`.
|
|
900
|
+
|
|
799
901
|
---
|
|
800
902
|
|
|
801
903
|
## Choosing a deployment method
|
|
@@ -830,6 +932,8 @@ sudo chown your-ssh-user:your-ssh-user /var/www/your-app-name
|
|
|
830
932
|
|
|
831
933
|
Replace `/var/www/your-app-name` with your actual deploy path and `your-ssh-user` with your configured `SSH_USER` (e.g. `ec2-user` on Amazon Linux, `ubuntu` on Ubuntu). Without this, DeployHub cannot write your build output — `deployhub doctor` will catch it and show the exact fix.
|
|
832
934
|
|
|
935
|
+
**Manually placed files (`.env`, etc.):** SSH / EC2 / Azure VM / GCP VM redeploy and rollback do **not** wipe the deploy directory. Extraction uses `unzip -o` (fullstack: `rsync -a` with no `--delete`) so only files present in the artifact are added or overwritten; a `.env` you place on the server yourself is left untouched **when this environment does not use DeployHub-managed `.env`**. If `envFileSecretName` is set, DeployHub **overwrites** `{deployPath}/.env` on every deploy — see [DeployHub-managed `.env`](#deployhub-managed-env). Do not mix the two modes. Keep secrets out of the zip. Docker-SSH is container-based and does not use this directory extract (managed `.env` for Docker-SSH uses `/opt/deployhub/envs/<project>-<env>/.env` + `--env-file`).
|
|
936
|
+
|
|
833
937
|
**Frontend deploys** that auto-activate `nginx.conf` also need **passwordless sudo** for Nginx test/reload (and `cp` into `/etc/nginx/`). After installing Nginx, run `sudo visudo` and add a line like:
|
|
834
938
|
|
|
835
939
|
```bash
|
|
@@ -853,6 +957,17 @@ sudo usermod -aG docker your-ssh-user
|
|
|
853
957
|
|
|
854
958
|
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.
|
|
855
959
|
|
|
960
|
+
**Managed `.env` directory:** DeployHub creates `/opt/deployhub/envs/<project>-<env>/` automatically on the first managed docker-ssh deploy — `mkdir -p`, then passwordless `sudo mkdir`/`chown` if the SSH user cannot write `/opt` (the same class of auto-create as SSH deploy-path `ensureWritableDeployDir`). Typical Ubuntu / Amazon Linux cloud images already have passwordless sudo for the default user, so there is **no extra one-time mkdir**.
|
|
961
|
+
|
|
962
|
+
If the SSH user cannot write `/opt` **and** has no passwordless sudo, deploy fails with the exact commands (same class of message as the docker-group `usermod` line above):
|
|
963
|
+
|
|
964
|
+
```bash
|
|
965
|
+
sudo mkdir -p /opt/deployhub/envs
|
|
966
|
+
sudo chown $USER:$USER /opt/deployhub
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Then retry the deploy. `deployhub doctor` cannot create that path (it does not write files over SSH).
|
|
970
|
+
|
|
856
971
|
You can also put that bootstrap in **preDeploy hooks** on a bare host (install Docker, `usermod`, `"reconnect": true`). Registry login runs **after** preDeploy, so those hooks get a chance to install `docker` before `docker login`. Failed login used to abort first (`docker: command not found`) and skip the hooks.
|
|
857
972
|
|
|
858
973
|
### SSH
|
|
@@ -904,7 +1019,7 @@ You can also put that bootstrap in **preDeploy hooks** on a bare host (install D
|
|
|
904
1019
|
- [ ] Docker installed (`docker --version` works) — on this machine / CI for **local** and **raw**; on the remote Linux host for **ssh**
|
|
905
1020
|
- [ ] Registry account if pushing private images
|
|
906
1021
|
- [ ] `docker-compose.yml` in project if you use multi-service Compose (not auto-generated)
|
|
907
|
-
- [ ] **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). A `preDeploy` hook can install Docker and run that `usermod` with `"reconnect": true`; registry login happens after preDeploy.
|
|
1022
|
+
- [ ] **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). Managed `.env` directories under `/opt/deployhub/envs` are created automatically when the SSH user can write `/opt` or has passwordless sudo. A `preDeploy` hook can install Docker and run that `usermod` with `"reconnect": true`; registry login happens after preDeploy.
|
|
908
1023
|
|
|
909
1024
|
`init` and `env add` ask **Where should the container run?**
|
|
910
1025
|
|
|
@@ -920,7 +1035,7 @@ You can also put that bootstrap in **preDeploy hooks** on a bare host (install D
|
|
|
920
1035
|
- Starter `Dockerfile` at project root when none exists (framework-aware; skipped if you already have one)
|
|
921
1036
|
- `.dockerignore` when missing (never overwrites an existing one)
|
|
922
1037
|
- `.env.example` for image name, registry, remote `DOCKER_HOST`, and SSH vars when `remote.mode` is `ssh`
|
|
923
|
-
- Docker daemon connectivity test during `init` (local / raw); SSH key, host reachability, remote daemon, and `docker` group
|
|
1038
|
+
- Docker daemon connectivity test during `init` (local / raw); SSH key validity, SSH host reachability, remote Docker daemon reachable, and remote Docker permission (`docker` group) when mode is `ssh`
|
|
924
1039
|
- Reuses the image built in the pipeline `docker` stage when present; otherwise builds from the artifact
|
|
925
1040
|
- Registry login + push when `DOCKER_REGISTRY_USERNAME` / `DOCKER_REGISTRY_TOKEN` are set
|
|
926
1041
|
- Auto-generates a unique image tag per build when `DOCKER_IMAGE_TAG` is unset (git SHA → CI run id → timestamp)
|
|
@@ -1175,6 +1290,9 @@ Prefer `deployhub init` over hand-writing config — it sets adapters, workflow,
|
|
|
1175
1290
|
| `Deploy requires storage upload` | Add at least one storage provider in config |
|
|
1176
1291
|
| AWS / GDrive check fails in `doctor` | Run `deployhub storage add <provider>` and match GitHub Secrets |
|
|
1177
1292
|
| SSH deploy fails | Verify `SSH_KEY_PATH` points to your private `.pem` file (or `SSH_KEY` in CI); user can write to deploy path; port 22 open |
|
|
1293
|
+
| SSH-mode Docker: permission denied talking to dockerd | SSH user is not in the `docker` group. On the server: `sudo usermod -aG docker <user>`, then reconnect. `deployhub doctor` prints this line. |
|
|
1294
|
+
| Docker-ssh container running but app unreachable | Missing published port. Set `environments.<env>.config.port` (asked as **Default port**). Doctor / `deployhub verify` check `0.0.0.0:<port>->`. |
|
|
1295
|
+
| `Managed .env is configured … missing or empty` | Add the GitHub Secret named in `envFileSecretName` (full `.env` file contents). Doctor cannot check the value — GitHub Secrets are write-only. |
|
|
1178
1296
|
| Wrong output uploaded | Fix `buildOutput` in config (`dist` vs `build` vs `.next`) |
|
|
1179
1297
|
| Tests fail in CI | Set `"pipeline": { "test": false }` temporarily, or fix tests |
|
|
1180
1298
|
| Monorepo subfolders | Edit `buildCommand` paths in `deployhub.config.json` after init |
|
|
@@ -1223,7 +1341,7 @@ Run `deployhub doctor` after any config change.
|
|
|
1223
1341
|
| `deployhub clean` | Remove old local artifacts |
|
|
1224
1342
|
| `deployhub update` | Check for CLI updates |
|
|
1225
1343
|
|
|
1226
|
-
**Tests:** `npm test` — currently **
|
|
1344
|
+
**Tests:** `npm test` — currently **466 passing** across the Jest suites (1 skipped).
|
|
1227
1345
|
|
|
1228
1346
|
## Storage vs deployment lookup credentials
|
|
1229
1347
|
|
|
@@ -1278,6 +1396,7 @@ Add these secrets in your repository (Settings → Secrets and variables → Act
|
|
|
1278
1396
|
| `AZURE_VM_LOOKUP_SUBSCRIPTION_ID`, `AZURE_VM_LOOKUP_RESOURCE_GROUP`, `AZURE_VM_LOOKUP_VM_NAME` | Optional Azure VM IP lookup |
|
|
1279
1397
|
| `GCP_VM_LOOKUP_PROJECT_ID`, `GCP_ZONE`, `GCP_INSTANCE_NAME`, `GCP_VM_LOOKUP_KEY_FILE` | Optional GCP VM IP lookup (project/key distinct from GCP Storage) |
|
|
1280
1398
|
| `DOCKER_IMAGE_NAME`, `DOCKER_REGISTRY_USERNAME`, `DOCKER_REGISTRY_TOKEN`, `DOCKER_REGISTRY_URL`, `DOCKER_HOST` | Docker deployment (`DOCKER_IMAGE_TAG` optional) |
|
|
1399
|
+
| `ENV_FILE` / `{ENV}_ENV_FILE` | DeployHub-managed `.env` — **full file contents**, only when `envFileSecretName` is set. Grandfathered env uses `ENV_FILE`; additional envs use `STAGING_ENV_FILE`, `PRODUCTION_ENV_FILE`, … (same prefix as `SSH_HOST`) |
|
|
1281
1400
|
| `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 |
|
|
1282
1401
|
|
|
1283
1402
|
**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.
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
preferredPhpFpmUnitName,
|
|
55
55
|
} from '../utils/php-fpm.js';
|
|
56
56
|
import { getHooksDoctorChecks } from '../deployment/hooks.js';
|
|
57
|
+
import { getEnvFileDoctorChecks } from '../deployment/env-file.js';
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
@@ -1414,6 +1415,15 @@ export function registerDoctorCommand(program) {
|
|
|
1414
1415
|
informationalCheckNames.add(hookCheck.name);
|
|
1415
1416
|
results.push(await runCheck(hookCheck.name, async () => hookCheck));
|
|
1416
1417
|
}
|
|
1418
|
+
let workflowText = '';
|
|
1419
|
+
const deployWorkflowPath = path.join(cwd, '.github', 'workflows', 'deployhub.yml');
|
|
1420
|
+
if (await fs.pathExists(deployWorkflowPath)) {
|
|
1421
|
+
workflowText = await fs.readFile(deployWorkflowPath, 'utf8');
|
|
1422
|
+
}
|
|
1423
|
+
for (const envFileCheck of getEnvFileDoctorChecks(config, { workflowText })) {
|
|
1424
|
+
informationalCheckNames.add(envFileCheck.name);
|
|
1425
|
+
results.push(await runCheck(envFileCheck.name, async () => envFileCheck));
|
|
1426
|
+
}
|
|
1417
1427
|
const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
|
|
1418
1428
|
for (const check of driftChecks) {
|
|
1419
1429
|
results.push(await runCheck(check.name, async () => check));
|
package/src/core/config.js
CHANGED
|
@@ -76,14 +76,24 @@ const MethodConfigSchema = z
|
|
|
76
76
|
/**
|
|
77
77
|
* Remote shell hooks for SSH-based methods (ssh / ec2 / azure-vm / gcp-vm /
|
|
78
78
|
* docker remote.mode ssh). Rejected on kubernetes and docker local/raw.
|
|
79
|
+
* `postInstall` is ssh/ec2/azure-vm/gcp-vm only (after remote dep install,
|
|
80
|
+
* before process start). Docker-ssh skips it: deps install at image build.
|
|
79
81
|
*/
|
|
80
82
|
hooks: z
|
|
81
83
|
.object({
|
|
82
84
|
preDeploy: z.array(HookCommandSchema).optional(),
|
|
85
|
+
postInstall: z.array(HookCommandSchema).optional(),
|
|
83
86
|
postDeploy: z.array(HookCommandSchema).optional(),
|
|
84
87
|
rollback: z.array(HookCommandSchema).optional(),
|
|
85
88
|
})
|
|
86
89
|
.optional(),
|
|
90
|
+
/**
|
|
91
|
+
* GitHub Secret *name* whose value is the full .env file. Never store the
|
|
92
|
+
* secret value here. SSH methods overwrite deployPath/.env every deploy;
|
|
93
|
+
* docker remote.mode ssh writes /opt/deployhub/envs/<project>-<env>/.env
|
|
94
|
+
* and passes --env-file. Unset = hand-placed .env (if any) is left alone.
|
|
95
|
+
*/
|
|
96
|
+
envFileSecretName: z.string().min(1).optional(),
|
|
87
97
|
appName: z.string().optional(),
|
|
88
98
|
framework: z.string().optional(),
|
|
89
99
|
port: z.number().optional(),
|
|
@@ -424,27 +424,62 @@ function dockerHasExplicitRemoteMode(settings) {
|
|
|
424
424
|
return mode === 'ssh' || mode === 'local' || mode === 'raw';
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
/** Added only when environments.<env>.config.envFileSecretName is set. */
|
|
428
|
+
const MANAGED_ENV_FILE_DEF = {
|
|
429
|
+
key: 'ENV_FILE',
|
|
430
|
+
optionalReason:
|
|
431
|
+
'only when this environment uses a DeployHub-managed .env (envFileSecretName in config)',
|
|
432
|
+
comment: [
|
|
433
|
+
'FULL CONTENTS of the project .env file (paste as a GitHub Secret).',
|
|
434
|
+
'Uploaded to the server on every deploy (including rollback) via SFTP — never committed.',
|
|
435
|
+
'Overwrites the remote .env each deploy. Do not also hand-place a .env for this environment.',
|
|
436
|
+
],
|
|
437
|
+
when: 'optional',
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* @param {string} deployType
|
|
442
|
+
* @param {Record<string, unknown>|null|undefined} settings
|
|
443
|
+
* @returns {boolean}
|
|
444
|
+
*/
|
|
445
|
+
function methodSupportsManagedEnvFile(deployType, settings) {
|
|
446
|
+
if (deployType === 'ssh' || deployType === 'ec2' || deployType === 'azure-vm' || deployType === 'gcp-vm') {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
if (deployType === 'docker') {
|
|
450
|
+
return resolveDockerRemoteMode(settings || {}, {}) === 'ssh';
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
427
455
|
/**
|
|
428
456
|
* Per-env docker defs: ssh mode adds SSH_* and drops raw DOCKER_HOST;
|
|
429
457
|
* explicit local drops DOCKER_HOST; configs with no remote.mode keep legacy defs.
|
|
458
|
+
* ENV_FILE is appended only when envFileSecretName is set on a supported method.
|
|
430
459
|
*
|
|
431
460
|
* @param {string} deployType
|
|
432
461
|
* @param {Record<string, unknown>|null} [settings]
|
|
433
462
|
* @returns {EnvVarDef[]}
|
|
434
463
|
*/
|
|
435
464
|
export function getMethodEnvDefs(deployType, settings = null) {
|
|
436
|
-
|
|
437
|
-
if (deployType
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
465
|
+
let defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
|
|
466
|
+
if (deployType === 'docker') {
|
|
467
|
+
const s = settings || {};
|
|
468
|
+
if (dockerHasExplicitRemoteMode(s)) {
|
|
469
|
+
const mode = resolveDockerRemoteMode(s, {});
|
|
470
|
+
if (mode === 'ssh') {
|
|
471
|
+
defs = [...defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key)), ...DOCKER_SSH_ENV_VARS];
|
|
472
|
+
} else if (mode === 'local') {
|
|
473
|
+
defs = defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
445
476
|
}
|
|
446
|
-
|
|
447
|
-
|
|
477
|
+
const secretName =
|
|
478
|
+
settings && typeof settings.envFileSecretName === 'string'
|
|
479
|
+
? settings.envFileSecretName.trim()
|
|
480
|
+
: '';
|
|
481
|
+
if (secretName && methodSupportsManagedEnvFile(deployType, settings)) {
|
|
482
|
+
defs = [...defs, MANAGED_ENV_FILE_DEF];
|
|
448
483
|
}
|
|
449
484
|
return defs;
|
|
450
485
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeployHub-managed .env: GitHub Secret name in config, binary SFTP of the
|
|
3
|
+
* secret value onto the deploy target. SSH methods write deployPath/.env
|
|
4
|
+
* (overwrite every deploy). Docker remote.mode "ssh" writes a per-project-
|
|
5
|
+
* per-env host path and passes --env-file to docker run.
|
|
6
|
+
*
|
|
7
|
+
* Never interpolate secret content into a shell command — the [ssh]/[docker]
|
|
8
|
+
* `$ …` logger prints every exec() string verbatim.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { createLogger } from '../logger/index.js';
|
|
15
|
+
import { shellQuote } from '../utils/shell-quote.js';
|
|
16
|
+
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
17
|
+
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
18
|
+
import { prefixSecretKey } from './deployment-env.js';
|
|
19
|
+
|
|
20
|
+
/** Unprefixed GitHub Actions / process.env key after secret overlay. */
|
|
21
|
+
export const MANAGED_ENV_FILE_UNPREFIXED_KEY = 'ENV_FILE';
|
|
22
|
+
|
|
23
|
+
/** Host directory for docker-ssh managed env files (project+env scoped). */
|
|
24
|
+
export const DOCKER_MANAGED_ENV_ROOT = '/opt/deployhub/envs';
|
|
25
|
+
|
|
26
|
+
const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} settings
|
|
30
|
+
* @returns {string|null}
|
|
31
|
+
*/
|
|
32
|
+
export function getEnvFileSecretName(settings = {}) {
|
|
33
|
+
const name = settings && /** @type {Record<string, unknown>} */ (settings).envFileSecretName;
|
|
34
|
+
if (typeof name !== 'string') return null;
|
|
35
|
+
const trimmed = name.trim();
|
|
36
|
+
return trimmed || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Record<string, unknown>} [settings]
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function envHasManagedEnvFile(settings = {}) {
|
|
44
|
+
return getEnvFileSecretName(settings) != null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* First environment (init / empty existing list) → ENV_FILE.
|
|
49
|
+
* Additional environments → {ENV}_ENV_FILE (same grandfathering as SSH_HOST).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} envName
|
|
52
|
+
* @param {string[]} [existingEnvNames]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function suggestEnvFileSecretName(envName, existingEnvNames = []) {
|
|
56
|
+
if (!Array.isArray(existingEnvNames) || existingEnvNames.length === 0) {
|
|
57
|
+
return MANAGED_ENV_FILE_UNPREFIXED_KEY;
|
|
58
|
+
}
|
|
59
|
+
return prefixSecretKey(envName || 'production', MANAGED_ENV_FILE_UNPREFIXED_KEY);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string|undefined} method
|
|
64
|
+
* @param {Record<string, unknown>} [settings]
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function envFileSupportedForMethod(method, settings = {}) {
|
|
68
|
+
if (SSH_BASED_METHODS.has(String(method || ''))) return true;
|
|
69
|
+
if (method === 'docker') {
|
|
70
|
+
return resolveDockerRemoteMode(settings) === 'ssh';
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Throw if a managed .env is configured on a method that cannot deliver it.
|
|
77
|
+
* No-op when envFileSecretName is unset (additive).
|
|
78
|
+
*
|
|
79
|
+
* @param {string|undefined} method
|
|
80
|
+
* @param {Record<string, unknown>} [settings]
|
|
81
|
+
* @param {string} [envName]
|
|
82
|
+
*/
|
|
83
|
+
export function assertEnvFileAllowed(method, settings = {}, envName = 'this environment') {
|
|
84
|
+
if (!envHasManagedEnvFile(settings)) return;
|
|
85
|
+
if (envFileSupportedForMethod(method, settings)) return;
|
|
86
|
+
|
|
87
|
+
let why;
|
|
88
|
+
if (method === 'kubernetes') {
|
|
89
|
+
why = 'Kubernetes is out of scope for DeployHub-managed .env (no SSH file transfer / docker --env-file).';
|
|
90
|
+
} else if (method === 'docker') {
|
|
91
|
+
why =
|
|
92
|
+
`Docker remote.mode "${resolveDockerRemoteMode(settings)}" has no DeployHub-managed SSH session ` +
|
|
93
|
+
`(managed .env requires remote.mode "ssh").`;
|
|
94
|
+
} else {
|
|
95
|
+
why = `Method "${method}" does not support DeployHub-managed .env.`;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Managed .env is configured for environment "${envName}" but is not supported: ${why} ` +
|
|
99
|
+
`Remove environments.${envName}.config.envFileSecretName or use ssh / ec2 / azure-vm / gcp-vm / docker (remote.mode ssh).`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the secret value from CI env. After applyEnvSecretOverlay the unprefixed
|
|
105
|
+
* ENV_FILE key is populated for prefixed environments. Never log the return value.
|
|
106
|
+
*
|
|
107
|
+
* @param {Record<string, string|undefined>} [env]
|
|
108
|
+
* @param {Record<string, unknown>} [settings]
|
|
109
|
+
* @returns {string|null} null when not configured
|
|
110
|
+
*/
|
|
111
|
+
export function readManagedEnvFileContent(env = {}, settings = {}) {
|
|
112
|
+
const name = getEnvFileSecretName(settings);
|
|
113
|
+
if (!name) return null;
|
|
114
|
+
const raw = env[MANAGED_ENV_FILE_UNPREFIXED_KEY] ?? env[name];
|
|
115
|
+
if (raw == null || raw === '') {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Managed .env is configured (GitHub Secret "${name}") but the value is missing or empty. ` +
|
|
118
|
+
`Add that secret with the FULL CONTENTS of your .env file. ` +
|
|
119
|
+
`DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return String(raw);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} value
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function sanitizePathSegment(value) {
|
|
130
|
+
return (
|
|
131
|
+
String(value || 'app')
|
|
132
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
133
|
+
.replace(/^-+|-+$/g, '') || 'app'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} remotePath
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
function stripTrailingSlash(remotePath) {
|
|
142
|
+
const s = String(remotePath || '').replace(/\\/g, '/');
|
|
143
|
+
if (s.length > 1 && s.endsWith('/')) return s.replace(/\/+$/, '');
|
|
144
|
+
return s || '/';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} remotePath
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
function posixDirname(remotePath) {
|
|
152
|
+
const normalized = stripTrailingSlash(remotePath);
|
|
153
|
+
const idx = normalized.lastIndexOf('/');
|
|
154
|
+
if (idx <= 0) return '/';
|
|
155
|
+
return normalized.slice(0, idx);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Docker-ssh host path: /opt/deployhub/envs/<project>-<env>/.env
|
|
160
|
+
* Always project AND environment scoped so two apps on one host never collide.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ project?: string }} config
|
|
163
|
+
* @param {string} envName
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
export function resolveDockerManagedEnvRemotePath(config, envName) {
|
|
167
|
+
const project = sanitizePathSegment(config?.project || 'app');
|
|
168
|
+
const env = sanitizePathSegment(envName || 'default');
|
|
169
|
+
return `${DOCKER_MANAGED_ENV_ROOT}/${project}-${env}/.env`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* SSH methods: deployPath/.env. Full-stack writes both frontend and backend
|
|
174
|
+
* deploy paths (same secret — one environment, two processes).
|
|
175
|
+
*
|
|
176
|
+
* @param {{ projectType?: string }} config
|
|
177
|
+
* @param {Record<string, unknown>} settings
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function resolveSshManagedEnvRemotePaths(config, settings = {}) {
|
|
181
|
+
const projectType = config?.projectType || 'frontend';
|
|
182
|
+
const deployPath = stripTrailingSlash(
|
|
183
|
+
String(settings.deployPath || settings.path || '/var/www/app')
|
|
184
|
+
);
|
|
185
|
+
if (projectType === 'both') {
|
|
186
|
+
const fe = stripTrailingSlash(String(settings.frontendDeployPath || deployPath));
|
|
187
|
+
const be = stripTrailingSlash(String(settings.backendDeployPath || deployPath));
|
|
188
|
+
return [...new Set([`${fe}/.env`, `${be}/.env`])];
|
|
189
|
+
}
|
|
190
|
+
return [`${deployPath}/.env`];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write content to a local temp file, SFTP it with ssh.putFile (binary, never
|
|
195
|
+
* shell-interpolated), chmod 600, then delete the local temp file.
|
|
196
|
+
*
|
|
197
|
+
* @param {{
|
|
198
|
+
* ssh: { putFile: Function },
|
|
199
|
+
* session: { exec: Function },
|
|
200
|
+
* content: string,
|
|
201
|
+
* remotePath: string,
|
|
202
|
+
* user: string,
|
|
203
|
+
* log?: { info: Function },
|
|
204
|
+
* }} opts
|
|
205
|
+
*/
|
|
206
|
+
export async function uploadManagedEnvFile(opts) {
|
|
207
|
+
const { ssh, session, content, remotePath, user } = opts;
|
|
208
|
+
const log = opts.log || createLogger('deploy');
|
|
209
|
+
if (typeof content !== 'string') {
|
|
210
|
+
throw new Error('Managed .env content must be a string');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deployhub-env-'));
|
|
214
|
+
const localPath = path.join(tmpDir, '.env');
|
|
215
|
+
try {
|
|
216
|
+
await fs.writeFile(localPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
217
|
+
const parent = posixDirname(remotePath);
|
|
218
|
+
const parentQ = shellQuote(parent);
|
|
219
|
+
const remoteQ = shellQuote(remotePath);
|
|
220
|
+
const userQ = shellQuote(user);
|
|
221
|
+
log.info(`Uploading managed .env to ${remotePath}`);
|
|
222
|
+
const dockerHostRoot = '/opt/deployhub';
|
|
223
|
+
const underDockerRoot =
|
|
224
|
+
parent === DOCKER_MANAGED_ENV_ROOT || parent.startsWith(`${DOCKER_MANAGED_ENV_ROOT}/`);
|
|
225
|
+
// Chown /opt/deployhub (not just the leaf) so later projects can mkdir as the
|
|
226
|
+
// deploy user without sudo — same idea as README `chown $USER:$USER /opt/deployhub`.
|
|
227
|
+
const chownTarget = underDockerRoot ? dockerHostRoot : parent;
|
|
228
|
+
const chownQ = shellQuote(chownTarget);
|
|
229
|
+
try {
|
|
230
|
+
await session.exec(ssh, `mkdir -p ${parentQ}`);
|
|
231
|
+
} catch {
|
|
232
|
+
try {
|
|
233
|
+
await session.exec(
|
|
234
|
+
ssh,
|
|
235
|
+
`sudo -n mkdir -p ${parentQ} && sudo -n chown -R ${userQ}:${userQ} ${chownQ}`
|
|
236
|
+
);
|
|
237
|
+
} catch {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Cannot create ${parent} for managed .env (the SSH user cannot write there).\n` +
|
|
240
|
+
` Run this on your server once:\n` +
|
|
241
|
+
` sudo mkdir -p ${parent}\n` +
|
|
242
|
+
` sudo chown ${user}:${user} ${chownTarget}\n` +
|
|
243
|
+
` (or grant passwordless sudo for mkdir/chown).`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await session.exec(
|
|
248
|
+
ssh,
|
|
249
|
+
`if [ ! -w ${parentQ} ]; then sudo -n chown ${userQ}:${userQ} ${parentQ}; fi`
|
|
250
|
+
);
|
|
251
|
+
await ssh.putFile(localPath, remotePath);
|
|
252
|
+
await session.exec(ssh, `chmod 600 ${remoteQ}`);
|
|
253
|
+
try {
|
|
254
|
+
await session.exec(ssh, `chown ${userQ}:${userQ} ${remoteQ}`);
|
|
255
|
+
} catch {
|
|
256
|
+
await session.exec(ssh, `sudo -n chown ${userQ}:${userQ} ${remoteQ}`);
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
await fs.remove(tmpDir).catch(() => {});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Collect secret names referenced as ${{ secrets.NAME }} in workflow YAML.
|
|
265
|
+
* Local copy so env-file.js does not import github-actions.js (cycle risk).
|
|
266
|
+
*
|
|
267
|
+
* @param {string} yaml
|
|
268
|
+
* @returns {string[]}
|
|
269
|
+
*/
|
|
270
|
+
function extractWorkflowSecretKeys(yaml) {
|
|
271
|
+
/** @type {Set<string>} */
|
|
272
|
+
const keys = new Set();
|
|
273
|
+
const re = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g;
|
|
274
|
+
let match;
|
|
275
|
+
while ((match = re.exec(String(yaml || ''))) !== null) {
|
|
276
|
+
keys.add(match[1]);
|
|
277
|
+
}
|
|
278
|
+
return [...keys];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Informational doctor lines — pass: true, never blocks.
|
|
283
|
+
* Cannot verify the secret VALUE (GitHub Secrets are write-only).
|
|
284
|
+
*
|
|
285
|
+
* @param {Record<string, unknown>} config
|
|
286
|
+
* @param {{ workflowText?: string }} [options]
|
|
287
|
+
* @returns {{ name: string, pass: boolean, message: string }[]}
|
|
288
|
+
*/
|
|
289
|
+
export function getEnvFileDoctorChecks(config, options = {}) {
|
|
290
|
+
const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
|
|
291
|
+
const workflowKeys = new Set(extractWorkflowSecretKeys(options.workflowText || ''));
|
|
292
|
+
const hasWorkflowText =
|
|
293
|
+
typeof options.workflowText === 'string' && options.workflowText.length > 0;
|
|
294
|
+
/** @type {{ name: string, pass: boolean, message: string }[]} */
|
|
295
|
+
const checks = [];
|
|
296
|
+
|
|
297
|
+
for (const [name, entry] of Object.entries(envs)) {
|
|
298
|
+
const settings = getEnvSettings(entry);
|
|
299
|
+
const secretName = getEnvFileSecretName(settings);
|
|
300
|
+
if (!secretName) continue;
|
|
301
|
+
const method = getEnvMethod(entry);
|
|
302
|
+
const supported = envFileSupportedForMethod(method, settings);
|
|
303
|
+
const listed = workflowKeys.has(secretName);
|
|
304
|
+
let listing;
|
|
305
|
+
if (!hasWorkflowText) {
|
|
306
|
+
listing =
|
|
307
|
+
`Workflow file not available here — cannot confirm "${secretName}" is in the secret list. ` +
|
|
308
|
+
`Run deployhub sync-workflows after enabling managed .env.`;
|
|
309
|
+
} else if (listed) {
|
|
310
|
+
listing = `GitHub Secret "${secretName}" is referenced in the deploy workflow's secret list.`;
|
|
311
|
+
} else {
|
|
312
|
+
listing =
|
|
313
|
+
`GitHub Secret "${secretName}" is NOT referenced in the deploy workflow ` +
|
|
314
|
+
`(run deployhub sync-workflows).`;
|
|
315
|
+
}
|
|
316
|
+
const limitation =
|
|
317
|
+
'Secret VALUES cannot be verified (GitHub Secrets are write-only).';
|
|
318
|
+
const overwrite =
|
|
319
|
+
'DeployHub-managed .env is overwritten every deploy; do not also hand-place a .env for this environment.';
|
|
320
|
+
checks.push({
|
|
321
|
+
name: `Managed .env (${name})`,
|
|
322
|
+
pass: true,
|
|
323
|
+
message: supported
|
|
324
|
+
? `Managed .env for '${name}': ${listing} ${limitation} ${overwrite}`
|
|
325
|
+
: `Managed .env configured for '${name}' (secret "${secretName}") but ${method} does not deliver it — remove envFileSecretName or use an SSH-based method / docker remote.mode ssh. ${limitation}`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return checks;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export default {
|
|
332
|
+
MANAGED_ENV_FILE_UNPREFIXED_KEY,
|
|
333
|
+
DOCKER_MANAGED_ENV_ROOT,
|
|
334
|
+
getEnvFileSecretName,
|
|
335
|
+
envHasManagedEnvFile,
|
|
336
|
+
suggestEnvFileSecretName,
|
|
337
|
+
envFileSupportedForMethod,
|
|
338
|
+
assertEnvFileAllowed,
|
|
339
|
+
readManagedEnvFileContent,
|
|
340
|
+
resolveDockerManagedEnvRemotePath,
|
|
341
|
+
resolveSshManagedEnvRemotePaths,
|
|
342
|
+
uploadManagedEnvFile,
|
|
343
|
+
getEnvFileDoctorChecks,
|
|
344
|
+
};
|
package/src/deployment/hooks.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-environment remote shell hooks (preDeploy / postDeploy / rollback).
|
|
2
|
+
* Per-environment remote shell hooks (preDeploy / postInstall / postDeploy / rollback).
|
|
3
3
|
* One implementation for ssh, ec2, azure-vm, gcp-vm, and docker remote.mode ssh.
|
|
4
4
|
* Callers must pass the deploy's existing SSH session — do not connect again.
|
|
5
|
+
* `postInstall` is invoked only from ssh.js `runBackendStartSequence` (plain SSH
|
|
6
|
+
* methods). Docker-ssh never runs it — image build already installed dependencies.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { createLogger } from '../logger/index.js';
|
|
@@ -9,10 +11,10 @@ import { formatRemoteCommandFailure } from '../utils/shell-quote.js';
|
|
|
9
11
|
import { getEnvMethod, getEnvSettings } from '../core/environments.js';
|
|
10
12
|
import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
|
|
11
13
|
|
|
12
|
-
/** @typedef {'preDeploy'|'postDeploy'|'rollback'} HookStage */
|
|
14
|
+
/** @typedef {'preDeploy'|'postInstall'|'postDeploy'|'rollback'} HookStage */
|
|
13
15
|
|
|
14
16
|
/** @type {HookStage[]} */
|
|
15
|
-
export const HOOK_STAGES = ['preDeploy', 'postDeploy', 'rollback'];
|
|
17
|
+
export const HOOK_STAGES = ['preDeploy', 'postInstall', 'postDeploy', 'rollback'];
|
|
16
18
|
|
|
17
19
|
const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
|
|
18
20
|
|
|
@@ -75,16 +77,17 @@ function normalizeHookList(raw) {
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* @param {Record<string, unknown>} [settings]
|
|
78
|
-
* @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
|
|
80
|
+
* @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postInstall: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
|
|
79
81
|
*/
|
|
80
82
|
export function getEnvHooks(settings = {}) {
|
|
81
83
|
const raw = settings.hooks;
|
|
82
84
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
83
|
-
return { preDeploy: [], postDeploy: [], rollback: [] };
|
|
85
|
+
return { preDeploy: [], postInstall: [], postDeploy: [], rollback: [] };
|
|
84
86
|
}
|
|
85
87
|
const h = /** @type {Record<string, unknown>} */ (raw);
|
|
86
88
|
return {
|
|
87
89
|
preDeploy: normalizeHookList(h.preDeploy),
|
|
90
|
+
postInstall: normalizeHookList(h.postInstall),
|
|
88
91
|
postDeploy: normalizeHookList(h.postDeploy),
|
|
89
92
|
rollback: normalizeHookList(h.rollback),
|
|
90
93
|
};
|
|
@@ -96,7 +99,13 @@ export function getEnvHooks(settings = {}) {
|
|
|
96
99
|
*/
|
|
97
100
|
export function envHasAnyHooks(settings = {}) {
|
|
98
101
|
const h = getEnvHooks(settings);
|
|
99
|
-
return
|
|
102
|
+
return (
|
|
103
|
+
h.preDeploy.length +
|
|
104
|
+
h.postInstall.length +
|
|
105
|
+
h.postDeploy.length +
|
|
106
|
+
h.rollback.length >
|
|
107
|
+
0
|
|
108
|
+
);
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
/**
|
|
@@ -241,6 +250,7 @@ export function getHooksDoctorChecks(config) {
|
|
|
241
250
|
const h = getEnvHooks(settings);
|
|
242
251
|
const parts = [];
|
|
243
252
|
if (h.preDeploy.length) parts.push(`${h.preDeploy.length} preDeploy`);
|
|
253
|
+
if (h.postInstall.length) parts.push(`${h.postInstall.length} postInstall`);
|
|
244
254
|
if (h.postDeploy.length) parts.push(`${h.postDeploy.length} postDeploy`);
|
|
245
255
|
if (h.rollback.length) parts.push(`${h.rollback.length} rollback`);
|
|
246
256
|
const method = getEnvMethod(entry);
|
package/src/deployment/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getEnvSettings,
|
|
12
12
|
} from '../core/environments.js';
|
|
13
13
|
import { assertHooksAllowed } from './hooks.js';
|
|
14
|
+
import { assertEnvFileAllowed } from './env-file.js';
|
|
14
15
|
import { applyEnvSecretOverlay } from './deployment-env.js';
|
|
15
16
|
import { recordEnvDeployment } from '../storage/index.js';
|
|
16
17
|
import { buildArtifactRemoteKey } from '../utils/build-id.js';
|
|
@@ -84,6 +85,7 @@ export async function deployToAll(config, artifactDir, envNames) {
|
|
|
84
85
|
|
|
85
86
|
const method = getEnvMethod(envConfig);
|
|
86
87
|
assertHooksAllowed(method, getEnvSettings(envConfig), envName);
|
|
88
|
+
assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
|
|
87
89
|
const provider = getDeploymentProvider(method, config, envName);
|
|
88
90
|
log.info(`Deploying to ${envName} (${method})...`);
|
|
89
91
|
await provider.deploy(artifactDir);
|
|
@@ -133,6 +135,7 @@ export async function rollbackAll(config, artifactDir, envNames, meta) {
|
|
|
133
135
|
const envConfig = config.environments[envName];
|
|
134
136
|
const method = getEnvMethod(envConfig);
|
|
135
137
|
assertHooksAllowed(method, getEnvSettings(envConfig), envName);
|
|
138
|
+
assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
|
|
136
139
|
const provider = getDeploymentProvider(method, config, envName);
|
|
137
140
|
await provider.rollback(artifactDir, meta);
|
|
138
141
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { createEnvNamePromptValidate, normalizeGitBranchName } from '../core/environments.js';
|
|
4
|
+
import { suggestEnvFileSecretName } from './env-file.js';
|
|
4
5
|
import {
|
|
5
6
|
suggestSshUser,
|
|
6
7
|
listKubeContexts,
|
|
@@ -154,12 +155,17 @@ export async function promptServerDeployment(
|
|
|
154
155
|
|
|
155
156
|
const triggerMeta = await promptTriggerAndBranch(options);
|
|
156
157
|
const hookMeta = await promptDeployHooksIfSupported(deployType, methodAnswers);
|
|
157
|
-
|
|
158
|
+
const envFileMeta = await promptManagedEnvFileIfSupported(deployType, methodAnswers, {
|
|
159
|
+
existingEnvNames,
|
|
160
|
+
envName: methodAnswers.envName || options.envName,
|
|
161
|
+
});
|
|
162
|
+
return { ...methodAnswers, ...triggerMeta, ...hookMeta, ...envFileMeta };
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/**
|
|
161
|
-
* Optional pre/post/rollback commands. Skipped unless the method
|
|
162
|
-
* DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
|
|
166
|
+
* Optional pre/post-install/post/rollback commands. Skipped unless the method
|
|
167
|
+
* has a DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
|
|
168
|
+
* `postInstall` is ssh-based only — Docker-ssh installs deps at image build time.
|
|
163
169
|
*
|
|
164
170
|
* @param {string} deployType
|
|
165
171
|
* @param {Record<string, unknown>} methodAnswers
|
|
@@ -170,7 +176,65 @@ async function promptDeployHooksIfSupported(deployType, methodAnswers) {
|
|
|
170
176
|
if (!sshBased && !dockerSsh) {
|
|
171
177
|
return {};
|
|
172
178
|
}
|
|
173
|
-
return promptDeployHooks();
|
|
179
|
+
return promptDeployHooks({ includePostInstall: sshBased });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Optional DeployHub-managed .env (GitHub Secret). Same method scope as hooks:
|
|
184
|
+
* ssh / ec2 / azure-vm / gcp-vm / docker-ssh. `--yes` never enters this path.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} deployType
|
|
187
|
+
* @param {Record<string, unknown>} methodAnswers
|
|
188
|
+
* @param {{ existingEnvNames?: string[], envName?: string }} [options]
|
|
189
|
+
*/
|
|
190
|
+
async function promptManagedEnvFileIfSupported(deployType, methodAnswers, options = {}) {
|
|
191
|
+
const sshBased = SSH_BASED.includes(deployType);
|
|
192
|
+
const dockerSsh = deployType === 'docker' && methodAnswers.remoteMode === 'ssh';
|
|
193
|
+
if (!sshBased && !dockerSsh) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
return promptManagedEnvFile({
|
|
197
|
+
envName: options.envName || methodAnswers.envName || 'production',
|
|
198
|
+
existingEnvNames: options.existingEnvNames || [],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {{ envName?: string, existingEnvNames?: string[] }} [options]
|
|
204
|
+
* @returns {Promise<{ envFileSecretName?: string }>}
|
|
205
|
+
*/
|
|
206
|
+
export async function promptManagedEnvFile(options = {}) {
|
|
207
|
+
const envName = options.envName || 'production';
|
|
208
|
+
const existingEnvNames = options.existingEnvNames || [];
|
|
209
|
+
const suggested = suggestEnvFileSecretName(envName, existingEnvNames);
|
|
210
|
+
|
|
211
|
+
const { deployEnvFile } = await inquirer.prompt([
|
|
212
|
+
{
|
|
213
|
+
type: 'confirm',
|
|
214
|
+
name: 'deployEnvFile',
|
|
215
|
+
message: 'Does this project have a .env file that should be deployed to the server?',
|
|
216
|
+
default: false,
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
if (!deployEnvFile) return {};
|
|
220
|
+
|
|
221
|
+
console.log(
|
|
222
|
+
chalk.gray(
|
|
223
|
+
`\n Paste the FULL CONTENTS of your .env file as a GitHub Secret named ${suggested}, then confirm below.\n` +
|
|
224
|
+
` DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).\n`
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await inquirer.prompt([
|
|
229
|
+
{
|
|
230
|
+
type: 'confirm',
|
|
231
|
+
name: 'envFileSecretAdded',
|
|
232
|
+
message: `Confirm once you've added the secret ${suggested}`,
|
|
233
|
+
default: false,
|
|
234
|
+
},
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
return { envFileSecretName: suggested };
|
|
174
238
|
}
|
|
175
239
|
|
|
176
240
|
/**
|
|
@@ -247,11 +311,13 @@ async function promptHookStageCommands(opts) {
|
|
|
247
311
|
}
|
|
248
312
|
|
|
249
313
|
/**
|
|
314
|
+
* @param {{ includePostInstall?: boolean }} [options]
|
|
250
315
|
* @returns {Promise<{ hooks?: Record<string, { command: string, continueOnError: boolean }[]> }>}
|
|
251
316
|
*/
|
|
252
|
-
export async function promptDeployHooks() {
|
|
317
|
+
export async function promptDeployHooks(options = {}) {
|
|
253
318
|
/** @type {Record<string, { command: string, continueOnError: boolean }[]>} */
|
|
254
319
|
const hooks = {};
|
|
320
|
+
const includePostInstall = options.includePostInstall !== false;
|
|
255
321
|
|
|
256
322
|
const preDeploy = await promptHookStageCommands({
|
|
257
323
|
firstConfirmName: 'addPreDeploy',
|
|
@@ -263,6 +329,19 @@ export async function promptDeployHooks() {
|
|
|
263
329
|
});
|
|
264
330
|
if (preDeploy.length) hooks.preDeploy = preDeploy;
|
|
265
331
|
|
|
332
|
+
if (includePostInstall) {
|
|
333
|
+
const postInstall = await promptHookStageCommands({
|
|
334
|
+
firstConfirmName: 'addPostInstall',
|
|
335
|
+
firstMessage:
|
|
336
|
+
'Add a post-install command? (e.g. prisma generate, collectstatic — runs after dependencies install, before the app starts)',
|
|
337
|
+
anotherMessage: 'Add another post-install command?',
|
|
338
|
+
commandMessage: 'Post-install command:',
|
|
339
|
+
abortMessage: 'Abort deploy if this command fails?',
|
|
340
|
+
abortDefault: true,
|
|
341
|
+
});
|
|
342
|
+
if (postInstall.length) hooks.postInstall = postInstall;
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
const postDeploy = await promptHookStageCommands({
|
|
267
346
|
firstConfirmName: 'addPostDeploy',
|
|
268
347
|
firstMessage: 'Add a post-deploy command? (e.g. clear cache, notify)',
|
|
@@ -885,6 +964,7 @@ export function buildServerEnvEntry(
|
|
|
885
964
|
settings.port = n;
|
|
886
965
|
}
|
|
887
966
|
attachHooksFromAnswers(settings, deployAnswers);
|
|
967
|
+
attachEnvFileFromAnswers(settings, deployAnswers);
|
|
888
968
|
return withTriggerAndBranch(
|
|
889
969
|
{
|
|
890
970
|
enabled: true,
|
|
@@ -931,6 +1011,7 @@ export function buildServerEnvEntry(
|
|
|
931
1011
|
}
|
|
932
1012
|
|
|
933
1013
|
attachHooksFromAnswers(settings, deployAnswers);
|
|
1014
|
+
attachEnvFileFromAnswers(settings, deployAnswers);
|
|
934
1015
|
return withTriggerAndBranch(
|
|
935
1016
|
{
|
|
936
1017
|
enabled: true,
|
|
@@ -951,11 +1032,23 @@ function attachHooksFromAnswers(settings, deployAnswers) {
|
|
|
951
1032
|
const raw = deployAnswers && deployAnswers.hooks;
|
|
952
1033
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
|
|
953
1034
|
const hooks = /** @type {Record<string, unknown>} */ (raw);
|
|
954
|
-
if (hooks.preDeploy || hooks.postDeploy || hooks.rollback) {
|
|
1035
|
+
if (hooks.preDeploy || hooks.postInstall || hooks.postDeploy || hooks.rollback) {
|
|
955
1036
|
settings.hooks = hooks;
|
|
956
1037
|
}
|
|
957
1038
|
}
|
|
958
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* Store the GitHub Secret *name* only — never the .env contents.
|
|
1042
|
+
* @param {Record<string, unknown>} settings
|
|
1043
|
+
* @param {Record<string, unknown>} deployAnswers
|
|
1044
|
+
*/
|
|
1045
|
+
function attachEnvFileFromAnswers(settings, deployAnswers) {
|
|
1046
|
+
const name = deployAnswers && deployAnswers.envFileSecretName;
|
|
1047
|
+
if (typeof name === 'string' && name.trim()) {
|
|
1048
|
+
settings.envFileSecretName = name.trim();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
959
1052
|
/**
|
|
960
1053
|
* Overlay prompt answers onto the env entry. `--yes` / missing answers keep
|
|
961
1054
|
* trigger `manual` and omit `branch` (backward compatible).
|
|
@@ -6,6 +6,12 @@ import { resolveDockerContainerName } from '../../utils/docker-container-name.js
|
|
|
6
6
|
import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
|
|
7
7
|
import { createSshExecSession } from '../ssh-connection.js';
|
|
8
8
|
import { assertHooksAllowed, runDeployHooks } from '../hooks.js';
|
|
9
|
+
import {
|
|
10
|
+
assertEnvFileAllowed,
|
|
11
|
+
readManagedEnvFileContent,
|
|
12
|
+
resolveDockerManagedEnvRemotePath,
|
|
13
|
+
uploadManagedEnvFile,
|
|
14
|
+
} from '../env-file.js';
|
|
9
15
|
import { resolveDockerRemoteMode } from '../../utils/docker-remote-mode.js';
|
|
10
16
|
import {
|
|
11
17
|
resolveDockerSshTarget,
|
|
@@ -68,6 +74,10 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
68
74
|
*/
|
|
69
75
|
async function deploy(artifactDir, options = {}) {
|
|
70
76
|
assertHooksAllowed('docker', settings, envName);
|
|
77
|
+
assertEnvFileAllowed('docker', settings, envName);
|
|
78
|
+
// Fail fast (before image pull / SSH): missing or empty GitHub Secret must
|
|
79
|
+
// not reach uploadManagedEnvFile. Actions injects "" for a never-created name.
|
|
80
|
+
readManagedEnvFileContent(effectiveEnv, settings);
|
|
71
81
|
const imageRef = options.fullImage || fullImage;
|
|
72
82
|
log.info(`Deploying via Docker (image: ${imageRef})...`);
|
|
73
83
|
|
|
@@ -159,7 +169,15 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
159
169
|
*/
|
|
160
170
|
async function deployOverSsh(imageRef, port, options = {}) {
|
|
161
171
|
const isRollback = options.isRollback === true;
|
|
162
|
-
|
|
172
|
+
// Current secret value (not the old build's). Rollback re-delivers this too —
|
|
173
|
+
// managed .env is not versioned per buildId.
|
|
174
|
+
const envContent = readManagedEnvFileContent(effectiveEnv, settings);
|
|
175
|
+
const envFilePath =
|
|
176
|
+
envContent != null ? resolveDockerManagedEnvRemotePath(config, envName) : undefined;
|
|
177
|
+
const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, {
|
|
178
|
+
publishPort: port,
|
|
179
|
+
envFile: envFilePath,
|
|
180
|
+
});
|
|
163
181
|
const session = sshSession();
|
|
164
182
|
let ssh = await session.connect();
|
|
165
183
|
try {
|
|
@@ -184,6 +202,17 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
184
202
|
);
|
|
185
203
|
}
|
|
186
204
|
|
|
205
|
+
if (envContent != null && envFilePath) {
|
|
206
|
+
await uploadManagedEnvFile({
|
|
207
|
+
ssh,
|
|
208
|
+
session,
|
|
209
|
+
content: envContent,
|
|
210
|
+
remotePath: envFilePath,
|
|
211
|
+
user: sshTarget().user,
|
|
212
|
+
log,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
187
216
|
await session.exec(ssh, cmds.stop);
|
|
188
217
|
await session.exec(ssh, cmds.rm);
|
|
189
218
|
await session.exec(ssh, cmds.pull, {
|
|
@@ -3,6 +3,11 @@ import { createLogger } from '../../logger/index.js';
|
|
|
3
3
|
import { getEnvSettings } from '../../core/config.js';
|
|
4
4
|
import { createSshExecSession } from '../ssh-connection.js';
|
|
5
5
|
import { runDeployHooks } from '../hooks.js';
|
|
6
|
+
import {
|
|
7
|
+
readManagedEnvFileContent,
|
|
8
|
+
resolveSshManagedEnvRemotePaths,
|
|
9
|
+
uploadManagedEnvFile,
|
|
10
|
+
} from '../env-file.js';
|
|
6
11
|
import {
|
|
7
12
|
getNginxSitesAvailablePath,
|
|
8
13
|
getNginxSitesEnabledPath,
|
|
@@ -253,9 +258,23 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
253
258
|
await assertPidAliveAfterStart(ssh, targetPath);
|
|
254
259
|
}
|
|
255
260
|
|
|
261
|
+
/**
|
|
262
|
+
* After the framework's package-manager install (and any built-in migrate
|
|
263
|
+
* / artisan steps), before stop/start. Same stage name on rollback so
|
|
264
|
+
* codegen re-runs against the restored files. No-op when the env has no
|
|
265
|
+
* `postInstall` hooks.
|
|
266
|
+
*
|
|
267
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
268
|
+
* @returns {Promise<import('node-ssh').NodeSSH>}
|
|
269
|
+
*/
|
|
270
|
+
async function runPostInstallHooks(ssh) {
|
|
271
|
+
return runDeployHooks({ session, ssh, settings, stage: 'postInstall' });
|
|
272
|
+
}
|
|
273
|
+
|
|
256
274
|
/**
|
|
257
275
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
258
276
|
* @param {string} targetPath
|
|
277
|
+
* @returns {Promise<import('node-ssh').NodeSSH>} possibly replaced after postInstall reconnect
|
|
259
278
|
*/
|
|
260
279
|
async function runBackendStartSequence(ssh, targetPath) {
|
|
261
280
|
const framework = resolveFramework();
|
|
@@ -265,6 +284,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
265
284
|
|
|
266
285
|
if (NODE_FRAMEWORKS.has(framework)) {
|
|
267
286
|
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
287
|
+
ssh = await runPostInstallHooks(ssh);
|
|
268
288
|
const start = startCommand || 'npm start';
|
|
269
289
|
if (start === 'npm start') {
|
|
270
290
|
await exec(
|
|
@@ -285,7 +305,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
285
305
|
);
|
|
286
306
|
}
|
|
287
307
|
await exec(ssh, 'pm2 save');
|
|
288
|
-
return;
|
|
308
|
+
return ssh;
|
|
289
309
|
}
|
|
290
310
|
|
|
291
311
|
if (PYTHON_FRAMEWORKS.has(framework)) {
|
|
@@ -293,6 +313,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
293
313
|
if (framework === 'django') {
|
|
294
314
|
await exec(ssh, `cd ${dir} && python manage.py migrate`);
|
|
295
315
|
}
|
|
316
|
+
ssh = await runPostInstallHooks(ssh);
|
|
296
317
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
297
318
|
if (framework === 'fastapi') {
|
|
298
319
|
await startScopedNohup(
|
|
@@ -318,7 +339,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
318
339
|
);
|
|
319
340
|
await assertPidAliveAfterStart(ssh, targetPath, logFile);
|
|
320
341
|
}
|
|
321
|
-
return;
|
|
342
|
+
return ssh;
|
|
322
343
|
}
|
|
323
344
|
|
|
324
345
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
@@ -338,14 +359,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
338
359
|
await exec(ssh, `cd ${dir} && php artisan config:cache`);
|
|
339
360
|
}
|
|
340
361
|
|
|
362
|
+
ssh = await runPostInstallHooks(ssh);
|
|
363
|
+
|
|
341
364
|
const fpmUnit = await resolveRemotePhpFpmUnit(ssh, phpVersion);
|
|
342
365
|
log.info(`Restarting PHP-FPM service: ${fpmUnit}`);
|
|
343
366
|
await exec(ssh, `sudo systemctl restart ${sh(fpmUnit)}`);
|
|
344
367
|
await reloadNginx(ssh);
|
|
345
|
-
return;
|
|
368
|
+
return ssh;
|
|
346
369
|
}
|
|
347
370
|
|
|
348
371
|
if (framework === 'spring' || framework === 'java') {
|
|
372
|
+
ssh = await runPostInstallHooks(ssh);
|
|
349
373
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
350
374
|
// -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
|
|
351
375
|
// so cmdline marker scans and the PID file both target only this env.
|
|
@@ -354,18 +378,20 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
354
378
|
targetPath,
|
|
355
379
|
`java -Ddeployhub.app=${appName} -jar target/*.jar`
|
|
356
380
|
);
|
|
357
|
-
return;
|
|
381
|
+
return ssh;
|
|
358
382
|
}
|
|
359
383
|
|
|
360
384
|
if (framework === 'go') {
|
|
385
|
+
ssh = await runPostInstallHooks(ssh);
|
|
361
386
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
362
387
|
// Binary is always ./bin/app — must NOT pkill by appName alone (that never
|
|
363
388
|
// matched the process) and must NOT pkill a bare "app" (cross-env collision).
|
|
364
389
|
await startScopedNohup(ssh, targetPath, './bin/app');
|
|
365
|
-
return;
|
|
390
|
+
return ssh;
|
|
366
391
|
}
|
|
367
392
|
|
|
368
393
|
if (framework === 'dotnet') {
|
|
394
|
+
ssh = await runPostInstallHooks(ssh);
|
|
369
395
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
370
396
|
// Discover the published DLL at runtime — csproj name is not always App.dll
|
|
371
397
|
// (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
|
|
@@ -374,22 +400,25 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
374
400
|
targetPath,
|
|
375
401
|
`sh -c 'dll=$(ls -1 *.dll 2>/dev/null | head -n1); test -n "$dll" || { echo "No .dll found in $(pwd) for .NET start" >&2; exit 1; }; exec dotnet "$dll"'`
|
|
376
402
|
);
|
|
377
|
-
return;
|
|
403
|
+
return ssh;
|
|
378
404
|
}
|
|
379
405
|
|
|
380
406
|
if (framework === 'rails' || framework === 'ruby') {
|
|
381
407
|
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
408
|
+
ssh = await runPostInstallHooks(ssh);
|
|
382
409
|
await stopScopedBackendProcess(ssh, targetPath);
|
|
383
410
|
await startScopedNohup(ssh, targetPath, `bundle exec puma -b tcp://0.0.0.0:${port}`);
|
|
384
|
-
return;
|
|
411
|
+
return ssh;
|
|
385
412
|
}
|
|
386
413
|
|
|
387
414
|
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
415
|
+
ssh = await runPostInstallHooks(ssh);
|
|
388
416
|
await exec(
|
|
389
417
|
ssh,
|
|
390
418
|
`cd ${dir} && pm2 restart ${sh(appName)} || pm2 start npm --name ${sh(appName)} -- start`
|
|
391
419
|
);
|
|
392
420
|
await exec(ssh, 'pm2 save');
|
|
421
|
+
return ssh;
|
|
393
422
|
}
|
|
394
423
|
|
|
395
424
|
/**
|
|
@@ -539,6 +568,33 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
539
568
|
await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
|
|
540
569
|
}
|
|
541
570
|
|
|
571
|
+
/**
|
|
572
|
+
* DeployHub-managed .env: SFTP the current GitHub Secret onto the target.
|
|
573
|
+
* Overwrites every deploy, including rollback — .env is the current secret,
|
|
574
|
+
* not versioned per buildId. Runs after unzip/rsync and before
|
|
575
|
+
* runBackendStartSequence (install → postInstall → start), so a postInstall
|
|
576
|
+
* hook can `cd {deployPath} && set -a && . ./.env && set +a && prisma generate`.
|
|
577
|
+
* No-op when envFileSecretName is unset (hand-placed .env then survives
|
|
578
|
+
* unzip -o, same as before).
|
|
579
|
+
*
|
|
580
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
581
|
+
*/
|
|
582
|
+
async function uploadManagedEnvIfConfigured(ssh) {
|
|
583
|
+
const content = readManagedEnvFileContent(env, settings);
|
|
584
|
+
if (content == null) return;
|
|
585
|
+
const paths = resolveSshManagedEnvRemotePaths(config, settings);
|
|
586
|
+
for (const remotePath of paths) {
|
|
587
|
+
await uploadManagedEnvFile({
|
|
588
|
+
ssh,
|
|
589
|
+
session,
|
|
590
|
+
content,
|
|
591
|
+
remotePath,
|
|
592
|
+
user,
|
|
593
|
+
log,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
542
598
|
/**
|
|
543
599
|
* @param {string} artifactDir
|
|
544
600
|
* @param {{ isRollback?: boolean }} [options]
|
|
@@ -546,6 +602,9 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
546
602
|
async function deploy(artifactDir, options = {}) {
|
|
547
603
|
const isRollback = options.isRollback === true;
|
|
548
604
|
const preStage = isRollback ? 'rollback' : 'preDeploy';
|
|
605
|
+
// Fail fast (before SSH): GitHub Actions injects "" when the secret was
|
|
606
|
+
// never added or the name is misspelled. Must not unzip then write empty .env.
|
|
607
|
+
readManagedEnvFileContent(env, settings);
|
|
549
608
|
let ssh = await connect();
|
|
550
609
|
const projectType = config.projectType || 'frontend';
|
|
551
610
|
|
|
@@ -577,11 +636,13 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
577
636
|
`rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
|
|
578
637
|
);
|
|
579
638
|
|
|
639
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
640
|
+
|
|
580
641
|
if (await remoteFileExists(ssh, `${frontendDeployPath}/nginx.conf`)) {
|
|
581
642
|
await setupNginx(ssh, frontendDeployPath);
|
|
582
643
|
}
|
|
583
644
|
|
|
584
|
-
await runBackendStartSequence(ssh, backendDeployPath);
|
|
645
|
+
ssh = await runBackendStartSequence(ssh, backendDeployPath);
|
|
585
646
|
} finally {
|
|
586
647
|
await exec(ssh, `rm -rf ${sh(remoteStaging)}`).catch(() => {});
|
|
587
648
|
}
|
|
@@ -589,15 +650,17 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
589
650
|
log.info(`Backend deploy path: ${deployPath}`);
|
|
590
651
|
ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
|
|
591
652
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
592
|
-
await
|
|
653
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
654
|
+
ssh = await runBackendStartSequence(ssh, deployPath);
|
|
593
655
|
} else {
|
|
594
656
|
log.info(`Frontend deploy path: ${deployPath}`);
|
|
595
657
|
ssh = await runDeployHooks({ session, ssh, settings, stage: preStage });
|
|
596
658
|
await extractToPath(ssh, remoteZip, deployPath);
|
|
659
|
+
await uploadManagedEnvIfConfigured(ssh);
|
|
597
660
|
|
|
598
661
|
const framework = config.framework || 'react';
|
|
599
662
|
if (framework === 'nextjs') {
|
|
600
|
-
await runBackendStartSequence(ssh, deployPath);
|
|
663
|
+
ssh = await runBackendStartSequence(ssh, deployPath);
|
|
601
664
|
} else if (await remoteFileExists(ssh, `${deployPath}/nginx.conf`)) {
|
|
602
665
|
await setupNginx(ssh, deployPath);
|
|
603
666
|
}
|
|
@@ -178,7 +178,7 @@ export async function probeRemoteDockerPs(target) {
|
|
|
178
178
|
* @param {string} imageRef
|
|
179
179
|
* @param {string} containerName
|
|
180
180
|
* @param {Record<string, string>} [runEnv]
|
|
181
|
-
* @param {{ publishPort?: number|null }} [options]
|
|
181
|
+
* @param {{ publishPort?: number|null, envFile?: string }} [options]
|
|
182
182
|
*/
|
|
183
183
|
export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
|
|
184
184
|
const image = shellQuote(imageRef);
|
|
@@ -192,12 +192,17 @@ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {},
|
|
|
192
192
|
const publishPort = options.publishPort;
|
|
193
193
|
const pFlag =
|
|
194
194
|
publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
|
|
195
|
+
const envFile = options.envFile;
|
|
196
|
+
const envFileFlag =
|
|
197
|
+
envFile != null && String(envFile).trim()
|
|
198
|
+
? `--env-file ${shellQuote(String(envFile).trim())} `
|
|
199
|
+
: '';
|
|
195
200
|
|
|
196
201
|
return {
|
|
197
202
|
stop: `docker stop ${name} 2>/dev/null || true`,
|
|
198
203
|
rm: `docker rm -f ${name} 2>/dev/null || true`,
|
|
199
204
|
pull: `docker pull ${image}`,
|
|
200
|
-
run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
|
|
205
|
+
run: `docker run -d --rm --name ${name} ${pFlag}${envFileFlag}${envArg}${image}`,
|
|
201
206
|
ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
|
|
202
207
|
info: 'docker info',
|
|
203
208
|
/**
|