Skip to content

Commit 4d1349d

Browse files
committed
feat: set up staging deploy pipeline sharing the production database
Adds a redesign branch and a dedicated Deploy Staging workflow that builds and ships this branch to a separate staging site on push (after its own CI passes), independent of the main production deploy. The staging site is intended to share the production database so the redesign can be validated against real tenant data before merging: - ResolveTenantFromSubdomain now resolves staging.<custom-domain> hosts to the same tenant as <custom-domain>, so no separate tenant row is needed for the staging alias. - Migrations default OFF on every automatic staging deploy (opt in via workflow_dispatch) since a bad migration would land on the shared, live production database. - Staging's finalize step deliberately does not run queue:restart, since that signal travels through the shared cache and would also bounce production's queue workers. Requires new repo secrets STAGING_DEPLOY_PATH and STAGING_DEPLOY_WEBROOT, plus server-side setup (subdomain, git checkout, .env) — see chat for the full checklist, none of which this workflow/agent can perform.
1 parent d073823 commit 4d1349d

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
name: Deploy Staging
2+
3+
# Deploys the `redesign` branch to a separate staging site that shares the
4+
# production database (same tenants, orders, users — see the warning in
5+
# "Finalize deploy" below). Intended for validating the redesign against
6+
# real data before merging to main.
7+
#
8+
# Triggers:
9+
# - Automatically after CI completes successfully on the redesign branch
10+
# - Manually via workflow_dispatch for hotfixes or re-deploys
11+
12+
on:
13+
workflow_run:
14+
workflows: ["CI"]
15+
types: [completed]
16+
branches: [redesign]
17+
workflow_dispatch:
18+
inputs:
19+
run_migrations:
20+
description: "Run database migrations (shared prod DB — confirm the migration is safe first)"
21+
required: true
22+
default: false
23+
type: boolean
24+
run_scout_import:
25+
description: "Re-index Typesense (slow — only if schema changed)"
26+
required: true
27+
default: false
28+
type: boolean
29+
30+
concurrency:
31+
group: deploy-staging-${{ github.ref }}
32+
cancel-in-progress: false # never cancel an in-flight deploy
33+
34+
jobs:
35+
deploy:
36+
name: Deploy to staging
37+
runs-on: ubuntu-latest
38+
# Only deploy if CI succeeded (or triggered manually), and only for the redesign branch
39+
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'redesign') }}
40+
environment: staging
41+
42+
steps:
43+
- name: Checkout
44+
uses: actions/checkout@v4
45+
with:
46+
ref: redesign
47+
48+
# ── Build frontend assets on CI runner (npm not available on server) ──
49+
- name: Setup PHP
50+
uses: shivammathur/setup-php@v2
51+
with:
52+
php-version: "8.4"
53+
tools: composer:v2
54+
coverage: none
55+
56+
- name: Install PHP dependencies
57+
run: composer install --no-dev --no-interaction --no-scripts --prefer-dist
58+
59+
- name: Setup Node.js
60+
uses: actions/setup-node@v4
61+
with:
62+
node-version: "20"
63+
cache: "npm"
64+
65+
- name: Install Node dependencies
66+
run: npm ci
67+
68+
- name: Build frontend assets
69+
run: npm run build
70+
71+
# ── SSH deploy ──────────────────────────────────────────
72+
# Required GitHub secrets (in addition to the ones deploy.yml already uses —
73+
# DEPLOY_HOST/PORT/USER/KEY/PHP are reused since it's the same server):
74+
# STAGING_DEPLOY_PATH — absolute path to the staging app root on the server
75+
# (e.g. /home/aljedkrq/nexo-ecommerce-staging)
76+
# STAGING_DEPLOY_WEBROOT — absolute path to the staging subdomain's document root
77+
# (e.g. /home/aljedkrq/staging.store.aljebal-albeedos.com)
78+
# Required when the web root differs from STAGING_DEPLOY_PATH/public.
79+
# ────────────────────────────────────────────────────────
80+
- name: Setup SSH agent
81+
uses: webfactory/ssh-agent@v0.9.0
82+
with:
83+
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
84+
85+
- name: Add server to known hosts
86+
run: ssh-keyscan -p ${{ secrets.DEPLOY_PORT }} -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
87+
88+
- name: Pull latest code & install PHP dependencies
89+
env:
90+
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
91+
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
92+
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
93+
DEPLOY_PATH: ${{ secrets.STAGING_DEPLOY_PATH }}
94+
DEPLOY_PHP: ${{ secrets.DEPLOY_PHP }}
95+
run: |
96+
ssh -p $DEPLOY_PORT $DEPLOY_USER@$DEPLOY_HOST << EOF
97+
set -e
98+
PHP="${DEPLOY_PHP:-php}"
99+
cd $DEPLOY_PATH
100+
101+
echo "==> Pulling latest code"
102+
git fetch origin redesign
103+
if ! git diff --quiet || ! git diff --cached --quiet; then
104+
echo "==> Stashing unexpected local changes on server before pulling"
105+
git stash push -u -m "auto-stash before deploy \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
106+
fi
107+
git checkout redesign
108+
git merge --ff-only origin/redesign
109+
110+
echo "==> Installing PHP dependencies"
111+
if command -v composer >/dev/null 2>&1; then
112+
COMPOSER="composer"
113+
elif [ -f "\$HOME/bin/composer" ]; then
114+
COMPOSER="\$HOME/bin/composer"
115+
elif [ -f "$DEPLOY_PATH/composer.phar" ]; then
116+
COMPOSER="$DEPLOY_PATH/composer.phar"
117+
else
118+
echo "==> composer not found on server, bootstrapping composer.phar"
119+
\$PHP -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
120+
ACTUAL_SIG="\$(\$PHP -r "echo hash_file('sha384', 'composer-setup.php');")"
121+
EXPECTED_SIG="\$(\$PHP -r "echo file_get_contents('https://composer.github.io/installer.sig');")"
122+
if [ "\$ACTUAL_SIG" != "\$EXPECTED_SIG" ]; then
123+
echo "Composer installer signature mismatch, aborting" >&2
124+
rm -f composer-setup.php
125+
exit 1
126+
fi
127+
\$PHP composer-setup.php --quiet --install-dir="$DEPLOY_PATH" --filename=composer.phar
128+
rm -f composer-setup.php
129+
COMPOSER="$DEPLOY_PATH/composer.phar"
130+
fi
131+
\$PHP \$COMPOSER install --no-dev --no-interaction --optimize-autoloader --prefer-dist
132+
EOF
133+
134+
- name: Upload built assets
135+
env:
136+
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
137+
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
138+
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
139+
DEPLOY_PATH: ${{ secrets.STAGING_DEPLOY_PATH }}
140+
run: |
141+
rsync -az --delete -e "ssh -p $DEPLOY_PORT" \
142+
public/build/ \
143+
$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/public/build/
144+
145+
- name: Finalize deploy
146+
env:
147+
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
148+
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
149+
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
150+
DEPLOY_PATH: ${{ secrets.STAGING_DEPLOY_PATH }}
151+
DEPLOY_PHP: ${{ secrets.DEPLOY_PHP }}
152+
DEPLOY_WEBROOT: ${{ secrets.STAGING_DEPLOY_WEBROOT }}
153+
# Shared production DB: default migrations OFF on every automatic push so a
154+
# half-finished redesign migration never silently lands on production data.
155+
# Opt in explicitly via workflow_dispatch once a migration is verified safe.
156+
RUN_MIGRATIONS: ${{ github.event.inputs.run_migrations || 'false' }}
157+
RUN_SCOUT_IMPORT: ${{ github.event.inputs.run_scout_import || 'false' }}
158+
run: |
159+
ssh -p $DEPLOY_PORT $DEPLOY_USER@$DEPLOY_HOST << EOF
160+
set -e
161+
PHP="${DEPLOY_PHP:-php}"
162+
cd $DEPLOY_PATH
163+
164+
# Ensure the web root's build/ points to the app's compiled assets.
165+
if [ -n "$DEPLOY_WEBROOT" ]; then
166+
ln -sfn $DEPLOY_PATH/public/build $DEPLOY_WEBROOT/build
167+
echo "==> Symlinked $DEPLOY_WEBROOT/build -> $DEPLOY_PATH/public/build"
168+
fi
169+
170+
echo "==> Caching configuration"
171+
\$PHP artisan config:cache
172+
\$PHP artisan route:cache
173+
\$PHP artisan view:cache
174+
\$PHP artisan event:cache
175+
176+
if [ "$RUN_MIGRATIONS" = "true" ]; then
177+
echo "==> Running database migrations (shared production database)"
178+
\$PHP artisan migrate --force
179+
fi
180+
181+
if [ "$RUN_SCOUT_IMPORT" = "true" ]; then
182+
echo "==> Re-indexing Typesense"
183+
\$PHP artisan scout:flush "App\Domain\Product\Models\Product"
184+
\$PHP artisan scout:import "App\Domain\Product\Models\Product"
185+
\$PHP artisan scout:flush "App\Domain\Category\Models\Category"
186+
\$PHP artisan scout:import "App\Domain\Category\Models\Category"
187+
\$PHP artisan scout:flush "App\Domain\Order\Models\Order"
188+
\$PHP artisan scout:import "App\Domain\Order\Models\Order"
189+
fi
190+
191+
echo "==> Done (\$(ls $DEPLOY_PATH/public/build/assets/ | wc -l) assets)"
192+
EOF
193+
194+
- name: Notify on failure
195+
if: failure()
196+
run: |
197+
echo "::error::Staging deployment failed. Check the logs above."

app/Http/Middleware/ResolveTenantFromSubdomain.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ private function resolveTenant(Request $request): ?Tenant
5151
return $tenant;
5252
}
5353

54+
// The staging environment shares the production database and mirrors
55+
// production tenants under a "staging." prefix on the same custom
56+
// domain (e.g. staging.store.example.com -> store.example.com), so
57+
// it always reflects the same tenant data without a separate row.
58+
if (str_starts_with($host, 'staging.')) {
59+
$tenant = Tenant::query()
60+
->where('domain', mb_substr($host, mb_strlen('staging.')))
61+
->first();
62+
63+
if ($tenant !== null) {
64+
return $tenant;
65+
}
66+
}
67+
5468
// Then, try to resolve by subdomain
5569
$subdomain = $this->extractSubdomain($host, $baseDomain);
5670

tests/Feature/MultiTenancy/TenantResolutionTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@
5555
expect($response->getContent())->toBe('OK');
5656
});
5757

58+
it('resolves the same tenant on the staging alias of a custom domain', function (): void {
59+
$tenant = Tenant::factory()
60+
->withDomain('store.example.com')
61+
->create(['slug' => 'store', 'is_active' => true]);
62+
63+
$middleware = new ResolveTenantFromSubdomain;
64+
$request = Request::create('http://staging.store.example.com/dashboard');
65+
66+
$response = $middleware->handle($request, function ($req) use ($tenant): ResponseFactory|Response {
67+
expect(Context::get('tenant_id'))->toBe($tenant->id);
68+
69+
return response('OK');
70+
});
71+
72+
expect($response->getContent())->toBe('OK');
73+
});
74+
5875
it('throws not found for non-existent tenant', function (): void {
5976
$middleware = new ResolveTenantFromSubdomain;
6077
$request = Request::create('http://nonexistent.example.com/dashboard');

0 commit comments

Comments
 (0)