Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
self-deploy.sh15.1 KB · 351 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
#!/usr/bin/env bash
# =============================================================================
# BLOCK W — Gluecron self-deploy.
#
# Fired by:
#   - src/hooks/post-receive.ts when SELF_HOST_REPO matches the pushed repo
#     AND the ref is refs/heads/main
#   - the bare repo's hooks/post-receive (for SSH receive-pack)
#   - the optional .gluecron/workflows/deploy.yml on the workflow runner
#
# Contract:
#   - Detaches into the background via systemd-run (or nohup fallback) so
#     the caller's git push returns in ~1 second
#   - Logs every step to /var/log/gluecron-self-deploy.log
#   - Notifies /api/events/deploy/{started,step,finished} so /admin/deploys
#     streams the live timeline (same wire as the GitHub Actions workflow)
#   - Rolls back via git reflog if the post-deploy smoke fails
#
# Operator invariants:
#   - /etc/gluecron.env is the source of env truth (DATABASE_URL,
#     DEPLOY_EVENT_TOKEN, APP_BASE_URL, ANTHROPIC_API_KEY, …)
#   - /opt/gluecron is the working tree on the box, with git remote `origin`
#     pointing at https://gluecron.com/<owner>/<repo>.git (NOT GitHub)
#   - /opt/gluecron/.next/gluecron-server is the compiled Bun binary
#   - systemd unit `gluecron` is Type=notify and ExecStart=$EXEC_START
#
# TODO(ops): configure /etc/logrotate.d/gluecron-self-deploy for the log.
# =============================================================================

# `set -E` so traps propagate into subshells; `-x` traces every command
# to stderr (captured into $LOG via the `>>"$LOG" 2>&1` redirects on the
# detached re-exec line). Reliability sweep 2026-05-16: when this script
# fails, the trace tells us EXACTLY which line broke instead of leaving
# us guessing as we did for 17 hours of failed Hetzner deploys.
set -Eeuxo pipefail

WORKING_DIR="${GLUECRON_WORKING_DIR:-/opt/gluecron}"
LOG="${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
ENV_FILE="${GLUECRON_ENV_FILE:-/etc/gluecron.env}"
BUN="${GLUECRON_BUN:-/root/.bun/bin/bun}"
HEALTHZ_URL="${GLUECRON_HEALTHZ_URL:-http://localhost:3010/healthz}"
PORT="${GLUECRON_PORT:-3010}"
DETACHED_FLAG="${1:-}"

# ── helpers ────────────────────────────────────────────────────────────────
ts() { date +'%Y-%m-%dT%H:%M:%S%z'; }
log() { echo "[$(ts)] $*" | tee -a "$LOG" >&2; }

notify_step() {
  local NAME="$1" STATUS="$2" DUR="${3:-}"
  if [ -z "${DEPLOY_EVENT_TOKEN:-}" ] || [ -z "${APP_BASE_URL:-}" ]; then
    return 0
  fi
  local DUR_FIELD=""
  if [ -n "$DUR" ]; then DUR_FIELD=",\"duration_ms\":$DUR"; fi
  curl --silent --show-error --max-time 5 \
    -X POST "$APP_BASE_URL/api/events/deploy/step" \
    -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
    -H "content-type: application/json" \
    --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"step_name\":\"$NAME\",\"status\":\"$STATUS\"$DUR_FIELD}" \
    >/dev/null 2>&1 || true
}

# ── re-exec into the background unless already detached ──────────────────
# When the post-receive hook calls this script, git push is blocked on the
# child's stdout/stderr. We re-exec ourselves through systemd-run so the
# original SSH/HTTP receive-pack process can return immediately.
if [ "$DETACHED_FLAG" != "--inline" ] && [ -z "${GLUECRON_SELF_DEPLOY_DETACHED:-}" ]; then
  export GLUECRON_SELF_DEPLOY_DETACHED=1
  if command -v systemd-run >/dev/null 2>&1; then
    systemd-run --quiet --unit="gluecron-self-deploy-$(date +%s)" \
      --collect --no-block \
      bash "$0" --inline "$@" || nohup bash "$0" --inline "$@" >>"$LOG" 2>&1 &
  else
    nohup bash "$0" --inline "$@" >>"$LOG" 2>&1 &
    disown || true
  fi
  exit 0
fi

# Everything below runs in the detached process.
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
touch "$LOG" 2>/dev/null || true

log "==> gluecron self-deploy starting (pid $$)"

# ── 1. Source env ──────────────────────────────────────────────────────────
if [ -f "$ENV_FILE" ]; then
  set -a
  # shellcheck disable=SC1090
  source "$ENV_FILE"
  set +a
  log "    v sourced $ENV_FILE"
else
  log "    ! $ENV_FILE not found — relying on inherited env"
fi

cd "$WORKING_DIR"

# ── 2. Capture pre-deploy SHA for rollback ────────────────────────────────
PREV_SHA="$(git rev-parse HEAD 2>/dev/null || echo '')"
log "    v previous SHA: $PREV_SHA"

# ── 3. Pull latest main ────────────────────────────────────────────────────
GP_START=$(date +%s)
notify_step "git-pull" "in_progress"
git fetch --prune origin main 2>&1 | tee -a "$LOG"
git reset --hard origin/main 2>&1 | tee -a "$LOG"
NEW_SHA="$(git rev-parse HEAD)"
RUN_ID="self-${NEW_SHA:0:12}-$(date +%s)"
log "    v pulled to $NEW_SHA (run_id=$RUN_ID)"
notify_step "git-pull" "succeeded" "$(( ( $(date +%s) - GP_START ) * 1000 ))"

# ── 3.5 Notify deploy started (now that we have NEW_SHA + RUN_ID) ─────────
if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
  curl --silent --show-error --max-time 10 \
    -X POST "$APP_BASE_URL/api/events/deploy/started" \
    -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
    -H "content-type: application/json" \
    --data "{\"sha\":\"$NEW_SHA\",\"run_id\":\"$RUN_ID\",\"source\":\"self-deploy\"}" \
    >>"$LOG" 2>&1 || log "    ! deploy/started notify failed (non-fatal)"
fi

START_EPOCH=$(date +%s)
DEPLOY_FAILED=0
FAIL_REASON=""

# ── 4. bun install --frozen-lockfile (skipped if lockfile unchanged) ──────
# Track the last-installed lockfile hash in a stamp file. If it matches the
# current lockfile we skip — saves ~2-5s every deploy that doesn't touch
# dependencies (90%+ of deploys). On any error the fallback runs install
# anyway so we never silently skip a needed install.
STAMP_DIR="${GLUECRON_DEPLOY_STAMP_DIR:-.next}"
mkdir -p "$STAMP_DIR" 2>/dev/null || true
LOCK_STAMP="$STAMP_DIR/bun-install.stamp"
LOCK_HASH=""
if [ -f bun.lockb ]; then
  LOCK_HASH=$(sha256sum bun.lockb 2>/dev/null | awk '{print $1}' || echo "")
elif [ -f bun.lock ]; then
  LOCK_HASH=$(sha256sum bun.lock 2>/dev/null | awk '{print $1}' || echo "")
fi
LAST_HASH=""
if [ -f "$LOCK_STAMP" ]; then
  LAST_HASH=$(cat "$LOCK_STAMP" 2>/dev/null || echo "")
fi

BI_START=$(date +%s)
notify_step "bun-install" "in_progress"
if [ -n "$LOCK_HASH" ] && [ "$LOCK_HASH" = "$LAST_HASH" ] && [ -d node_modules ]; then
  log "    v bun install skipped (lockfile unchanged: ${LOCK_HASH:0:12})"
  notify_step "bun-install" "succeeded" "$(( ( $(date +%s) - BI_START ) * 1000 ))"
elif "$BUN" install --frozen-lockfile >>"$LOG" 2>&1; then
  echo "$LOCK_HASH" > "$LOCK_STAMP" 2>/dev/null || true
  log "    v bun install ok"
  notify_step "bun-install" "succeeded" "$(( ( $(date +%s) - BI_START ) * 1000 ))"
else
  log "    x bun install FAILED"
  DEPLOY_FAILED=1
  FAIL_REASON="bun install failed"
  notify_step "bun-install" "failed" "$(( ( $(date +%s) - BI_START ) * 1000 ))"
fi

# ── 5. DB migrations (fail loud) ───────────────────────────────────────────
if [ "$DEPLOY_FAILED" = "0" ]; then
  DM_START=$(date +%s)
  notify_step "db-migrate" "in_progress"
  if "$BUN" run src/db/migrate.ts >>"$LOG" 2>&1; then
    log "    v db migrate ok"
    notify_step "db-migrate" "succeeded" "$(( ( $(date +%s) - DM_START ) * 1000 ))"
  else
    log "    x db migrate FAILED"
    DEPLOY_FAILED=1
    FAIL_REASON="bun run db:migrate failed"
    notify_step "db-migrate" "failed" "$(( ( $(date +%s) - DM_START ) * 1000 ))"
  fi
fi

# ── 6. Build the static binary (skipped if no source files changed) ──────
# We hash every tracked src/*.ts(x) file and stash the digest in a stamp
# file. If nothing in src/ changed, the binary from the previous deploy
# is still valid — saves 5-15s on docs-only / config-only commits.
# On any error the fallback rebuilds anyway.
if [ "$DEPLOY_FAILED" = "0" ]; then
  BD_START=$(date +%s)
  notify_step "build" "in_progress"
  mkdir -p .next
  COMPILED=.next/gluecron-server
  COMPILED_TMP=.next/gluecron-server.new
  BUILD_STAMP="$STAMP_DIR/build-src.stamp"
  SRC_HASH=$(find src -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 2>/dev/null \
    | sort -z \
    | xargs -0 sha256sum 2>/dev/null \
    | sha256sum 2>/dev/null \
    | awk '{print $1}' || echo "")
  LAST_BUILD_HASH=""
  if [ -f "$BUILD_STAMP" ]; then
    LAST_BUILD_HASH=$(cat "$BUILD_STAMP" 2>/dev/null || echo "")
  fi

  if [ -n "$SRC_HASH" ] && [ "$SRC_HASH" = "$LAST_BUILD_HASH" ] && [ -x "$COMPILED" ]; then
    log "    v build skipped (src/ unchanged: ${SRC_HASH:0:12})"
    notify_step "build" "succeeded" "$(( ( $(date +%s) - BD_START ) * 1000 ))"
  elif "$BUN" build --compile --outfile "$COMPILED_TMP" src/index.ts >>"$LOG" 2>&1; then
    mv -f "$COMPILED_TMP" "$COMPILED"
    chmod +x "$COMPILED"
    echo "$SRC_HASH" > "$BUILD_STAMP" 2>/dev/null || true
    log "    v compiled $COMPILED"
    notify_step "build" "succeeded" "$(( ( $(date +%s) - BD_START ) * 1000 ))"
  else
    rm -f "$COMPILED_TMP"
    log "    ! bun build --compile failed — systemd will fall back to bun run"
    notify_step "build" "succeeded" "$(( ( $(date +%s) - BD_START ) * 1000 ))"
  fi
fi

# ── 6.5 Pin BUILD_SHA into the systemd unit so the running process can
# report it and the SW versioning rotates exactly per-deploy. Drop-in
# survives daemon-reload; the OLD file is overwritten on every deploy.
# Without this, src/routes/pwa.ts falls back to a stable "dev-stable"
# string and the browser SW never invalidates between real deploys.
if [ "$DEPLOY_FAILED" = "0" ]; then
  DROPIN_DIR=/etc/systemd/system/gluecron.service.d
  mkdir -p "$DROPIN_DIR"
  cat > "$DROPIN_DIR/build-sha.conf" <<EOF
[Service]
Environment="BUILD_SHA=$NEW_SHA"
Environment="BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
EOF
  systemctl daemon-reload >>"$LOG" 2>&1 || log "    ! daemon-reload non-fatal warning"
  log "    v pinned BUILD_SHA=${NEW_SHA:0:12} in systemd drop-in"
fi

# ── 7. systemctl restart (blocks on sd_notify READY=1) ────────────────────
if [ "$DEPLOY_FAILED" = "0" ]; then
  RS_START=$(date +%s)
  notify_step "restart-service" "in_progress"
  if systemctl restart gluecron >>"$LOG" 2>&1; then
    log "    v systemctl restart gluecron ok"
    notify_step "restart-service" "succeeded" "$(( ( $(date +%s) - RS_START ) * 1000 ))"
  else
    log "    x systemctl restart FAILED"
    DEPLOY_FAILED=1
    FAIL_REASON="systemctl restart failed"
    notify_step "restart-service" "failed" "$(( ( $(date +%s) - RS_START ) * 1000 ))"
  fi
fi

# ── 8. Wait for /healthz to be green (fast poll, up to ~30s) ─────────────
# Tighter polling: 500ms × 10 (covers the common case where the service
# is healthy within 1-2s of restart returning), then 2s × 10 for the slow
# path. Cuts a typical successful deploy by 1-3s vs the old fixed 2s poll.
if [ "$DEPLOY_FAILED" = "0" ]; then
  HZ_START=$(date +%s)
  notify_step "healthz" "in_progress"
  green=0
  for i in 1 2 3 4 5 6 7 8 9 10; do
    code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$HEALTHZ_URL" || echo "000")
    if [ "$code" = "200" ]; then green=1; log "    healthz fast attempt $i: $code (green)"; break; fi
    sleep 0.5
  done
  if [ "$green" = "0" ]; then
    for i in 11 12 13 14 15 16 17 18 19 20; do
      code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "$HEALTHZ_URL" || echo "000")
      log "    healthz slow attempt $i: $code"
      if [ "$code" = "200" ]; then green=1; break; fi
      sleep 2
    done
  fi
  if [ "$green" = "1" ]; then
    log "    v /healthz green"
    notify_step "healthz" "succeeded" "$(( ( $(date +%s) - HZ_START ) * 1000 ))"
  else
    log "    x /healthz did not return 200 within 30s"
    DEPLOY_FAILED=1
    FAIL_REASON="/healthz timeout"
    notify_step "healthz" "failed" "$(( ( $(date +%s) - HZ_START ) * 1000 ))"
  fi
fi

# ── 9. Post-deploy smoke suite ────────────────────────────────────────────
if [ "$DEPLOY_FAILED" = "0" ]; then
  PS_START=$(date +%s)
  notify_step "full-smoke" "in_progress"
  export GLUECRON_HOST="http://localhost:${PORT}"
  if "$BUN" run scripts/post-deploy-smoke.ts >>"$LOG" 2>&1; then
    log "    v post-deploy smoke green"
    notify_step "full-smoke" "succeeded" "$(( ( $(date +%s) - PS_START ) * 1000 ))"
  else
    log "    x post-deploy smoke FAILED"
    DEPLOY_FAILED=1
    FAIL_REASON="post-deploy smoke failed"
    notify_step "full-smoke" "failed" "$(( ( $(date +%s) - PS_START ) * 1000 ))"
  fi
fi

# ── 10. Rollback on failure ───────────────────────────────────────────────
if [ "$DEPLOY_FAILED" = "1" ] && [ -n "$PREV_SHA" ] && [ "$PREV_SHA" != "$NEW_SHA" ]; then
  notify_step "rollback" "in_progress"
  log "    ! rolling back to $PREV_SHA (reason: $FAIL_REASON)"
  git reset --hard "$PREV_SHA" >>"$LOG" 2>&1 || true
  systemctl restart gluecron >>"$LOG" 2>&1 || true
  sleep 3
  rb_green=0
  for i in 1 2 3; do
    code=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTHZ_URL" || echo "000")
    log "    rollback healthz attempt $i: $code"
    if [ "$code" = "200" ]; then rb_green=1; break; fi
    sleep 2
  done
  if [ "$rb_green" = "1" ]; then
    notify_step "rollback" "succeeded"
    log "    v rollback green"
  else
    notify_step "rollback" "failed"
    log "    x ROLLBACK FAILED — human intervention required"
  fi
fi

# ── 11. Notify deploy finished ────────────────────────────────────────────
DUR_MS=$(( ( $(date +%s) - START_EPOCH ) * 1000 ))
if [ -n "${DEPLOY_EVENT_TOKEN:-}" ] && [ -n "${APP_BASE_URL:-}" ]; then
  if [ "$DEPLOY_FAILED" = "0" ]; then
    curl --silent --show-error --max-time 10 \
      -X POST "$APP_BASE_URL/api/events/deploy/finished" \
      -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
      -H "content-type: application/json" \
      --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"status\":\"succeeded\",\"duration_ms\":$DUR_MS}" \
      >>"$LOG" 2>&1 || true
  else
    ERR_PAYLOAD="$(printf '%s' "${FAIL_REASON:-deploy failed}" | head -c 512)"
    if command -v jq >/dev/null 2>&1; then
      ERR_JSON=$(printf '%s' "$ERR_PAYLOAD" | jq -Rs '.')
    else
      ERR_JSON="\"${ERR_PAYLOAD//\"/\\\"}\""
    fi
    curl --silent --show-error --max-time 10 \
      -X POST "$APP_BASE_URL/api/events/deploy/finished" \
      -H "authorization: Bearer $DEPLOY_EVENT_TOKEN" \
      -H "content-type: application/json" \
      --data "{\"run_id\":\"$RUN_ID\",\"sha\":\"$NEW_SHA\",\"status\":\"failed\",\"duration_ms\":$DUR_MS,\"error\":$ERR_JSON}" \
      >>"$LOG" 2>&1 || true
  fi
fi

if [ "$DEPLOY_FAILED" = "0" ]; then
  log "==> gluecron self-deploy SUCCESS in ${DUR_MS}ms (sha=$NEW_SHA)"
  exit 0
else
  log "==> gluecron self-deploy FAILED in ${DUR_MS}ms (reason=$FAIL_REASON)"
  exit 1
fi