Track real website deploy success/failure and support master branch.

Deploy agent waits for build completion, writes status, and checks out origin/HEAD (main or master) so empty-main repos like mashinify can deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 08:52:49 +03:30
co-authored by Cursor
parent acc8f35682
commit f7cee26975
5 changed files with 177 additions and 28 deletions
+5 -1
View File
@@ -1,7 +1,10 @@
# Websites deploy / provision agent (runs on websites VM: /opt/websites-agent)
#
# Endpoints (X-Deploy-Token):
# POST /deploy { slug } — git pull + build + pm2 restart
# POST /deploy { slug, wait?: true } — git pull + build + pm2 restart
# wait=true (API default): sync, returns success/failed
# wait=false: fire-and-forget 202 accepted
# GET /deploy-status?slug=… — last deploy status JSON for slug
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot)
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
# GET /health
@@ -10,3 +13,4 @@
#
# Note: provision.sh intentionally skips certbot. SSL is issued via POST /ssl so
# Edit/Add Domain does not hang when www DNS is wrong.
# deploy.sh checks out origin/HEAD (falls back to main, then master).
+60 -1
View File
@@ -9,19 +9,78 @@ fi
ROOT="/var/www/sites/$SLUG"
LOG="/var/log/websites/deploy-$SLUG.log"
STATUS="/var/log/websites/deploy-$SLUG.status.json"
exec >>"$LOG" 2>&1
write_status() {
local status="$1"
local detail="${2:-}"
detail="${detail//\\/\\\\}"
detail="${detail//\"/\\\"}"
detail="${detail//$'\n'/ }"
detail="${detail:0:500}"
printf '{"slug":"%s","status":"%s","detail":"%s","at":"%s"}\n' \
"$SLUG" \
"$status" \
"$detail" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
>"$STATUS"
}
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ===="
write_status "started" "Deploy running"
cleanup_fail() {
local code=$?
write_status "failed" "deploy.sh exited with code $code"
exit "$code"
}
trap cleanup_fail ERR
if [[ ! -d "$ROOT/.git" ]]; then
echo "missing site: $ROOT"
write_status "failed" "missing site directory"
exit 1
fi
cd "$ROOT"
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -i /root/.ssh/websites_deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new}"
git fetch origin
git reset --hard origin/main
# Prefer origin/HEAD, then main, then master (repos may use either).
BRANCH=""
if git rev-parse --verify --quiet origin/HEAD >/dev/null; then
BRANCH="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || true)"
BRANCH="${BRANCH#origin/}"
fi
if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
if git rev-parse --verify --quiet origin/main >/dev/null; then
BRANCH="main"
elif git rev-parse --verify --quiet origin/master >/dev/null; then
BRANCH="master"
else
echo "could not determine default branch (tried origin/HEAD, main, master)"
write_status "failed" "no origin/main or origin/master"
exit 1
fi
fi
echo "resetting to origin/$BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"
git reset --hard "origin/$BRANCH"
if [[ ! -f package.json ]]; then
echo "package.json missing after checkout"
write_status "failed" "package.json missing — empty or wrong branch?"
exit 1
fi
npm ci
npm run build
pm2 restart "$SLUG" --update-env || pm2 start /var/www/sites/ecosystem.config.cjs --only "$SLUG"
pm2 save || true
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
trap - ERR
+59 -6
View File
@@ -103,11 +103,34 @@ function runScript(script, args) {
});
}
function readDeployStatus(slug) {
const file = `/var/log/websites/deploy-${slug}.status.json`;
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/health') {
return send(res, 200, { ok: true, allowed: [...allowedSlugs()] });
}
if (req.method === 'GET' && req.url?.startsWith('/deploy-status')) {
if (!assertAuth(req, res)) return;
const url = new URL(req.url, 'http://127.0.0.1');
const slug = String(url.searchParams.get('slug') || '').trim();
if (!slug || !allowedSlugs().has(slug)) {
return send(res, 400, { error: 'unknown or disallowed slug' });
}
const status = readDeployStatus(slug);
if (!status) {
return send(res, 404, { error: 'no deploy status yet', slug });
}
return send(res, 200, status);
}
if (req.method === 'POST' && req.url === '/deploy') {
if (!assertAuth(req, res)) return;
@@ -119,17 +142,47 @@ const server = http.createServer(async (req, res) => {
}
const slug = String(body.slug || '').trim();
const wait = body.wait === true || body.wait === 'true';
if (!slug || !allowedSlugs().has(slug)) {
return send(res, 400, { error: 'unknown or disallowed slug' });
}
const child = spawn('/opt/websites-agent/deploy.sh', [slug], {
detached: true,
stdio: 'ignore',
});
child.unref();
if (!wait) {
const child = spawn('/opt/websites-agent/deploy.sh', [slug], {
detached: true,
stdio: 'ignore',
});
child.unref();
return send(res, 202, { status: 'accepted', slug });
}
return send(res, 202, { status: 'accepted', slug });
try {
const result = await runScript('/opt/websites-agent/deploy.sh', [slug]);
const status = readDeployStatus(slug) || {
slug,
status: 'success',
detail: 'Deploy finished',
at: new Date().toISOString(),
};
return send(res, 200, {
status: 'success',
slug,
detail: status.detail || null,
at: status.at || null,
log: (result.stdout || '').slice(-2000),
});
} catch (err) {
const status = readDeployStatus(slug);
const detail =
(status && status.detail) ||
(err instanceof Error ? err.message.slice(0, 2000) : String(err));
return send(res, 500, {
status: 'failed',
slug,
error: 'deploy failed',
detail,
});
}
}
if (req.method === 'POST' && req.url === '/provision') {