Skip to content

Commit 48f26b8

Browse files
authored
fix(deploy): add ecosystem.production.config.js + fix uv PATH (#727) (#799)
* fix(deploy): add ecosystem.production.config.js + fix uv PATH (#727) The production deploy job ran 'pm2 start ecosystem.production.config.js' (file never existed — only staging) and exported PATH="$HOME/.cargo/bin:$PATH" unescaped (expands on the runner, wrong dir) while uv installs to ~/.local/bin. A fresh production host aborted under set -e / couldn't find uv. - add ecosystem.production.config.js mirroring staging; app names + ports read from env (.env.production / deploy shell) so 'pm2 --only "$PM2_*_NAME"' matches the deploy's secret-provided names (defaults otherwise) - fix the prod uv PATH to '$HOME/.local/bin' (escaped, matches staging) - smoke tests: config exists + loads (node) with 2 apps; deploy.yml has no dangling ecosystem ref and no cargo/bin PATH Closes #727 * test(deploy): use node --check (dependency-free) + static app check; hoist re import (#727 CI + review) The node smoke test require()'d dotenv, which isn't installed at the repo root in the Backend Unit Tests job (only web-ui/ runs npm). node --check validates syntax without module resolution, and a static check confirms the two apps.
1 parent a979b35 commit 48f26b8

3 files changed

Lines changed: 128 additions & 1 deletion

File tree

.github/workflows/deploy.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,9 @@ jobs:
491491
echo "🐍 Setting up Python backend..."
492492
if ! command -v uv &> /dev/null; then
493493
curl -LsSf https://astral.sh/uv/install.sh | sh
494-
export PATH="$HOME/.cargo/bin:$PATH"
494+
# uv installs to ~/.local/bin, and \$HOME must expand on the host
495+
# (not the runner) — mirror the staging job (#727).
496+
export PATH="\$HOME/.local/bin:\$PATH"
495497
fi
496498
uv venv --clear
497499
source .venv/bin/activate

ecosystem.production.config.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
const path = require('path');
2+
const dotenv = require('dotenv');
3+
4+
// Use relative paths - no hardcoded absolute paths
5+
const PROJECT_ROOT = __dirname;
6+
7+
// Load environment variables from .env.production
8+
const envConfig = dotenv.config({ path: path.join(PROJECT_ROOT, '.env.production') }).parsed || {};
9+
10+
// PM2 app names and ports are read from the environment (.env.production or the
11+
// deploy shell) so `pm2 start ecosystem.production.config.js --only "$NAME"`
12+
// matches whatever PM2_BACKEND_NAME / PM2_FRONTEND_NAME the deploy passes
13+
// (#727). Defaults are used only when those are unset.
14+
const BACKEND_NAME =
15+
process.env.PM2_BACKEND_NAME || envConfig.PM2_BACKEND_NAME || 'codeframe-production-backend';
16+
const FRONTEND_NAME =
17+
process.env.PM2_FRONTEND_NAME || envConfig.PM2_FRONTEND_NAME || 'codeframe-production-frontend';
18+
const BACKEND_PORT = process.env.BACKEND_PORT || envConfig.BACKEND_PORT || '8000';
19+
const FRONTEND_PORT = process.env.FRONTEND_PORT || envConfig.FRONTEND_PORT || '3000';
20+
21+
module.exports = {
22+
apps: [
23+
{
24+
name: BACKEND_NAME,
25+
script: path.join(PROJECT_ROOT, '.venv/bin/python'),
26+
args: `-m codeframe.ui.server --port ${BACKEND_PORT}`,
27+
cwd: PROJECT_ROOT,
28+
env: {
29+
...envConfig,
30+
PYTHONPATH: PROJECT_ROOT
31+
},
32+
autorestart: true,
33+
watch: false,
34+
max_memory_restart: '500M',
35+
error_file: path.join(PROJECT_ROOT, 'logs/backend-error.log'),
36+
out_file: path.join(PROJECT_ROOT, 'logs/backend-out.log'),
37+
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
38+
kill_timeout: 5000,
39+
wait_ready: true,
40+
listen_timeout: 10000
41+
},
42+
{
43+
name: FRONTEND_NAME,
44+
script: path.join(PROJECT_ROOT, 'web-ui/node_modules/.bin/next'),
45+
args: `start -H 0.0.0.0 -p ${FRONTEND_PORT}`,
46+
cwd: path.join(PROJECT_ROOT, 'web-ui'),
47+
env: {
48+
...envConfig
49+
},
50+
autorestart: true,
51+
watch: false,
52+
max_memory_restart: '500M',
53+
error_file: path.join(PROJECT_ROOT, 'logs/frontend-error.log'),
54+
out_file: path.join(PROJECT_ROOT, 'logs/frontend-out.log'),
55+
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
56+
kill_timeout: 5000,
57+
wait_ready: true,
58+
listen_timeout: 10000
59+
}
60+
]
61+
};

tests/test_deploy_config.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Production deploy smoke checks (#727 / P0.16).
2+
3+
The production deploy job referenced a missing ecosystem.production.config.js
4+
and exported a wrong uv PATH ($HOME/.cargo/bin, unescaped), so a fresh
5+
production host aborted under `set -e`. These guard against regressions.
6+
"""
7+
8+
import re
9+
import shutil
10+
import subprocess
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
pytestmark = pytest.mark.v2
16+
17+
REPO = Path(__file__).resolve().parents[1]
18+
DEPLOY_YML = REPO / ".github" / "workflows" / "deploy.yml"
19+
PROD_ECOSYSTEM = REPO / "ecosystem.production.config.js"
20+
21+
22+
def test_production_ecosystem_config_exists():
23+
assert PROD_ECOSYSTEM.is_file(), (
24+
"ecosystem.production.config.js must exist — deploy.yml runs "
25+
"`pm2 start ecosystem.production.config.js`"
26+
)
27+
28+
29+
def test_deploy_uses_correct_uv_path():
30+
text = DEPLOY_YML.read_text()
31+
# The broken cargo path must be gone...
32+
assert "cargo/bin" not in text, "deploy.yml still exports the wrong uv PATH (cargo/bin)"
33+
# ...and every uv PATH export uses the escaped ~/.local/bin (expands on the host).
34+
assert 'export PATH="\\$HOME/.local/bin:\\$PATH"' in text
35+
36+
37+
def test_no_dangling_ecosystem_reference():
38+
"""Every ecosystem.*.config.js file the workflow starts must exist."""
39+
text = DEPLOY_YML.read_text()
40+
for name in set(re.findall(r"ecosystem\.[\w.]*config\.js", text)):
41+
assert (REPO / name).is_file(), f"deploy.yml references missing {name}"
42+
43+
44+
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
45+
def test_production_config_is_valid_javascript():
46+
"""Smoke-test: the config parses as valid JS. `node --check` is syntax-only
47+
(no execution / module resolution), so it doesn't need the root npm deps
48+
that only exist on the deploy host, yet still catches a broken config."""
49+
result = subprocess.run(
50+
["node", "--check", "ecosystem.production.config.js"],
51+
cwd=str(REPO),
52+
capture_output=True,
53+
text=True,
54+
)
55+
assert result.returncode == 0, result.stderr
56+
57+
58+
def test_production_config_defines_two_apps():
59+
"""The config must define backend + frontend apps. Checked statically so it
60+
needs no node/npm deps: both app blocks reference the venv python and next."""
61+
text = PROD_ECOSYSTEM.read_text()
62+
assert ".venv/bin/python" in text # backend app
63+
assert "web-ui/node_modules/.bin/next" in text # frontend app
64+
assert text.count("name:") == 2 # exactly two pm2 apps

0 commit comments

Comments
 (0)