@oneie/claude 0.7.0 → 0.9.0

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.
Files changed (263) hide show
  1. package/agents/abm-strategist.md +89 -0
  2. package/agents/ads-meta.md +97 -0
  3. package/agents/analyst.md +173 -0
  4. package/agents/architect.md +280 -0
  5. package/agents/brand-guardian.md +88 -0
  6. package/agents/brand-strategist.md +92 -0
  7. package/agents/campaign-content.md +90 -0
  8. package/agents/campaign-email.md +88 -0
  9. package/agents/campaign-sms.md +87 -0
  10. package/agents/campaign-social.md +87 -0
  11. package/agents/cco.md +99 -0
  12. package/agents/ceo.md +106 -0
  13. package/agents/chairman.md +120 -0
  14. package/agents/cmo.md +101 -0
  15. package/agents/community-greeter.md +89 -0
  16. package/agents/community-moderator.md +92 -0
  17. package/agents/compliance.md +92 -0
  18. package/agents/copywriter.md +100 -0
  19. package/agents/creative-strategist.md +95 -0
  20. package/agents/cro.md +98 -0
  21. package/agents/cto.md +267 -0
  22. package/agents/customer-interviewer.md +93 -0
  23. package/agents/customer-researcher.md +89 -0
  24. package/agents/customer-success-manager.md +90 -0
  25. package/agents/customer-trainer.md +90 -0
  26. package/agents/cxo.md +100 -0
  27. package/agents/demand-creator.md +89 -0
  28. package/agents/demo-mover.md +83 -0
  29. package/agents/demo-specialist.md +89 -0
  30. package/agents/demo-thai-family-law.md +87 -0
  31. package/agents/designer.md +112 -0
  32. package/agents/discovery-caller.md +98 -0
  33. package/agents/doctor.md +139 -0
  34. package/agents/educate-coach.md +100 -0
  35. package/agents/elevate-tutor.md +83 -0
  36. package/agents/email-lifecycle-marketer.md +91 -0
  37. package/agents/engage-specialist.md +99 -0
  38. package/agents/events-coordinator.md +90 -0
  39. package/agents/foundation-builder.md +94 -0
  40. package/agents/funnel-architect.md +89 -0
  41. package/agents/gift-creator.md +100 -0
  42. package/agents/google-ads.md +103 -0
  43. package/agents/guide.md +292 -0
  44. package/agents/helpdesk-dispatcher.md +88 -0
  45. package/agents/hook-specialist.md +91 -0
  46. package/agents/identify-optimizer.md +101 -0
  47. package/agents/implementer.md +305 -0
  48. package/agents/incident-commander.md +120 -0
  49. package/agents/insights-lead.md +93 -0
  50. package/agents/journey-runner.md +113 -0
  51. package/agents/linkedin-ads.md +123 -0
  52. package/agents/live-sales-chat.md +90 -0
  53. package/agents/market-researcher.md +88 -0
  54. package/agents/media-buyer.md +96 -0
  55. package/agents/movers-customer-researcher.md +95 -0
  56. package/agents/movers-foundation-builder.md +96 -0
  57. package/agents/movers-market-researcher.md +97 -0
  58. package/agents/movers-pricing-strategist.md +171 -0
  59. package/agents/nurture-architect.md +99 -0
  60. package/agents/offer-architect.md +92 -0
  61. package/agents/onboarder.md +222 -0
  62. package/agents/onboarding-specialist.md +92 -0
  63. package/agents/operations-dashboard.md +98 -0
  64. package/agents/perf-engineer.md +348 -0
  65. package/agents/playbook-writer.md +71 -0
  66. package/agents/plg-strategist.md +91 -0
  67. package/agents/positioning-architect.md +88 -0
  68. package/agents/press-officer.md +89 -0
  69. package/agents/pricing-strategist.md +94 -0
  70. package/agents/privacy-officer.md +92 -0
  71. package/agents/referral-manager.md +91 -0
  72. package/agents/refine-analyst.md +102 -0
  73. package/agents/release-manager.md +261 -0
  74. package/agents/renewals-upsell-rep.md +90 -0
  75. package/agents/review-engineer.md +327 -0
  76. package/agents/rewards-steward.md +82 -0
  77. package/agents/sales-call-coach.md +94 -0
  78. package/agents/sales-closer.md +98 -0
  79. package/agents/security-auditor.md +313 -0
  80. package/agents/sell-closer.md +101 -0
  81. package/agents/share-amplifier.md +99 -0
  82. package/agents/social-media-manager.md +92 -0
  83. package/agents/storyteller.md +250 -0
  84. package/agents/strategist.md +83 -0
  85. package/agents/strategy-aligner.md +94 -0
  86. package/agents/support-agent.md +90 -0
  87. package/agents/tagger.md +245 -0
  88. package/agents/tech-writer.md +216 -0
  89. package/agents/test-engineer.md +243 -0
  90. package/agents/tiktok-ads.md +105 -0
  91. package/agents/tracking-engineer.md +92 -0
  92. package/agents/upsell-strategist.md +101 -0
  93. package/agents/voice.md +85 -0
  94. package/agents/w4-tools.md +153 -0
  95. package/agents/w4-verify.md +20 -138
  96. package/agents/workflow-optimiser.md +84 -0
  97. package/commands/close.md +814 -160
  98. package/commands/create.md +2 -2
  99. package/commands/deploy.md +554 -175
  100. package/commands/do.md +119 -109
  101. package/commands/fast.md +67 -0
  102. package/commands/improve.md +2 -2
  103. package/commands/one.md +418 -0
  104. package/commands/see.md +1 -1
  105. package/commands/sync.md +1 -1
  106. package/commands/tasks.md +222 -0
  107. package/commands/vespio.md +110 -0
  108. package/commands/vespio.remote.md +227 -0
  109. package/hooks/hooks.json +25 -79
  110. package/hooks/lib/governor-escape-match.sh +88 -0
  111. package/hooks/lib/hook.sh +4 -13
  112. package/hooks/lib/signal.sh +9 -2
  113. package/hooks/scripts/branch-pin.sh +6 -0
  114. package/hooks/scripts/config-protect.sh +6 -0
  115. package/hooks/scripts/do-outcome-gate.sh +19 -2
  116. package/hooks/scripts/git-add-guard.sh +43 -2
  117. package/hooks/scripts/governor-escape.sh +96 -0
  118. package/hooks/scripts/load-guard.sh +127 -0
  119. package/hooks/scripts/post-edit-check.sh +36 -29
  120. package/hooks/scripts/session-start.sh +34 -80
  121. package/hooks/scripts/task-complete-verify.sh +46 -40
  122. package/package.json +1 -1
  123. package/rules/documentation.md +9 -0
  124. package/scripts/ad-copy-lint.sh +656 -0
  125. package/scripts/agentverse-audit.sh +256 -0
  126. package/scripts/asi-walk.sh +435 -0
  127. package/scripts/astro-build-cached.sh +143 -0
  128. package/scripts/blocks-manifest-cached.sh +100 -0
  129. package/scripts/blocks-manifest.mjs +497 -0
  130. package/scripts/blocks-ratchet.sh +116 -0
  131. package/scripts/blocks-render-probe.mjs +529 -0
  132. package/scripts/blocks-usage.mjs +334 -0
  133. package/scripts/chat-context-check.sh +89 -0
  134. package/scripts/close-metrics.sh +558 -0
  135. package/scripts/close-owner.sh +268 -0
  136. package/scripts/db-sync-lock-check.sh +116 -0
  137. package/scripts/deploy-dev.sh +35 -0
  138. package/scripts/deploy-emit.sh +298 -0
  139. package/scripts/deploy-gate-check.sh +128 -0
  140. package/scripts/deploy-ready.sh +78 -0
  141. package/scripts/deploy-record.sh +605 -0
  142. package/scripts/deploy.sh +1273 -0
  143. package/scripts/do-auto.sh +598 -48
  144. package/scripts/do-brief.sh +113 -0
  145. package/scripts/do-close.sh +1137 -0
  146. package/scripts/do-commit.sh +75 -0
  147. package/scripts/do-consumer-sweep.sh +18 -1
  148. package/scripts/do-cycle-shape-check.sh +160 -0
  149. package/scripts/do-decide.sh +476 -0
  150. package/scripts/do-derive-check.sh +436 -0
  151. package/scripts/do-fleet.sh +106 -28
  152. package/scripts/do-folder.sh +10 -1
  153. package/scripts/do-next.sh +106 -0
  154. package/scripts/do-orchestrate.sh +17 -5
  155. package/scripts/do-plan-json.mjs +201 -0
  156. package/scripts/do-plan-json.sh +8 -0
  157. package/scripts/do-preflight.sh +117 -0
  158. package/scripts/do-project.sh +157 -0
  159. package/scripts/do-prove-selftest.sh +108 -0
  160. package/scripts/do-prove.sh +295 -23
  161. package/scripts/do-rank.py +31 -1
  162. package/scripts/do-recon-cache.sh +7 -1
  163. package/scripts/do-recon-pack.sh +196 -0
  164. package/scripts/do-reconcile.sh +121 -4
  165. package/scripts/do-signal.sh +280 -23
  166. package/scripts/do-smoke.sh +18 -1
  167. package/scripts/do-test-gate.sh +80 -0
  168. package/scripts/do-tick.sh +102 -0
  169. package/scripts/do-tier.sh +6 -0
  170. package/scripts/do-triage.sh +182 -0
  171. package/scripts/do-ui-gate.sh +1 -1
  172. package/scripts/do-w4-gates.sh +451 -0
  173. package/scripts/do-walk.sh +12 -1
  174. package/scripts/env-sync.sh +173 -0
  175. package/scripts/factory-brief-check.sh +330 -0
  176. package/scripts/factory-check.sh +68 -14
  177. package/scripts/factory-close-check.sh +257 -0
  178. package/scripts/factory-emit.sh +211 -0
  179. package/scripts/factory-executor-check.mjs +353 -0
  180. package/scripts/factory-peak.sh +301 -0
  181. package/scripts/factory-repo.sh +118 -3
  182. package/scripts/factory-review-check.mjs +61 -0
  183. package/scripts/factory-ship.sh +61 -0
  184. package/scripts/factory-tasks-check.sh +18 -1
  185. package/scripts/factory-turn.sh +326 -0
  186. package/scripts/factory-walk.sh +396 -0
  187. package/scripts/factory-width.sh +57 -0
  188. package/scripts/fade-toxic.sh +4 -3
  189. package/scripts/fixtures/factory-brief-real.md +44 -0
  190. package/scripts/fixtures/triage-dupe.md +5 -0
  191. package/scripts/fleet-manifest.mjs +108 -0
  192. package/scripts/fleet-status.sh +110 -0
  193. package/scripts/full-suite-paths-check.sh +144 -0
  194. package/scripts/gate-reaper-check.sh +98 -0
  195. package/scripts/gate-reaper.sh +125 -0
  196. package/scripts/gate-run.sh +73 -0
  197. package/scripts/gc-content-check.sh +140 -0
  198. package/scripts/gen-dev-config.py +20 -0
  199. package/scripts/govern-bound-check.sh +60 -0
  200. package/scripts/govern-claims-check.sh +233 -0
  201. package/scripts/govern-mem-check.sh +290 -0
  202. package/scripts/governor-doors-check.sh +362 -0
  203. package/scripts/governor-escape-check.sh +171 -0
  204. package/scripts/health.sh +413 -0
  205. package/scripts/id-inventory.mjs +418 -0
  206. package/scripts/land.sh +551 -0
  207. package/scripts/lib/gc-finished.sh +77 -0
  208. package/scripts/lib/govern.sh +361 -0
  209. package/scripts/lib/govern.ts +756 -0
  210. package/scripts/lighthouse-run.sh +187 -0
  211. package/scripts/livekit-live-check.sh +61 -0
  212. package/scripts/livekit-ratchet.sh +46 -0
  213. package/scripts/load-guard-check.sh +49 -0
  214. package/scripts/machine-check.sh +102 -0
  215. package/scripts/machine-watch.sh +177 -0
  216. package/scripts/one-agents.mjs +415 -0
  217. package/scripts/one-resume.sh +78 -0
  218. package/scripts/orphan-baseline.json +182 -0
  219. package/scripts/orphan-modules.mjs +179 -0
  220. package/scripts/pr-body.sh +335 -0
  221. package/scripts/preview-fd-check.sh +289 -0
  222. package/scripts/promise-manifest.mjs +24 -1
  223. package/scripts/release.sh +322 -0
  224. package/scripts/roles-check.sh +946 -0
  225. package/scripts/sdk-build-cached.sh +64 -0
  226. package/scripts/signal-watch.sh +241 -0
  227. package/scripts/skills-publish.sh +94 -0
  228. package/scripts/speed-cache-check.sh +173 -0
  229. package/scripts/speed-check.mjs +907 -0
  230. package/scripts/speed-parity-check.sh +648 -0
  231. package/scripts/speed-waterfall-check.sh +355 -0
  232. package/scripts/substrate-env-parity.mjs +156 -0
  233. package/scripts/tasks-claim-race.mjs +108 -0
  234. package/scripts/tasks-loop.sh +185 -0
  235. package/scripts/test-cached.sh +255 -0
  236. package/scripts/test-full.sh +87 -0
  237. package/scripts/test-honesty.mjs +137 -0
  238. package/scripts/test-lanes.sh +166 -0
  239. package/scripts/test-speed.sh +94 -0
  240. package/scripts/triage-shape-check.sh +149 -0
  241. package/scripts/tsc-cached.sh +179 -0
  242. package/scripts/typedb-flake-check.sh +197 -0
  243. package/scripts/urls-lint.sh +15 -0
  244. package/scripts/verify-fast.sh +445 -0
  245. package/scripts/vespio-sync.sh +149 -0
  246. package/scripts/wf-check.mjs +104 -1
  247. package/scripts/worktree-preview.sh +879 -0
  248. package/scripts/worktree-up.sh +208 -0
  249. package/skills/livekit-agents/SKILL.md +285 -0
  250. package/skills/livekit-agents/references/freshness-rules.md +168 -0
  251. package/skills/shadcn/SKILL.md +1 -1
  252. package/skills/signal/SKILL.md +0 -1
  253. package/skills/voice/SKILL.md +94 -6
  254. package/skills/voice/corpus-check.sh +87 -0
  255. package/hooks/scripts/compact-hint.sh +0 -35
  256. package/hooks/scripts/gate-guard.sh +0 -83
  257. package/hooks/scripts/read-tracker.sh +0 -26
  258. package/hooks/scripts/session-end-verify.sh +0 -51
  259. package/hooks/scripts/stop-reflect.sh +0 -140
  260. package/hooks/scripts/sync-priority-todo.sh +0 -57
  261. package/hooks/scripts/sync-todo-docs.sh +0 -46
  262. package/hooks/scripts/tool-signal.sh +0 -48
  263. package/scripts/do-tasks-bridge.py +0 -366
@@ -0,0 +1,1273 @@
1
+ #!/usr/bin/env bash
2
+ # Deterministic /deploy — the whole pipeline with no LLM in the loop.
3
+ #
4
+ # Ships the five services to Cloudflare: gateway (api) · sync · channels ·
5
+ # pay/backend · astro (one-prod). Every step reports a number; the final
6
+ # report is the same shape /deploy prints by hand.
7
+ #
8
+ # Usage:
9
+ # bash .claude/scripts/deploy.sh dev # one.ie/web → one-dev (dev.one.ie)
10
+ # bash .claude/scripts/deploy.sh # full pipeline (PRODUCTION)
11
+ # bash .claude/scripts/deploy.sh astro # one.ie/web → one-prod only
12
+ # bash .claude/scripts/deploy.sh workers # api + sync + channels
13
+ # bash .claude/scripts/deploy.sh gateway|sync|agents|pay
14
+ #
15
+ # Flags:
16
+ # --skip-tests skip vitest (typecheck still runs)
17
+ # --no-typedb-flake-waiver
18
+ # hard-stop on a red suite even when every failure is the
19
+ # shared TypeDB Cloud cluster being unreachable. The waiver
20
+ # is ON by default (DEPLOY_ALLOW_TYPEDB_FLAKE=0 also disables)
21
+ # and is classified by signature — a real assertion break, or
22
+ # a tasks:claim `not_found`, still blocks.
23
+ # --skip-typecheck skip per-service tsc
24
+ # --skip-build reuse an existing one.ie/web/dist
25
+ # --skip-migrations skip `d1 migrations apply DB --remote`
26
+ # --skip-health deploy without the post-deploy probes
27
+ # --changed skip services with no commits since their last deploy
28
+ # --gates-only run the gates, ship nothing (a local pre-flight)
29
+ # --check-creds self-test the credential ladder, ship nothing
30
+ # --yes / -y no approval prompt (also: DEPLOY_YES=1)
31
+ # --dry-run print every command, deploy nothing
32
+ #
33
+ # NOT done here (it needs judgment): commit / PR / merge. Commit before you
34
+ # run this. The script refuses a dirty tree unless --allow-dirty.
35
+ #
36
+ # HARD RULES baked in (see .claude/commands/deploy.md):
37
+ # · never `--env production` — it ships to the `one-prod-production` decoy
38
+ # · CLOUDFLARE_API_TOKEN is unset; auth is GLOBAL_API_KEY + EMAIL
39
+ # · health-check custom domains only (*.workers.dev is blocked on this net)
40
+ # · sync is cron-only: a clean `wrangler deploy` IS its health signal
41
+ #
42
+ # THE TWO TIERS — one command, two destinations:
43
+ # dev → https://dev.one.ie (worker `one-dev`). Agents finish loops here.
44
+ # MINIMUM gate: the FAST lane. No approval prompt, no migrations, no
45
+ # five-service fan-out. Delegates to deploy-dev.sh, which is the
46
+ # authority for the dev procedure.
47
+ # prod → https://one.ie and the other four services. FULL gate always
48
+ # (FULL_VERIFY=1), approval prompt unless --yes.
49
+ # A fast pass is NEVER reported as a full pass, and `dev` never touches prod.
50
+ # dev.one.ie shares PRODUCTION's D1 and KV byte-for-byte — only the cron strip
51
+ # differs. So a dev deploy is NOT a sandbox for data: it reads and writes the
52
+ # same rows one.ie does. Ship dev freely; treat its DATA as production.
53
+
54
+ set -uo pipefail
55
+
56
+ # The deploy gate is the FULL gate, always. Build cycles run the dev lane
57
+ # (verify:fast — tsc + related tests + pinned suites); this is the one place
58
+ # every test must run, so any script that honours FULL_VERIFY runs everything.
59
+ export FULL_VERIFY=1
60
+
61
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
62
+ cd "$ROOT" || exit 1
63
+
64
+ [[ -d one.ie/web && -d api && -d sync && -d channels && -d pay/backend ]] || {
65
+ echo "deploy.sh: not the one-ie monorepo (missing a service dir): $ROOT" >&2
66
+ exit 2
67
+ }
68
+
69
+ MODE="full"
70
+ SKIP_TESTS=0 SKIP_TYPECHECK=0 SKIP_BUILD=0 SKIP_MIGRATIONS=0 SKIP_HEALTH=0
71
+ ASSUME_YES="${DEPLOY_YES:-0}" DRY=0 ALLOW_DIRTY=0 ONLY_CHANGED=0 GATES_ONLY=0 CHECK_CREDS=0
72
+ # A RED suite whose every failure is the shared TypeDB Cloud cluster refusing to
73
+ # answer is waivable — nothing was disproven. Classified by signature, never by
74
+ # filename, by typedb-flake-check.sh, which refuses the tasks:claim not_found
75
+ # privilege boundary outright. --no-typedb-flake-waiver restores the hard stop.
76
+ TYPEDB_FLAKE_WAIVER="${DEPLOY_ALLOW_TYPEDB_FLAKE:-1}" TESTS_WAIVED=0
77
+
78
+ for a in "$@"; do
79
+ case "$a" in
80
+ dev|full|astro|workers|gateway|sync|agents|channels|pay) MODE="$a" ;;
81
+ --skip-tests) SKIP_TESTS=1 ;;
82
+ --skip-typecheck) SKIP_TYPECHECK=1 ;;
83
+ --skip-build) SKIP_BUILD=1 ;;
84
+ --skip-migrations) SKIP_MIGRATIONS=1 ;;
85
+ --skip-health) SKIP_HEALTH=1 ;;
86
+ --allow-dirty) ALLOW_DIRTY=1 ;;
87
+ --no-typedb-flake-waiver) TYPEDB_FLAKE_WAIVER=0 ;;
88
+ --changed) ONLY_CHANGED=1 ;;
89
+ --gates-only) GATES_ONLY=1 ;;
90
+ --check-creds) CHECK_CREDS=1 ;;
91
+ --all) ONLY_CHANGED=0 ;;
92
+ -y|--yes) ASSUME_YES=1 ;;
93
+ -n|--dry-run) DRY=1 ;;
94
+ -h|--help) sed -n '2,46p' "${BASH_SOURCE[0]}"; exit 0 ;;
95
+ *) echo "deploy.sh: unknown argument '$a' (try --help)" >&2; exit 2 ;;
96
+ esac
97
+ done
98
+ [[ "$MODE" == channels ]] && MODE=agents
99
+
100
+ # ── dev tier ─────────────────────────────────────────────────────────────────
101
+ # `./deploy dev` is a different destination with a different gate, so it exits
102
+ # here rather than threading a flag through 700 lines of production pipeline.
103
+ # deploy-dev.sh owns the dev procedure; this is only the door to it.
104
+ if [[ "$MODE" == dev ]]; then
105
+ # --skip-tests is the only production flag that carries over: it is the dev
106
+ # script's DEV_SKIP_GATE. `(( SKIP_TESTS ))` and not `${SKIP_TESTS:+...}` —
107
+ # the latter expands on the literal string "0" and would silently skip the
108
+ # gate on every dev deploy.
109
+ dev_gate=0; (( SKIP_TESTS )) && dev_gate=1
110
+ if (( DRY )); then
111
+ echo "[dry] DEV_SKIP_GATE=$dev_gate bash .claude/scripts/deploy-dev.sh"
112
+ exit 0
113
+ fi
114
+ exec env "DEV_SKIP_GATE=$dev_gate" bash "$ROOT/.claude/scripts/deploy-dev.sh"
115
+ fi
116
+
117
+ LOG_DIR="$ROOT/.deploy-logs"
118
+ mkdir -p "$LOG_DIR"
119
+ STAMP="$(date +%Y%m%d-%H%M%S)"
120
+ LOG="$LOG_DIR/deploy-$STAMP.log"
121
+
122
+ RED=$'\033[31m'; GRN=$'\033[32m'; YEL=$'\033[33m'; DIM=$'\033[2m'; OFF=$'\033[0m'
123
+ [[ -t 1 ]] || { RED=""; GRN=""; YEL=""; DIM=""; OFF=""; }
124
+
125
+ say() { printf '%s\n' "$*" | tee -a "$LOG"; }
126
+ step() { printf '\n%s══ %s%s\n' "$DIM" "$*" "$OFF" | tee -a "$LOG"; }
127
+ ok() { printf '%s ✓%s %s\n' "$GRN" "$OFF" "$*" | tee -a "$LOG"; }
128
+ bad() { printf '%s ✗%s %s\n' "$RED" "$OFF" "$*" | tee -a "$LOG"; }
129
+ warn() { printf '%s !%s %s\n' "$YEL" "$OFF" "$*" | tee -a "$LOG"; }
130
+ die() { bad "$*"; say ""; say "log: $LOG"; exit 1; }
131
+
132
+ # run <label> <dir> <cmd...> — logs, honours --dry-run, returns the exit code
133
+ run() {
134
+ local label="$1" dir="$2"; shift 2
135
+ if (( DRY )); then say " [dry] (cd $dir && $*)"; return 0; fi
136
+ say " → $label"
137
+ ( cd "$ROOT/$dir" && unset CLOUDFLARE_API_TOKEN && "$@" ) >>"$LOG" 2>&1
138
+ }
139
+
140
+ want_astro=0 want_gateway=0 want_sync=0 want_agents=0 want_pay=0
141
+ case "$MODE" in
142
+ full) want_astro=1 want_gateway=1 want_sync=1 want_agents=1 want_pay=1 ;;
143
+ astro) want_astro=1 ;;
144
+ workers) want_gateway=1 want_sync=1 want_agents=1 ;;
145
+ gateway) want_gateway=1 ;;
146
+ sync) want_sync=1 ;;
147
+ agents) want_agents=1 ;;
148
+ pay) want_pay=1 ;;
149
+ esac
150
+
151
+ # --changed: a service whose tree has no new commits since its last successful
152
+ # deploy has nothing to ship. The marker is written per service AFTER a clean
153
+ # deploy, so an interrupted run never marks anything shipped. Secrets/bindings
154
+ # change outside git — use --all (the default) when that's what you changed.
155
+ svc_paths() { case "$1" in
156
+ astro) echo "one.ie/web packages" ;;
157
+ gateway) echo "api" ;;
158
+ sync) echo "sync" ;;
159
+ channels) echo "channels packages" ;;
160
+ pay) echo "pay/backend" ;;
161
+ esac }
162
+ mark_file() { echo "$LOG_DIR/last-$1.sha"; }
163
+ svc_changed() { # 0 = has new commits (or no marker yet)
164
+ local last; last="$(cat "$(mark_file "$1")" 2>/dev/null)"
165
+ [[ -n "$last" ]] || return 0
166
+ git merge-base --is-ancestor "$last" HEAD 2>/dev/null || return 0
167
+ [[ -n "$(git log --oneline "$last..HEAD" -- $(svc_paths "$1") 2>/dev/null)" ]]
168
+ }
169
+ SKIPPED_UNCHANGED=()
170
+ if (( ONLY_CHANGED )); then
171
+ for svc in astro gateway sync channels pay; do
172
+ case "$svc" in
173
+ astro) (( want_astro )) || continue ;;
174
+ gateway) (( want_gateway )) || continue ;;
175
+ sync) (( want_sync )) || continue ;;
176
+ channels) (( want_agents )) || continue ;;
177
+ pay) (( want_pay )) || continue ;;
178
+ esac
179
+ svc_changed "$svc" && continue
180
+ SKIPPED_UNCHANGED+=("$svc")
181
+ case "$svc" in
182
+ astro) want_astro=0 ;; gateway) want_gateway=0 ;; sync) want_sync=0 ;;
183
+ channels) want_agents=0 ;; pay) want_pay=0 ;;
184
+ esac
185
+ done
186
+ fi
187
+
188
+ # Typecheck only what is being shipped — `./deploy gateway` has no business
189
+ # spending 30s on one.ie/web. Full mode still covers all five.
190
+ SERVICES_TYPECHECK=()
191
+ (( want_astro )) && SERVICES_TYPECHECK+=(one.ie/web)
192
+ (( want_gateway )) && SERVICES_TYPECHECK+=(api)
193
+ (( want_sync )) && SERVICES_TYPECHECK+=(sync)
194
+ (( want_agents )) && SERVICES_TYPECHECK+=(channels)
195
+ (( want_pay )) && SERVICES_TYPECHECK+=(pay/backend)
196
+
197
+ if (( ONLY_CHANGED )) && ! (( want_astro + want_gateway + want_sync + want_agents + want_pay )); then
198
+ echo "deploy: nothing changed since the last deploy of ${SKIPPED_UNCHANGED[*]} — use --all to ship anyway"
199
+ exit 0
200
+ fi
201
+
202
+ say "deploy · mode=$MODE · $(date '+%Y-%m-%d %H:%M:%S')"
203
+ say "log: $LOG"
204
+
205
+ # ── Cloudflare credential resolution ────────────────────────────────────────
206
+ # WHY THIS IS A LADDER AND NOT A LOOKUP (measured 2026-08-19, cost one red
207
+ # deploy and one wrong diagnosis):
208
+ #
209
+ # There were THREE credentials reachable from one `./deploy`, and nothing chose
210
+ # between them — each of the five services silently got whichever one its own
211
+ # cwd happened to surface:
212
+ #
213
+ # 1. a STALE 37-char global key in the ambient env (injected by
214
+ # ~/.claude/settings.json `env`, so it exists in Claude Code sessions and
215
+ # NOT in a plain terminal — which is why it was invisible for so long).
216
+ # /user → 403. Dead.
217
+ # 2. the GOOD 52-char key in one.ie/web/.env + .env.local. /user → 200.
218
+ # wrangler 4.x auto-loads `.env` from the CWD, so this one is only
219
+ # reachable from one.ie/web — the single directory of the five that has it.
220
+ # 3. the `wrangler login` OAuth session, used when neither of the above is in
221
+ # scope.
222
+ #
223
+ # Process env BEATS a per-directory `.env`. So the stale key shadowed the good
224
+ # one everywhere, Step 4's whoami probe failed, its `unset` cleared the ambient
225
+ # key, and then: Step 6.5 in one.ie/web picked up (2) from `.env` and passed,
226
+ # while Step 6.6 in channels — which has no `.env` — fell through to (3) and
227
+ # died `code: 7403`. A deploy must never be able to use two different
228
+ # credentials in two consecutive steps.
229
+ #
230
+ # The rules that follow from that:
231
+ # · RESOLVE one credential explicitly, from an ordered ladder of sources.
232
+ # · Test it with curl against the API — ground truth — never `wrangler
233
+ # whoami` in one directory, which answers about a credential the other four
234
+ # services will not use.
235
+ # · EXPORT the winner so all five run() subshells share it; that also
236
+ # neutralises the per-dir `.env` divergence without editing any `.env`.
237
+ # · NAME the source in the log (sha8 + length, never bytes).
238
+ # · PROVE all five services agree on the account id before shipping.
239
+ # · Run all of this BEFORE the 20-minute gates, not after them.
240
+ CF_AUTH="" CF_AUTH_SOURCE="" CF_ACCOUNT=""
241
+
242
+ # _cf_probe <email> <key> — 200 from /user means these exact bytes authenticate.
243
+ # Ground truth, and direction-agnostic: it is equally able to prove a key ALIVE
244
+ # (2026-08-19) as dead (2026-08-18). Never conclude either from wrangler alone.
245
+ _cf_probe() {
246
+ local e="$1" k="$2" code
247
+ [[ -n "$e" && -n "$k" ]] || return 1
248
+ code="$(curl -s -m 15 -o /dev/null -w '%{http_code}' \
249
+ -H "X-Auth-Email: $e" -H "X-Auth-Key: $k" \
250
+ https://api.cloudflare.com/client/v4/user 2>/dev/null)"
251
+ [[ "$code" == "200" ]]
252
+ }
253
+
254
+ # _cf_fingerprint <secret> — length + sha8. The whole point of logging the
255
+ # credential's identity is to make "which key was that?" answerable next time,
256
+ # so it must be logged in a form that can never leak the key itself.
257
+ _cf_fingerprint() { printf 'len=%s sha=%s' "${#1}" "$(printf %s "$1" | shasum | cut -c1-8)"; }
258
+
259
+ # _cf_read <file> <var> — one value out of a .env-style file, quotes stripped.
260
+ _cf_read() {
261
+ [[ -f "$1" ]] || return 1
262
+ local v; v="$(grep -m1 "^$2=" "$1" 2>/dev/null | cut -d= -f2- | tr -d '"'"'"'\r' | xargs)"
263
+ [[ -n "$v" ]] && printf '%s' "$v"
264
+ }
265
+
266
+ _cf_resolve() {
267
+ local e k src
268
+ # Rung 1-3: a global key from ambient env, then repo root, then one.ie/web.
269
+ # Ordered so an operator's explicit export still wins, but a STALE export
270
+ # cannot block a good key on disk — it just fails its probe and falls through.
271
+ for src in "ambient env" "$ROOT/.env.local" "$ROOT/one.ie/web/.env"; do
272
+ case "$src" in
273
+ "ambient env")
274
+ e="${CLOUDFLARE_EMAIL:-}"
275
+ k="${CLOUDFLARE_API_KEY:-${CLOUDFLARE_GLOBAL_API_KEY:-}}" ;;
276
+ *)
277
+ e="$(_cf_read "$src" CLOUDFLARE_EMAIL || true)"
278
+ k="$(_cf_read "$src" CLOUDFLARE_API_KEY || _cf_read "$src" CLOUDFLARE_GLOBAL_API_KEY || true)"
279
+ [[ -n "${e:-}" ]] || e="${CLOUDFLARE_EMAIL:-}" ;;
280
+ esac
281
+ if _cf_probe "${e:-}" "${k:-}"; then
282
+ # wrangler reads CLOUDFLARE_API_KEY; older copies of this script exported
283
+ # only CLOUDFLARE_GLOBAL_API_KEY, a name wrangler ignores (the 2026-08-18
284
+ # bug). Export BOTH so neither spelling can diverge again.
285
+ export CLOUDFLARE_EMAIL="$e" CLOUDFLARE_API_KEY="$k" CLOUDFLARE_GLOBAL_API_KEY="$k"
286
+ CF_AUTH="global-api-key"
287
+ CF_AUTH_SOURCE="$src ($(_cf_fingerprint "$k"))"
288
+ return 0
289
+ fi
290
+ [[ -n "${k:-}" ]] && say " ${DIM}rejected: $src ($(_cf_fingerprint "$k")) — /user did not answer 200${OFF}"
291
+ done
292
+ # Rung 4: OAuth. Last, because it expires (~1h) and because a shadowing key
293
+ # must never let the deploy land here by accident. A DEAD ambient key would
294
+ # otherwise sit in the env poisoning every service, so strip all three now.
295
+ unset CLOUDFLARE_EMAIL CLOUDFLARE_API_KEY CLOUDFLARE_GLOBAL_API_KEY
296
+ if ( cd "$ROOT/one.ie/web" && ./node_modules/.bin/wrangler whoami >/dev/null 2>&1 ); then
297
+ CF_AUTH="oauth"; CF_AUTH_SOURCE="wrangler login session (expires ~1h — re-run 'wrangler login' if a step 403s)"
298
+ return 0
299
+ fi
300
+ return 1
301
+ }
302
+
303
+ # _cf_agree — every service must be deploying to the SAME account. This is the
304
+ # check that would have caught 6.5-vs-6.6 before either ran: one.ie/web
305
+ # answering about a different credential than channels IS the bug, and it is
306
+ # only visible by asking each directory separately.
307
+ _cf_agree() {
308
+ (( DRY )) && return 0
309
+ local d id first="" bad=0
310
+ for d in one.ie/web api sync channels pay/backend; do
311
+ id="$( cd "$ROOT/$d" 2>/dev/null && bunx wrangler whoami 2>/dev/null \
312
+ | grep -oE '[0-9a-f]{32}' | head -1 )"
313
+ if [[ -z "$id" ]]; then warn "$d — no account id from wrangler whoami"; bad=1; continue; fi
314
+ if [[ -z "$first" ]]; then first="$id"; CF_ACCOUNT="$id"
315
+ elif [[ "$id" != "$first" ]]; then
316
+ bad "$d resolves account $id, but one.ie/web resolves $first"
317
+ say " a per-directory .env is overriding the resolved credential in $d"
318
+ bad=1
319
+ fi
320
+ done
321
+ (( bad )) && return 1
322
+ ok "5/5 services agree on account ${first:0:8}…"
323
+ return 0
324
+ }
325
+
326
+ # --check-creds — the red half. A gate that has never been seen to FAIL is not
327
+ # known to work (repo canon: presence greps are not proof). This plants a key
328
+ # that cannot authenticate and asserts the ladder REJECTS it and falls through,
329
+ # then asserts the five services agree. Ships nothing.
330
+ if (( CHECK_CREDS )); then
331
+ step "credential ladder — self-test"
332
+ fails=0
333
+ unset CLOUDFLARE_API_TOKEN
334
+ # Resolve for real first — tests 3 and 4 assert properties OF a resolution,
335
+ # so running them against an unresolved shell tests nothing.
336
+ _cf_resolve || die "self-test cannot run: no working credential on any rung"
337
+ ok "resolved: $CF_AUTH"
338
+
339
+ say " 1/4 · a dead key must be rejected, not exported"
340
+ if _cf_probe "nobody@example.invalid" "0000000000000000000000000000000000000"; then
341
+ bad "_cf_probe returned 0 for a key that cannot authenticate — the probe is fail-open"; fails=1
342
+ else ok "dead key rejected by /user probe"; fi
343
+
344
+ say " 2/4 · a live credential must still resolve with a dead key shadowing it"
345
+ ( export CLOUDFLARE_API_KEY="0000000000000000000000000000000000000" \
346
+ CLOUDFLARE_GLOBAL_API_KEY="0000000000000000000000000000000000000" \
347
+ CLOUDFLARE_EMAIL="nobody@example.invalid"
348
+ _cf_resolve >/dev/null 2>&1 && [[ -n "$CF_AUTH" ]] ) \
349
+ && ok "ladder fell through the shadowing key to a working rung" \
350
+ || { bad "a dead ambient key BLOCKS resolution — this is the 2026-08-19 bug"; fails=1; }
351
+
352
+ say " 3/4 · the resolved source must be named, and must not be the key itself"
353
+ if [[ "$CF_AUTH_SOURCE" == *"sha="* || "$CF_AUTH" == "oauth" ]]; then
354
+ ok "source reported: $CF_AUTH_SOURCE"
355
+ else bad "credential source is unnamed — the next confusion is unavoidable"; fails=1; fi
356
+ if [[ -n "${CLOUDFLARE_API_KEY:-}" && "$CF_AUTH_SOURCE" == *"${CLOUDFLARE_API_KEY}"* ]]; then
357
+ bad "the log line contains the key BYTES"; fails=1
358
+ else ok "no key bytes in the log line"; fi
359
+
360
+ say " 4/4 · all five services must agree on the account"
361
+ _cf_agree || fails=1
362
+
363
+ say ""
364
+ (( fails )) && die "credential self-test FAILED"
365
+ ok "credential self-test passed"
366
+ say "log: $LOG"; exit 0
367
+ fi
368
+
369
+ # ── Step 0.4 — credentials (BEFORE the slow gates) ──────────────────────────
370
+ # It was after them: on 2026-08-18 a bad credential was discovered only at Step
371
+ # 6.5, having already spent the full test suite and a 2m14s production build.
372
+ # One curl costs nothing. Fail here.
373
+ step "Step 0.4 — credentials"
374
+ unset CLOUDFLARE_API_TOKEN
375
+ _cf_resolve || die "no working Cloudflare credential — run 'wrangler login', or put a VALID CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL in .env.local"
376
+ ok "resolved: $CF_AUTH"
377
+ say " source: $CF_AUTH_SOURCE"
378
+
379
+ # ── Step 0 — tree state (before the slow gates: a dirty tree should fail fast)
380
+ step "Step 0 — tree"
381
+ # Same reasoning as the dirty-tree check, applied to the APPROVAL gate. That
382
+ # prompt lives at Step 6, AFTER the gate battery, so a launch that can never
383
+ # answer it pays for every typecheck, the whole suite and the astro build and
384
+ # only then dies at `aborted at approval`. Measured 2026-09-04: one nohup'd
385
+ # deploy burned a 189s green cycle exactly that way, and the box was contended
386
+ # enough at the time that the preceding cycle had cost 5508s.
387
+ #
388
+ # stdin not a TTY means nobody can type "yes". Refuse here, where it is free.
389
+ if (( ! ASSUME_YES )) && (( ! DRY )) && [[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]] && [[ ! -t 0 ]]; then
390
+ die "non-interactive (stdin is not a TTY) and the Step 6 approval prompt cannot be answered. Pass --yes / -y, or set DEPLOY_YES=1."
391
+ fi
392
+ BRANCH="$(git rev-parse --abbrev-ref HEAD)"
393
+ HEAD_SHA="$(git rev-parse --short HEAD)"
394
+ say " branch=$BRANCH head=$HEAD_SHA"
395
+ DIRTY="$(git status --porcelain | wc -l | tr -d ' ')"
396
+ if [[ "$DIRTY" != "0" ]]; then
397
+ git status --porcelain | sed 's/^/ /' | tee -a "$LOG"
398
+ if (( ALLOW_DIRTY )); then
399
+ warn "$DIRTY uncommitted path(s) — shipping anyway (--allow-dirty)"
400
+ else
401
+ die "$DIRTY uncommitted path(s). Commit them (deploy.sh does not write git history), or pass --allow-dirty."
402
+ fi
403
+ else
404
+ ok "clean tree"
405
+ fi
406
+
407
+ # ── Step 0.5 — generated artifacts (cheap, before the slow gates) ─────────
408
+ # Same reasoning as Step 0.4: one cheap deterministic check, run where failing is
409
+ # free, rather than after five typechecks, the whole suite and an astro build.
410
+ #
411
+ # blocks-manifest.mjs builds THREE COMMITTED artifacts from one block registry --
412
+ # one.ie/web/src/lib/puck/block-manifest.json (what tooling reads),
413
+ # channels/src/generated/block-enum.ts (what the model is told) and
414
+ # one.ie/web/src/lib/puck/block-schema.generated.ts (what the browser normalises
415
+ # against) -- and until 2026-09-09 nothing ran it: not this script, not
416
+ # verify-fast.sh, not a test. `grep -c blocks-manifest` on both was 0 and 0.
417
+ # A drifted enum ships a vocabulary the model is told about and the browser
418
+ # cannot render, so the generator belongs on the SHIP gate and not only the
419
+ # dev lane.
420
+ #
421
+ # Step 0 has already refused a dirty tree, so what --check diffs is exactly what
422
+ # would ship. It exits non-zero on drift AND on failing to run, and both stop the
423
+ # deploy: an unrun gate is not a pass.
424
+ #
425
+ # THE DRY BRANCH IS EXPLICIT, and it has to be: `run` returns 0 under --dry-run
426
+ # (see its definition above), so `if run ...; then ok "no drift"` would print a
427
+ # GREEN verdict for a check that never executed -- an unrun gate reported as a
428
+ # pass, in the one script where that is the cardinal sin. `gate_start` below
429
+ # already handles DRY the same way: say what would run, record no verdict.
430
+ step "Step 0.5 — generated artifacts"
431
+ if (( DRY )); then
432
+ say " [dry] node .claude/scripts/blocks-manifest.mjs --check"
433
+ elif run "blocks-manifest --check" . node "$ROOT/.claude/scripts/blocks-manifest.mjs" --check; then
434
+ ok "block manifest: no drift"
435
+ else
436
+ tail -5 "$LOG" | sed 's/^/ /'
437
+ die "block manifest DRIFT (or --check could not run) — regenerate with 'node .claude/scripts/blocks-manifest.mjs' and commit all three artifacts"
438
+ fi
439
+
440
+ # ── Steps 1+3 — the slow gates, all at once ─────────────────────────────────
441
+ # typecheck ×5, vitest and the astro build share no STATE — the only thing they
442
+ # share is the SDK's dist/, built first if missing. Running them concurrently
443
+ # makes W0+build cost max() instead of sum(). Nothing here has a side effect
444
+ # outside the working tree, so a red gate still stops the deploy before
445
+ # anything ships.
446
+ #
447
+ # They are NOT independent in COST, and the comment here used to claim they
448
+ # were. This box has 10 cores / 4 performance cores; the block above spawns 5
449
+ # tsc, an 8 GiB-heap astro build, and a vitest that forks its own worker pool.
450
+ # Anything inside the suite that shells out to a heavy child competes with all
451
+ # of it — measured 2026-08-08, tests/integration/reconcile.test.ts (which runs
452
+ # a full-repo tsc of its own) went 53s standalone → 376s here and blew its
453
+ # budget, failing the PASSING direction and blocking a deploy over nothing.
454
+ # Test budgets in this repo must therefore be sized for a STARVED run, not a
455
+ # quiet one. If a gate ever needs the box to itself, take it out of this block
456
+ # rather than widening every timeout downstream.
457
+
458
+ # web + channels resolve @oneie/sdk types out of packages/sdk/dist — an unbuilt
459
+ # dist fakes a wall of TS2307, so this one is a real dependency, done serially.
460
+ if ! (( SKIP_TYPECHECK )) && [[ ! -d packages/sdk/dist ]]; then
461
+ step "Step 1.0 — @oneie/sdk build (dist/ missing)"
462
+ run "build @oneie/sdk" packages/sdk bun run build || die "SDK build failed — see $LOG"
463
+ ok "sdk built"
464
+ fi
465
+
466
+ # ── heavy-gate concurrency, priced in MEMORY not cores ──────────────────────
467
+ # The five typechecks are cheap (~1s each, tree-fingerprint cached). The other
468
+ # two are not: the astro build runs with --max-old-space-size=8192 and vitest
469
+ # runs a driver plus 4 forks (~6GB). Both at once wants ~14GB, and this box has
470
+ # 24GB with editors and sessions already resident.
471
+ #
472
+ # Cores were never the binding constraint, so price the gates in memory. What
473
+ # this DOES buy: two gates that want ~14GB no longer overlap on a 24GB box.
474
+ #
475
+ # What it does NOT buy, and an earlier version of this comment wrongly claimed:
476
+ # it is not the fix for the vitest gate HANGING. That was reproduced with the
477
+ # gates serialised, the box at 41% free, no swap thrash and no sockets held —
478
+ # see .claude/commands/deploy.md "The vitest gate hangs". Serialising addresses
479
+ # a slow gate; the bound below is what addresses a parked one.
480
+ #
481
+ # Same reasoning as lib/govern.sh gate_headroom, but priced for THESE two gates
482
+ # rather than a generic ~2GB cycle. A probe that cannot read memory returns a
483
+ # large number — a broken sensor must never silently serialise the pipeline.
484
+ HEAVY_NEED_GB="${DEPLOY_HEAVY_NEED_GB:-14}" # build 8 + vitest ~6
485
+ # shellcheck source=lib/govern.sh
486
+ . "$ROOT/.claude/scripts/lib/govern.sh"
487
+ # This used to be `hw.memsize * memory_pressure "free percentage" / 100`, which
488
+ # is the defect lib/govern.sh documents at length: that percentage counts file
489
+ # cache, purgeable and compressed pages as free. Measured 2026-09-03 it read 74%
490
+ # on a box with 1.3% actually free and 9.4 GB in swap — so this gate overlapped
491
+ # two ~7 GB gates on a machine that had ~3 GB. gate_mem_avail_mb is the honest
492
+ # quantity. Unreadable probe still returns 999: a broken sensor must never
493
+ # silently serialise the pipeline.
494
+ heavy_free_gb() {
495
+ local avail_mb
496
+ avail_mb="$(gate_mem_avail_mb 2>/dev/null || echo "")"
497
+ [ -z "$avail_mb" ] && { echo 999; return 0; }
498
+ echo $(( avail_mb / 1024 ))
499
+ }
500
+ HEAVY_FREE_GB="$(heavy_free_gb)"
501
+ if (( HEAVY_FREE_GB >= HEAVY_NEED_GB )); then HEAVY_PARALLEL=1; else HEAVY_PARALLEL=0; fi
502
+ [[ "${DEPLOY_HEAVY_PARALLEL:-}" == 1 ]] && HEAVY_PARALLEL=1 # escape hatch
503
+
504
+ if (( HEAVY_PARALLEL )); then
505
+ step "Steps 1+3 — gates in parallel (typecheck ×5 · vitest · astro build)"
506
+ say " memory: ${HEAVY_FREE_GB}GB free >= ${HEAVY_NEED_GB}GB needed — heavy gates overlap"
507
+ else
508
+ step "Steps 1+3 — gates (typecheck ×5 parallel · vitest + astro build serial)"
509
+ say " memory: ${HEAVY_FREE_GB}GB free < ${HEAVY_NEED_GB}GB needed — heavy gates serialised"
510
+ say " (overlapping them pages; measured 1145s vs 176s — see deploy.sh)"
511
+ fi
512
+
513
+ # ── the run, streamed into the world ────────────────────────────────────────
514
+ # Until 2026-09-07 this script emitted NOTHING — no signal, no row — and a
515
+ # committed JSON file was a deploy's only trace. `deploy-emit.sh` puts each gate
516
+ # on the same run/event stream /do and the factory already write to, LIVE, so a
517
+ # reader can see a deploy that is happening rather than only one that happened.
518
+ #
519
+ # THREE RULES, and all three are about not making the telemetry load-bearing:
520
+ #
521
+ # 1. IT CAN NEVER FAIL THE DEPLOY. Backgrounded and `|| true`, exactly as
522
+ # _record_run is guarded. A gate that passed while the network was down is
523
+ # still a gate that passed, and a deploy going red because it could not
524
+ # phone home is a worse failure than the one it was reporting.
525
+ # 2. IT CAN NEVER SLOW THE DEPLOY. `&` — the emitter's own --max-time is 8s and
526
+ # that must not land on the critical path of a gate somebody is waiting on.
527
+ # 3. --dry-run EMITS NOTHING. A run row for a deploy that did not happen is the
528
+ # theater /deploy already refuses to render. Checked here, because the
529
+ # emitter cannot know its caller's flags.
530
+ #
531
+ # A dropped emit degrades to a GAP in the trace, never to a lie: the mapper
532
+ # treats an absent stage as absent and has no default frame.
533
+ # bash 3.2 (what macOS ships) closes `$(` at the FIRST `)` it meets — and a
534
+ # case pattern ends in one. So the old form,
535
+ # EMIT_TARGET="$( case "$MODE" in dev) echo dev.one.ie ;; *) echo one.ie ;; esac )"
536
+ # raised `syntax error near unexpected token` at run time and left EMIT_TARGET
537
+ # holding the literal tail ` echo dev.one.ie ;; *) echo one.ie ;; esac )`, which
538
+ # was then passed as --target and written into deploy-runs.json. Measured on the
539
+ # 2026-09-08 production ship, in the log, twice.
540
+ #
541
+ # That is the one outcome the comment above forbids: an emit that degrades to a
542
+ # LIE rather than a gap. deploy-runs.json is the artifact the release path reads
543
+ # to learn what production runs, so a false target there misinforms the next
544
+ # person to ask "what is live". An `if` has no pattern and no closing paren.
545
+ if [ "$MODE" = dev ]; then EMIT_TARGET="dev.one.ie"; else EMIT_TARGET="one.ie"; fi
546
+ EMIT_ENV="$( [[ "$MODE" == dev ]] && echo dev || echo prod )"
547
+
548
+ # deploy.sh's gate names are implementation names (`tsc-one.ie-web`, `vitest`);
549
+ # DEPLOY_SPINE_STEPS are the pipeline's. Anything unmapped emits NOTHING rather
550
+ # than inventing a stage — an unknown stage is refused at the receiver anyway,
551
+ # and a gate the spine has no name for is better absent than mislabelled.
552
+ _spine_stage() {
553
+ case "$1" in
554
+ tsc-*) echo typecheck ;;
555
+ vitest) echo tests ;;
556
+ build) echo build ;;
557
+ *) echo "" ;;
558
+ esac
559
+ }
560
+
561
+ # The verdict CLOSES the run, and it is a field of its own rather than something
562
+ # inferred from the last stage to arrive. `--gates-only` is a complete,
563
+ # successful run that never reaches ship or health; closing on a stage would
564
+ # leave every one of those runs `open` forever in every run list.
565
+ _emit_verdict() {
566
+ local v="$1"
567
+ _emit --verdict "$v" \
568
+ --detail "shipped=${TARGETS[*]:-none}" \
569
+ --detail "wallSec=$(( SECONDS ))" \
570
+ --detail "mode=${MODE}" \
571
+ --detail "heavyParallel=$( (( ${HEAVY_PARALLEL:-0} )) && echo true || echo false )" \
572
+ --detail "memFreeGb=${HEAVY_FREE_GB:-0}"
573
+ # Give the backgrounded emits a moment to land. Bounded and ignored: the run
574
+ # is closing either way, and a deploy must not wait on its own telemetry.
575
+ wait -n 2>/dev/null || true
576
+ }
577
+
578
+ _emit() { # ...deploy-emit.sh args
579
+ [[ -x "$ROOT/.claude/scripts/deploy-emit.sh" ]] || return 0
580
+ (( DRY )) && return 0
581
+ ( bash "$ROOT/.claude/scripts/deploy-emit.sh" \
582
+ --run "$STAMP" --target "$EMIT_TARGET" --env "$EMIT_ENV" \
583
+ --door "deploy${MODE:+ $MODE}" \
584
+ --sha "$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '')" \
585
+ "$@" >/dev/null 2>&1 || true ) &
586
+ return 0
587
+ }
588
+
589
+ GATE_NAMES=() GATE_PIDS=() GATE_LOGS=() GATE_RC=() GATE_T=() GATE_DUR=()
590
+ GATE_BOUND="${DEPLOY_GATE_TIMEOUT:-900}" # healthy vitest 176s, build 174s
591
+ # Typechecks were run bare on the claim that they are "cached ~1s each". They
592
+ # are NOT: no tsconfig in this repo sets `incremental`/`tsBuildInfoFile`, so
593
+ # every one is a full check. Measured 2026-08-23: a single one held 1.5GB for
594
+ # 12m, and five run in parallel — ~7.5GB of fan-out that the HEAVY_NEED_GB test
595
+ # below never covered, because it prices only vitest+build. On a cold worktree
596
+ # that is the whole melt (load 52, swap 20GB).
597
+ #
598
+ # So price them the same way: if the box cannot hold the fan-out, route them
599
+ # through the semaphore. deploy.sh holds no slot itself, so the nested calls
600
+ # really do queue (gate-run.sh is re-entrant — this would be cosmetic if an
601
+ # outer slot were held). A warm, healthy box still runs them bare and parallel.
602
+ TSC_EACH_GB="${DEPLOY_TSC_EACH_GB:-2}"
603
+ TSC_NEED_GB=$(( TSC_EACH_GB * ${#SERVICES_TYPECHECK[@]} ))
604
+ if (( HEAVY_FREE_GB >= TSC_NEED_GB )); then TSC_GOVERNED=0; else TSC_GOVERNED=1; fi
605
+ [[ "${DEPLOY_TSC_GOVERNED:-}" == 1 ]] && TSC_GOVERNED=1 # escape hatch
606
+ [[ "${DEPLOY_TSC_GOVERNED:-}" == 0 ]] && TSC_GOVERNED=0
607
+ gate_start() { # <name> <dir> <cmd...>
608
+ local name="$1" dir="$2"; shift 2
609
+ if (( DRY )); then say " [dry] (cd $dir && $*)"; return; fi
610
+ local out="$LOG_DIR/$name-$STAMP.log"
611
+ ( cd "$ROOT/$dir" && unset CLOUDFLARE_API_TOKEN && "$@" ) >"$out" 2>&1 &
612
+ GATE_NAMES+=("$name"); GATE_PIDS+=("$!"); GATE_LOGS+=("$out"); GATE_RC+=(-1)
613
+ GATE_T+=("$SECONDS"); GATE_DUR+=(-1)
614
+ # The `start` frame is what makes a run render as IN PROGRESS rather than
615
+ # appearing whole at the end. Emitted per LAUNCH, so a serialised gate's
616
+ # start is the moment it really started, not the moment the batch did.
617
+ { st="$(_spine_stage "$name")"; [[ -n "$st" ]] && _emit --stage "$st" --status start; } || true
618
+ say " → $name (pid $!)"
619
+ }
620
+ # Heavy gates run under gate-run.sh for its BOUND and its process-group REAP:
621
+ # macOS ships no timeout(1), and killing only the shell reparents the vitest
622
+ # forks to launchd. A hung gate must die on a clock, not outlive the deploy.
623
+ gate_start_governed() { # <name> <dir> <cmd...>
624
+ local name="$1" dir="$2"; shift 2
625
+ if (( DRY )); then say " [dry] (cd $dir && gate-run.sh deploy-$name -- $*)"; return; fi
626
+ local out="$LOG_DIR/$name-$STAMP.log"
627
+ ( cd "$ROOT/$dir" && unset CLOUDFLARE_API_TOKEN \
628
+ && GOVERN_GATE_TIMEOUT="$GATE_BOUND" \
629
+ exec "$ROOT/.claude/scripts/gate-run.sh" "deploy-$name" -- "$@" ) >"$out" 2>&1 &
630
+ GATE_NAMES+=("$name"); GATE_PIDS+=("$!"); GATE_LOGS+=("$out"); GATE_RC+=(-1)
631
+ GATE_T+=("$SECONDS"); GATE_DUR+=(-1)
632
+ # The `start` frame is what makes a run render as IN PROGRESS rather than
633
+ # appearing whole at the end. Emitted per LAUNCH, so a serialised gate's
634
+ # start is the moment it really started, not the moment the batch did.
635
+ { st="$(_spine_stage "$name")"; [[ -n "$st" ]] && _emit --stage "$st" --status start; } || true
636
+ say " → $name (pid $!, bound ${GATE_BOUND}s)"
637
+ }
638
+ # gate_start_heavy — same, but when memory says the two heavy gates cannot
639
+ # overlap, block until the previous heavy gate has finished. Its exit status is
640
+ # recorded in GATE_RC so the collection loop below never waits on a reaped pid
641
+ # (a second `wait` on the same child returns 127 and would read as a failure).
642
+ HEAVY_IDX=()
643
+ gate_start_heavy() {
644
+ if (( ! DRY )) && (( ! HEAVY_PARALLEL )) && (( ${#HEAVY_IDX[@]} )); then
645
+ local prev="${HEAVY_IDX[${#HEAVY_IDX[@]}-1]}"
646
+ say " … waiting for ${GATE_NAMES[$prev]} before starting $1 (serialised)"
647
+ if wait "${GATE_PIDS[$prev]}"; then GATE_RC[$prev]=0; else GATE_RC[$prev]=1; fi
648
+ GATE_DUR[$prev]=$(( SECONDS - GATE_T[$prev] ))
649
+ fi
650
+ gate_start_governed "$@"
651
+ (( DRY )) || HEAVY_IDX+=( $(( ${#GATE_NAMES[@]} - 1 )) )
652
+ }
653
+
654
+ GATE_T0=$SECONDS
655
+ if ! (( SKIP_TYPECHECK )); then
656
+ if (( TSC_GOVERNED )); then
657
+ say " memory: ${HEAVY_FREE_GB}GB free < ${TSC_NEED_GB}GB needed — typechecks governed (queued)"
658
+ else
659
+ say " memory: ${HEAVY_FREE_GB}GB free >= ${TSC_NEED_GB}GB needed — typechecks bare (parallel)"
660
+ fi
661
+ # NOT routed through tsc-cached.sh, and that is a measurement, not an oversight.
662
+ # Measured 2026-09-01 on a warm box: one.ie/web 4s · api 0s · sync 0s ·
663
+ # channels 2s · pay/backend 2s -- 8s serial, ~4s of wall-clock in parallel, and
664
+ # they finish inside the vitest gate's shadow anyway, so caching them buys zero
665
+ # seconds off the deploy. It would also COST something: tsc-cached takes the
666
+ # per-folder `tsc-<folder>` gate_lock that do-reconcile.sh takes, so a /do cycle
667
+ # typechecking one.ie/web would make this gate queue up to TSC_CACHE_WAIT (900s)
668
+ # and then exit 1 -- a red ship gate caused by lock contention rather than by
669
+ # code. Four seconds is not worth that trade.
670
+ # CONTENT TYPES FIRST, and this line is worth its four seconds.
671
+ #
672
+ # `astro check` and `astro build` sync implicitly; raw `tsc --noEmit` does NOT,
673
+ # and `DataEntryMap` is GENERATED into the gitignored one.ie/web/.astro/. So a
674
+ # freshly-materialised tree carries whatever that directory happened to hold —
675
+ # for .release/, types from before the newest collection existed; for a new
676
+ # worktree, no file at all. Either way tsc rejects the collection key and the
677
+ # gate goes red on code that is fine.
678
+ #
679
+ # Measured three times on 2026-09-06/07, each costing a full gate: twice on
680
+ # .release/ during a promote, once on a fresh worktree. The error names
681
+ # TypeScript and the cause is Astro, which is why it kept being re-diagnosed:
682
+ # src/lib/docs/sections.ts(23,40): error TS2344:
683
+ # Type '"docs"' does not satisfy the constraint 'keyof DataEntryMap'.
684
+ #
685
+ # It must run BEFORE the fan-out rather than inside the one.ie/web gate: the
686
+ # five typechecks start concurrently, and a sync racing them writes the types
687
+ # out from under a tsc that is already reading them.
688
+ if (( want_astro )) && [[ -d one.ie/web ]]; then
689
+ ( cd one.ie/web && NODE_OPTIONS=--max-old-space-size=4096 \
690
+ ./node_modules/.bin/astro sync >/dev/null 2>&1 ) \
691
+ && ok "content types synced" \
692
+ || warn "astro sync failed — a new content collection will read as a tsc error"
693
+ fi
694
+ for svc in "${SERVICES_TYPECHECK[@]}"; do
695
+ if (( TSC_GOVERNED )); then
696
+ gate_start_governed "tsc-$(echo "$svc" | tr / -)" "$svc" bunx tsc --noEmit
697
+ else
698
+ gate_start "tsc-$(echo "$svc" | tr / -)" "$svc" bunx tsc --noEmit
699
+ fi
700
+ done
701
+ fi
702
+ # the suite lives in one.ie/web and gates that worker
703
+ # The vitest gate hangs when stdout is not a TTY (fork spawned, never given
704
+ # work — see deploy.md). `script -qeF` allocates a pty and flushes so the
705
+ # runner matches a green standalone terminal; `--reporter=dot` is the other
706
+ # half of that unstick (default reporter still hung under script).
707
+ # test-cached.sh keys a PASS on the exact inputs -- args + selected test files +
708
+ # `git diff HEAD` by CONTENT + untracked contents + HEAD. deploy refuses a dirty
709
+ # tree, so on the normal path the key collapses to (args, HEAD, test-file set):
710
+ # a full suite already proven green at this exact commit is not run twice. Only
711
+ # passes are memoised, so a red suite re-runs every time until it is green.
712
+ #
713
+ # TEST_CACHE_PTY=1 is not optional here: this gate's stdout is a log file, and the
714
+ # runner hangs on a non-TTY stdout (a fork is spawned and never given work). The
715
+ # wrapper is the same `script -qeF /dev/null` that fixed it, moved inside the memo
716
+ # so the cached and uncached paths run byte-identical commands.
717
+ # The vitest ARGS used to live here, spelled out. They now live in test-full.sh,
718
+ # which /close reaches too -- test-cached keys on argv, so two spellings of the
719
+ # same suite could never share a stamp and a warm tree paid it twice.
720
+ (( SKIP_TESTS || ! want_astro )) || gate_start_heavy vitest one.ie/web \
721
+ env TYPEDB_LANE_NONBLOCKING=1 TEST_CACHE_PTY=1 bash "$ROOT/.claude/scripts/test-full.sh"
722
+ if (( want_astro )); then
723
+ if (( SKIP_BUILD )); then
724
+ [[ -d one.ie/web/dist/server ]] || die "--skip-build but one.ie/web/dist/server/ does not exist"
725
+ else
726
+ gate_start_heavy build one.ie/web bash "$ROOT/.claude/scripts/astro-build-cached.sh"
727
+ fi
728
+ fi
729
+
730
+ TYPECHECK_REPORT="skipped" TESTS_REPORT="skipped" BUILD_REPORT="skipped"
731
+ tc_pass=0 tc_total=0 gate_fail=()
732
+ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
733
+ (( ${#GATE_NAMES[@]} )) || break
734
+ name="${GATE_NAMES[$i]}" glog="${GATE_LOGS[$i]}"
735
+ if (( ${GATE_RC[$i]} >= 0 )); then rc="${GATE_RC[$i]}" # already reaped when serialised
736
+ elif wait "${GATE_PIDS[$i]}"; then rc=0; else rc=1; fi
737
+ GATE_RC[$i]=$rc # record it: the deploy receipt below reads results, not just timings
738
+ (( ${GATE_DUR[$i]} < 0 )) && GATE_DUR[$i]=$(( SECONDS - GATE_T[$i] ))
739
+ # …and onto the stream. ONE hook for typecheck/tests/build — the three gates
740
+ # that are ~95% of the wall clock and carry the metrics deploy-runs.json has
741
+ # no shape for. Deferred to after the `case` below for `tests`, which is the
742
+ # only gate whose reused/waived facts are not known yet at this line.
743
+ _emit_stage="$(_spine_stage "$name")"
744
+ _emit_dur=$(( GATE_DUR[i] < 0 ? 0 : GATE_DUR[i] ))
745
+ cat "$glog" >>"$LOG"
746
+ case "$name" in
747
+ tsc-*)
748
+ tc_total=$((tc_total+1))
749
+ if (( rc )); then bad "${name#tsc-} typecheck FAILED"; gate_fail+=("$name")
750
+ else tc_pass=$((tc_pass+1)); fi ;;
751
+ vitest)
752
+ # The suite now runs as TWO lanes, so the log carries TWO "Tests N passed"
753
+ # lines and `tail -1` reported only the last one — the 19-file typedb lane.
754
+ # A deploy that ran 10940 tests said "143 passed", which understates the
755
+ # gate by two orders of magnitude and reads like a partial run. Sum them.
756
+ TESTS_REPORT="$(awk '
757
+ /Tests +[0-9]+ (passed|failed)/ {
758
+ for (i = 1; i <= NF; i++) {
759
+ if ($i == "passed") p += $(i-1) + 0
760
+ if ($i == "failed") f += $(i-1) + 0
761
+ }
762
+ n++
763
+ }
764
+ # vitest reports UNHANDLED errors on their own line — "Errors 45 errors" —
765
+ # and exits non-zero on them with ZERO failed tests. A verdict that reads
766
+ # only the Tests line printed "11327 passed" for a red run and the flake
767
+ # checker called it GREEN (measured 2026-09-05). Read the Errors line too.
768
+ /Errors +[0-9]+ errors?/ { for (i = 1; i <= NF; i++) if ($i ~ /^errors?$/) e += $(i-1) + 0 }
769
+ END {
770
+ if (n == 0) exit
771
+ if (n > 1) printf "Tests %d passed", p; else printf "Tests %d passed", p
772
+ if (f > 0) printf " | %d failed", f
773
+ if (e > 0) printf " | %d unhandled errors", e
774
+ if (n > 1) printf " (%d lanes)", n
775
+ printf "\n"
776
+ }' "$glog")"
777
+ if (( rc )); then
778
+ bad "${TESTS_REPORT:-vitest failed}"
779
+ say " Failing files (check the Known-Flaky allowlist in .claude/commands/deploy.md):"
780
+ grep -E '^ *(FAIL|❯ .*\.test\.ts.*(failed|×))' "$glog" | sort -u | sed 's/^/ /' | tee -a "$LOG"
781
+ # Is the whole red run just the shared substrate being down? The checker
782
+ # answers by SIGNATURE, and blocks on the first failure it cannot account
783
+ # for — so a real break standing beside a 503 still stops the deploy.
784
+ _flake_out=""
785
+ if (( TYPEDB_FLAKE_WAIVER )); then
786
+ _flake_out="$(bash "$ROOT/.claude/scripts/typedb-flake-check.sh" "$glog" 2>&1)"; _flake_rc=$?
787
+ else
788
+ _flake_out="typedb-flake: waiver disabled (--no-typedb-flake-waiver)"; _flake_rc=1
789
+ fi
790
+ printf '%s\n' "$_flake_out" | sed 's/^/ /' | tee -a "$LOG"
791
+ if (( _flake_rc == 0 )); then
792
+ TESTS_WAIVED=1
793
+ TESTS_REPORT="${TESTS_REPORT:-vitest failed} — WAIVED as TypeDB outage (suite NOT green)"
794
+ ok "test failures waived: shared TypeDB Cloud unavailable"
795
+ else
796
+ gate_fail+=(vitest)
797
+ fi
798
+ elif grep -q 'cache HIT' "$glog"; then
799
+ # Say WHICH it was. A reused pass and a fresh run are both green, but
800
+ # reporting a memo hit as "all pass" is the same class of dishonesty as
801
+ # reporting a fast lane as a full one -- the operator cannot tell whether
802
+ # 10,780 tests just executed or whether an earlier identical tree's
803
+ # result was replayed. Both are sound; only one of them took 20 minutes.
804
+ TESTS_REPORT="full suite REUSED — identical inputs already passed ($(grep -o 'already PASSED.*' "$glog" | tail -1))"
805
+ ok "$TESTS_REPORT"
806
+ else
807
+ TESTS_REPORT="${TESTS_REPORT:-all pass}"; ok "$TESTS_REPORT"
808
+ fi ;;
809
+ build)
810
+ if (( rc )); then bad "astro build FAILED"; gate_fail+=(build)
811
+ else BUILD_REPORT="ok"; ok "astro build clean"; fi ;;
812
+ esac
813
+ # AFTER the case, deliberately: `tests` only learns whether it was a memo hit
814
+ # or a waived failure inside it, and those two booleans are the whole reason
815
+ # this stream exists. Emitting before the case would carry the timing and lose
816
+ # the meaning.
817
+ #
818
+ # `waived` is the one that matters most. On 2026-09-06 a flake waiver let this
819
+ # gate exit 0 on a genuinely red suite, and deploy-runs.json recorded `green`
820
+ # with nothing to distinguish it. A waived pass is now a fact on the run.
821
+ if [[ -n "$_emit_stage" ]]; then
822
+ _emit_args=(--stage "$_emit_stage" --wall-ms "$(( _emit_dur * 1000 ))")
823
+ if (( rc )) && ! { [[ "$_emit_stage" == tests ]] && (( TESTS_WAIVED )); }; then
824
+ _emit_args+=(--status fail --reason "${name}")
825
+ else
826
+ _emit_args+=(--status ok)
827
+ fi
828
+ case "$_emit_stage" in
829
+ tests)
830
+ _emit_args+=(--detail "reused=$( [[ "$TESTS_REPORT" == *REUSED* ]] && echo true || echo false )")
831
+ _emit_args+=(--detail "waived=$( (( TESTS_WAIVED )) && echo true || echo false )") ;;
832
+ typecheck)
833
+ # Which of the five, not just how many — a reader chasing a red gate
834
+ # should not have to open the log to learn the service.
835
+ _emit_args+=(--detail "service=${name#tsc-}") ;;
836
+ esac
837
+ _emit "${_emit_args[@]}"
838
+ fi
839
+ done
840
+ (( tc_total )) && {
841
+ TYPECHECK_REPORT="$tc_pass/$tc_total services clean"
842
+ (( tc_pass == tc_total )) && ok "$TYPECHECK_REPORT"
843
+ }
844
+ if (( ${#GATE_NAMES[@]} )); then
845
+ GATE_WALL="$((SECONDS - GATE_T0))s"
846
+ # Per-gate, not just the aggregate. The gates overlap, so the total is max()
847
+ # over the parallel set and sum() over the serialised heavies -- without the
848
+ # breakdown there is no way to tell which one is actually the critical path,
849
+ # and "make the deploy faster" turns into caching whatever finishes inside
850
+ # vitest's shadow for zero seconds.
851
+ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
852
+ say " ${GATE_NAMES[$i]}: ${GATE_DUR[$i]}s"
853
+ done
854
+ say " gates wall-clock: $GATE_WALL"
855
+ # Repeat the branch HERE, next to the durations it explains. Without it a
856
+ # reader comparing two runs cannot tell a slow gate from a serialised one.
857
+ if (( HEAVY_PARALLEL )); then
858
+ say " heavy gates: OVERLAPPED (${HEAVY_FREE_GB}GB free >= ${HEAVY_NEED_GB}GB)"
859
+ else
860
+ say " heavy gates: SERIALISED (${HEAVY_FREE_GB}GB free < ${HEAVY_NEED_GB}GB) — the 6.5x branch"
861
+ fi
862
+ [[ "$BUILD_REPORT" == ok ]] && BUILD_REPORT="$GATE_WALL (overlapped)"
863
+ fi
864
+ (( ${#gate_fail[@]} )) && die "gates red: ${gate_fail[*]} — nothing deployed"
865
+
866
+ # ── deferred-pin debt ───────────────────────────────────────────────────────
867
+ # verify-fast.sh may DEFER its pinned integration suites mid-plan and record the
868
+ # debt in a machine-keyed ledger. That debt is meant to come due at the close
869
+ # gate. deploy is the LAST place it can come due, and until now it could not:
870
+ # deploy sets FULL_VERIFY=1 but invokes vitest directly, so it never reached the
871
+ # branch in verify-fast.sh that settles the ledger. A production deploy could
872
+ # therefore ship over pins that no run had ever executed.
873
+ #
874
+ # Two rules, both one-directional:
875
+ # · a GREEN full suite PAYS the debt. The suite is a superset of every pin, so
876
+ # reaching here green is the payment -- same reasoning verify-fast uses.
877
+ # · anything else REFUSES. A skipped suite does not pay a debt; neither does a
878
+ # red one (we never get here) nor a `--gates-only` run that skipped tests.
879
+ # An unpaid debt with no run to settle it is an unrun gate, and an unrun gate
880
+ # is not a passing gate.
881
+ DEBT_FILE="${VERIFY_FAST_DEBT_FILE:-${TMPDIR:-/tmp}/one-verify-fast-debt}"
882
+ if [[ -s "$DEBT_FILE" ]]; then
883
+ step "Deferred-pin debt"
884
+ say " ledger: $DEBT_FILE"
885
+ sed 's/^/ /' "$DEBT_FILE" | tee -a "$LOG"
886
+ if (( SKIP_TESTS )) || [[ "$TESTS_REPORT" == "skipped" ]]; then
887
+ die "deferred pins are outstanding and this run did not execute the suite — re-run without --skip-tests"
888
+ fi
889
+ # A waived run is not a green run. The debt exists precisely because the pinned
890
+ # gates were deferred, and a waiver says only that the failures we DID see were
891
+ # substrate — it says nothing about a pin that never got to report.
892
+ if (( TESTS_WAIVED )); then
893
+ die "deferred pins are outstanding and the suite was WAIVED, not green — re-run when TypeDB answers"
894
+ fi
895
+ rm -f "$DEBT_FILE"
896
+ ok "debt settled by the green full suite"
897
+ fi
898
+ if (( GATES_ONLY )); then
899
+ say ""; ok "gates green in ${GATE_WALL:-0s} — nothing shipped (--gates-only)"
900
+ say " Typecheck: $TYPECHECK_REPORT"; say " Tests: $TESTS_REPORT"; say " Log: $LOG"
901
+ # CLOSE THE RUN. This exit is the one --gates-only always takes, and it is the
902
+ # path this feature is exercised by most (every release.sh promote mints its
903
+ # receipt through it). Without this the run would carry its gate frames and
904
+ # sit `open` forever — the precise failure the verdict field was added to
905
+ # prevent, and it is only not a bug because of this line.
906
+ _emit_verdict green
907
+ exit 0
908
+ fi
909
+
910
+ # ── Step 4 — credentials (the real work happens in Step 0.4) ────────────────
911
+ step "Step 4 — credentials"
912
+ ok "auth verified ($CF_AUTH) · API_TOKEN unset"
913
+ say " source: $CF_AUTH_SOURCE"
914
+ _cf_agree || die "services disagree about which Cloudflare account they are deploying to"
915
+
916
+ # ── Step 5 — smoke ──────────────────────────────────────────────────────────
917
+ step "Step 5 — smoke"
918
+ for cfg in api/wrangler.toml sync/wrangler.toml channels/wrangler.toml \
919
+ one.ie/web/wrangler.toml pay/backend/wrangler.toml; do
920
+ [[ -f "$cfg" ]] || die "missing wrangler config: $cfg"
921
+ done
922
+ ok "5/5 wrangler configs present"
923
+ if (( want_astro )); then
924
+ [[ -d one.ie/web/dist/server ]] || die "one.ie/web/dist/server/ missing after build"
925
+ GZIP_HINT="$(grep -Eo 'gzip: *[0-9.]+ *[KM]iB' "$LOG" | tail -1)"
926
+ say " dist/server: $(du -sh one.ie/web/dist/server | cut -f1)"
927
+ fi
928
+ # --env is the decoy trap; assert no config reintroduced an env-scoped target
929
+ if grep -q '^\[env\.production' one.ie/web/wrangler.toml 2>/dev/null; then
930
+ warn "one.ie/web/wrangler.toml grew an [env.production] block — the deploy-target trap is reachable again"
931
+ fi
932
+
933
+ # ── Step 6 — approval ───────────────────────────────────────────────────────
934
+ step "Step 6 — approval"
935
+ TARGETS=()
936
+ (( want_gateway )) && TARGETS+=("api → one-gateway")
937
+ (( want_sync )) && TARGETS+=("sync → one-sync (cron-only)")
938
+ (( want_agents )) && TARGETS+=("channels → channels.one.ie")
939
+ (( want_pay )) && TARGETS+=("pay/backend → one-core-worker")
940
+ (( want_astro )) && TARGETS+=("one.ie/web → one-prod (one.ie)")
941
+ for t in "${TARGETS[@]}"; do say " $t"; done
942
+ if (( ASSUME_YES || DRY )); then
943
+ ok "auto-approved"
944
+ elif [[ "$BRANCH" != "main" ]]; then
945
+ ok "auto-approved (branch $BRANCH ≠ main)"
946
+ else
947
+ read -r -p " deploy to production? [yes/N] " reply
948
+ [[ "$reply" == "yes" ]] || die "aborted at approval"
949
+ fi
950
+
951
+ # ── Step 6.5 — D1 migrations ────────────────────────────────────────────────
952
+ MIG_REPORT="skipped"
953
+ if (( want_astro )) && ! (( SKIP_MIGRATIONS )); then
954
+ step "Step 6.5 — D1 migrations (DB, remote)"
955
+ if run "d1 migrations apply DB --remote" one.ie/web bunx wrangler d1 migrations apply DB --remote; then
956
+ if (( DRY )); then
957
+ MIG_REPORT="dry-run"
958
+ elif tail -40 "$LOG" | grep -q "No migrations to apply"; then
959
+ MIG_REPORT="none to apply"
960
+ else
961
+ MIG_REPORT="$(grep -Eo '[0-9]{4}_[a-z0-9_]+\.sql' "$LOG" | tail -5 | tr '\n' ' ')"
962
+ MIG_REPORT="applied: ${MIG_REPORT:-see log}"
963
+ fi
964
+ ok "$MIG_REPORT"
965
+ else
966
+ die "D1 migrations failed — never ship worker code ahead of its schema"
967
+ fi
968
+ fi
969
+
970
+ # ── Step 6.6 — D1 migrations (channels) ─────────────────────────────────────
971
+ # A SECOND database. The step above runs in one.ie/web, where `DB` is
972
+ # `one-owners`; channels binds `DB` to `claw` and keeps its own
973
+ # channels/migrations/. Nothing applied that directory: the step above never
974
+ # enters it, and channels' own `bun run migrate` was pinned to 0001_init.sql.
975
+ # So 0010_action_bindings.sql shipped with the C6 worker code and its table was
976
+ # never created — and because bindAction's INSERT was fire-and-forget, every
977
+ # button binding failed silently, leaving taps that resolve to nothing. The
978
+ # promise's accept (`ls channels/migrations/*action*.sql`) proved the FILE
979
+ # existed, never the table. Same rule as above: schema before code.
980
+ MIG_CHAN_REPORT="skipped"
981
+ if (( want_agents )) && ! (( SKIP_MIGRATIONS )); then
982
+ step "Step 6.6 — D1 migrations (channels DB, remote)"
983
+ if run "d1 migrations apply DB --remote (channels)" channels bunx wrangler d1 migrations apply DB --remote; then
984
+ if (( DRY )); then
985
+ MIG_CHAN_REPORT="dry-run"
986
+ elif tail -40 "$LOG" | grep -q "No migrations to apply"; then
987
+ MIG_CHAN_REPORT="none to apply"
988
+ else
989
+ MIG_CHAN_REPORT="$(grep -Eo '[0-9]{4}_[a-z0-9_]+\.sql' "$LOG" | tail -5 | tr '\n' ' ')"
990
+ MIG_CHAN_REPORT="applied: ${MIG_CHAN_REPORT:-see log}"
991
+ fi
992
+ ok "$MIG_CHAN_REPORT"
993
+ else
994
+ die "channels D1 migrations failed — never ship worker code ahead of its schema"
995
+ fi
996
+ fi
997
+
998
+ # ── Step 7 — deploy ─────────────────────────────────────────────────────────
999
+ step "Step 7 — deploy"
1000
+ # bash 3.2 on macOS has no associative arrays — two parallel indexed ones
1001
+ BG_NAMES=() BG_PIDS=()
1002
+ hfail=()
1003
+ deploy_bg() { # <name> <dir> <cmd...>
1004
+ local name="$1" dir="$2"; shift 2
1005
+ if (( DRY )); then say " [dry] (cd $dir && ${*})"; return; fi
1006
+ local out="$LOG_DIR/$name-$STAMP.log"
1007
+ ( cd "$ROOT/$dir" && unset CLOUDFLARE_API_TOKEN && "$@" ) >"$out" 2>&1 &
1008
+ BG_NAMES+=("$name"); BG_PIDS+=("$!")
1009
+ say " → $name (pid $!, log $out)"
1010
+ }
1011
+
1012
+ # All five upload independently — no shared state, no ordering constraint
1013
+ # (verified 2026-07-08 for the three workers; astro's only dependency is its own
1014
+ # build, already done). Astro is slowest (~30s), so it starts first.
1015
+ # NEVER --env production on any of them — it ships to the one-prod-production decoy.
1016
+ (( want_astro )) && deploy_bg astro one.ie/web bunx wrangler deploy
1017
+ (( want_gateway )) && deploy_bg gateway api bunx wrangler deploy
1018
+ (( want_sync )) && deploy_bg sync sync bunx wrangler deploy
1019
+ (( want_agents )) && deploy_bg channels channels bunx wrangler deploy
1020
+ (( want_pay )) && deploy_bg pay pay/backend bun run deploy
1021
+
1022
+ DEPLOY_FAIL=()
1023
+ for i in $(seq 0 $(( ${#BG_NAMES[@]} - 1 )) ); do
1024
+ (( ${#BG_NAMES[@]} )) || break
1025
+ name="${BG_NAMES[$i]}"
1026
+ if wait "${BG_PIDS[$i]}"; then
1027
+ ok "$name deployed"
1028
+ cat "$LOG_DIR/$name-$STAMP.log" >>"$LOG"
1029
+ else
1030
+ bad "$name FAILED"
1031
+ tail -30 "$LOG_DIR/$name-$STAMP.log" | sed 's/^/ /' | tee -a "$LOG"
1032
+ DEPLOY_FAIL+=("$name")
1033
+ fi
1034
+ done
1035
+ (( ${#DEPLOY_FAIL[@]} )) && die "parallel deploy failed: ${DEPLOY_FAIL[*]}"
1036
+ if ! (( DRY )); then
1037
+ for i in $(seq 0 $(( ${#BG_NAMES[@]} - 1 )) ); do
1038
+ (( ${#BG_NAMES[@]} )) || break
1039
+ git rev-parse HEAD > "$(mark_file "${BG_NAMES[$i]}")"
1040
+ done
1041
+ fi
1042
+ (( want_astro && ! DRY )) && GZIP_HINT="$(grep -Eo 'gzip: *[0-9.]+ *[KM]iB' "$LOG_DIR/astro-$STAMP.log" | tail -1)"
1043
+
1044
+ # ── Step 8 — health ─────────────────────────────────────────────────────────
1045
+ HEALTH_REPORT="skipped"
1046
+ if (( SKIP_HEALTH || DRY )); then
1047
+ step "Step 8 — health (skipped)"
1048
+ else
1049
+ step "Step 8 — health (custom domains only)"
1050
+ probe() { # <label> <url> <assert-substring|-> ; 3 tries, backoff
1051
+ local label="$1" url="$2" want="$3" i body code
1052
+ for i in 1 2 3; do
1053
+ body="$(curl -sL --max-time 20 -w $'\n%{http_code}' "${url}?_t=$(date +%s)" 2>/dev/null)"
1054
+ code="${body##*$'\n'}"; body="${body%$'\n'*}"
1055
+ if [[ "$code" == "200" ]] && { [[ "$want" == "-" ]] || [[ "$body" == *"$want"* ]]; }; then
1056
+ ok "$label 200"; return 0
1057
+ fi
1058
+ sleep $((i * 3))
1059
+ done
1060
+ bad "$label unhealthy (last code=${code:-none})"
1061
+ return 1
1062
+ }
1063
+ # probe all four at once — they are independent hosts; serial cost was the
1064
+ # sum of four round trips plus, on a bad deploy, three backoff sleeps each
1065
+ hp=0 ht=0 PNAMES=() PPIDS=()
1066
+ probe_bg() { probe "$2" "$3" "$4" >"$LOG_DIR/health-$1-$STAMP.log" 2>&1 & PNAMES+=("$1"); PPIDS+=("$!"); ht=$((ht+1)); }
1067
+ (( want_gateway )) && probe_bg gateway "api.one.ie/health" https://api.one.ie/health -
1068
+ (( want_astro )) && probe_bg astro "one.ie/api/health" https://one.ie/api/health '"status":"ok"'
1069
+ (( want_agents )) && probe_bg channels "channels.one.ie" https://channels.one.ie/health -
1070
+ (( want_pay )) && probe_bg pay "pay.one.ie/status" https://pay.one.ie/status '"ok"'
1071
+ for i in $(seq 0 $(( ${#PNAMES[@]} - 1 )) ); do
1072
+ (( ${#PNAMES[@]} )) || break
1073
+ if wait "${PPIDS[$i]}"; then hp=$((hp+1)); else hfail+=("${PNAMES[$i]}"); fi
1074
+ cat "$LOG_DIR/health-${PNAMES[$i]}-$STAMP.log" | tee -a "$LOG"
1075
+ done
1076
+ (( want_sync )) && ok "sync — cron-only, deploy success IS the health signal"
1077
+ HEALTH_REPORT="$hp/$ht HTTP 200"
1078
+ (( want_sync )) && HEALTH_REPORT="$HEALTH_REPORT + sync deploy-confirmed"
1079
+
1080
+ # ── deploy receipt ────────────────────────────────────────────────────────
1081
+ # The last deploy's gate timings and per-worker outcome, read by the operator
1082
+ # deploy dashboard at /u/one/deploy. Written HERE because health is the last
1083
+ # fact it needs. Three honesty rules the data itself forces:
1084
+ #
1085
+ # * gates in this file are only ever GREEN — `die "gates red"` above exits
1086
+ # long before Step 8, so a red gate never reaches a receipt. The page says
1087
+ # that out loud rather than letting "all pass" imply a full audit.
1088
+ # * `healthy` is null for sync. It is never probed (cron-only — the line two
1089
+ # above), and a worker nobody asked must not render healthy OR unhealthy.
1090
+ # * `deployed` comes from BG_NAMES — what actually uploaded — never from
1091
+ # TARGETS, which is only the intent.
1092
+ #
1093
+ # Wrapped in `{ … } || true` so a failed write can never fail a deploy that
1094
+ # has already shipped.
1095
+ _receipt="$ROOT/one.ie/web/src/lib/generated/deploy-receipt.json"
1096
+ {
1097
+ mkdir -p "$(dirname "$_receipt")"
1098
+ {
1099
+ printf '{\n "stamp": "%s",\n "headSha": "%s",\n "mode": "%s",\n' \
1100
+ "$STAMP" "${HEAD_SHA:-unknown}" "$MODE"
1101
+ printf ' "gates": ['
1102
+ _sep=""
1103
+ for i in $(seq 0 $(( ${#GATE_NAMES[@]} - 1 )) ); do
1104
+ (( ${#GATE_NAMES[@]} )) || break
1105
+ if (( ${GATE_RC[$i]} < 0 )); then _res=unrun
1106
+ elif (( ${GATE_RC[$i]} > 0 )); then _res=fail
1107
+ else _res=pass; fi
1108
+ printf '%s\n {"name": "%s", "seconds": %s, "result": "%s"}' \
1109
+ "$_sep" "${GATE_NAMES[$i]}" "${GATE_DUR[$i]}" "$_res"
1110
+ _sep=","
1111
+ done
1112
+ (( ${#GATE_NAMES[@]} )) && printf '\n '
1113
+ printf '],\n "workers": ['
1114
+ _sep=""
1115
+ for i in $(seq 0 $(( ${#BG_NAMES[@]} - 1 )) ); do
1116
+ (( ${#BG_NAMES[@]} )) || break
1117
+ _n="${BG_NAMES[$i]}"
1118
+ if [[ "$_n" == sync ]]; then _h=null # never probed
1119
+ # `${hfail[*]-}` not `${hfail[*]}`: under `set -u` bash 3.2 treats an EMPTY
1120
+ # array as unbound, and hfail is empty exactly when every probe passed —
1121
+ # so a perfect deploy died here (line 933, 2026-09-05), truncated its own
1122
+ # receipt mid-JSON and reported exit 1. Same trap test-lanes.sh already paid.
1123
+ elif [[ " ${hfail[*]-} " == *" $_n "* ]]; then _h=false
1124
+ else _h=true; fi
1125
+ printf '%s\n {"name": "%s", "deployed": true, "healthy": %s}' "$_sep" "$_n" "$_h"
1126
+ _sep=","
1127
+ done
1128
+ (( ${#BG_NAMES[@]} )) && printf '\n '
1129
+ printf ']\n}\n'
1130
+ } >"$_receipt" && say " receipt: $_receipt"
1131
+ } || true
1132
+ fi
1133
+
1134
+ # ── Report ──────────────────────────────────────────────────────────────────
1135
+ step "Step 9 — speed (advisory)"
1136
+ # Post-deploy Lighthouse reading of the LIVE site. ADVISORY: it reports, it
1137
+ # does not block a deploy that already shipped. The ENFORCED ratchet runs
1138
+ # pre-deploy against the local production build (`bun run speed` in one.ie/web),
1139
+ # because live-site numbers swing ~30 points on network jitter and cannot gate.
1140
+ #
1141
+ # Placed here, serial and after the parallel deploys, deliberately: Lighthouse
1142
+ # drives a full Chrome and would fight the Step 1+3 gate fan-out and the machine
1143
+ # governor's slot cap if it ran alongside them.
1144
+ #
1145
+ # Exit 2 = INCONCLUSIVE (could not measure). It is reported as such and NEVER
1146
+ # as a pass — an unrun check is not a green check.
1147
+ if (( DRY )); then
1148
+ echo " (dry run — speed check skipped)" | tee -a "$LOG"
1149
+ elif [[ "${SKIP_SPEED:-0}" == "1" ]]; then
1150
+ echo " (SKIP_SPEED=1 — skipped)" | tee -a "$LOG"
1151
+ else
1152
+ sp_out="$LOG_DIR/speed-$STAMP.log"
1153
+ sp_cmd=(node "$(dirname "${BASH_SOURCE[0]}")/speed-check.mjs" --prod --runs 3)
1154
+ # The verdict, from the exit code. One function, used by both the inline and
1155
+ # the detached path, so the two can never phrase the same code differently.
1156
+ # NOTE: bad() only PRINTS in this script (die() is what exits). That is
1157
+ # correct here — Step 9 runs AFTER the deploy has shipped, so failing the
1158
+ # pipeline could not un-ship it. This step reports; it does not block. The
1159
+ # blocking gate is `bun run speed` pre-deploy against the local build.
1160
+ # warn() is used so a red x is never mistaken for a blocked deploy.
1161
+ speed_verdict() {
1162
+ grep -E 'perf=|faster|SLOWER|performance:|advisory only' "$sp_out" | sed 's/^/ /' | tee -a "$LOG" || true
1163
+ case "$1" in
1164
+ 0) ok "speed: nothing got slower (live site)" ;;
1165
+ 1) warn "speed: REGRESSION on the live site — ADVISORY, deploy not blocked — $sp_out" ;;
1166
+ 2) warn "speed: INCONCLUSIVE (not a pass) — ADVISORY — $sp_out" ;;
1167
+ # 3 = regressions found, deliberately not gating (--prod). Distinct from 0
1168
+ # so it can never render as "nothing got slower" — which it did, twice, in
1169
+ # the same log that printed the regression two lines above.
1170
+ 3) warn "speed: REGRESSION on the live site — advisory, not gating — $sp_out" ;;
1171
+ # 124 is run_bounded's own code: Lighthouse hung past the cap and was
1172
+ # reaped. Reported as unrun, never as a pass.
1173
+ 124) warn "speed: TIMED OUT (not a pass) — ADVISORY — $sp_out" ;;
1174
+ *) warn "speed: unexpected exit $1 — ADVISORY — $sp_out" ;;
1175
+ esac
1176
+ }
1177
+ if [[ "${SPEED_SYNC:-0}" == "1" ]]; then
1178
+ # Inline — the operator wants the number before leaving the terminal.
1179
+ run_bounded "${SPEED_TIMEOUT:-600}" "${sp_cmd[@]}" >"$sp_out" 2>&1
1180
+ speed_verdict $?
1181
+ else
1182
+ # DETACHED, by default. Measured 2026-09-05 on deploy-20260904-173517: the
1183
+ # pipeline was 227s wall-clock, and 166s of it was THIS step — three
1184
+ # Lighthouse runs over six routes against a site that had already shipped
1185
+ # and already passed health at +61s. The number is advisory by design, so
1186
+ # nothing downstream waits on it; the only thing that waited was the
1187
+ # operator. The verdict is appended to $LOG when it lands, in the same
1188
+ # words the inline path uses, and the process is bounded so a hung Chrome
1189
+ # cannot outlive the deploy by more than SPEED_TIMEOUT.
1190
+ (
1191
+ run_bounded "${SPEED_TIMEOUT:-600}" "${sp_cmd[@]}" >"$sp_out" 2>&1
1192
+ rc=$?
1193
+ { echo ""; echo "══ Step 9 — speed (detached, landed $(date -u +%FT%TZ))"; } >>"$LOG"
1194
+ speed_verdict $rc >>"$LOG" 2>&1
1195
+ ) >/dev/null 2>&1 </dev/null &
1196
+ disown 2>/dev/null || true
1197
+ say " speed: measuring the live site in the background (pid $!, ~3 min)"
1198
+ say " verdict lands in $LOG · raw: $sp_out · inline: SPEED_SYNC=1"
1199
+ fi
1200
+ fi
1201
+
1202
+ step "Report"
1203
+ say " Mode: $MODE"
1204
+ say " Branch: $BRANCH @ $HEAD_SHA"
1205
+ say " Typecheck: $TYPECHECK_REPORT"
1206
+ say " Tests: $TESTS_REPORT"
1207
+ say " Build: $BUILD_REPORT"
1208
+ say " Migrations: $MIG_REPORT"
1209
+ say " Deployed: ${TARGETS[*]:-none}"
1210
+ say " Health: $HEALTH_REPORT"
1211
+ [[ -n "${sp_out:-}" && "${SPEED_SYNC:-0}" != "1" ]] && say " Speed: detached — $sp_out"
1212
+ (( ${#SKIPPED_UNCHANGED[@]} )) && say " Unchanged: ${SKIPPED_UNCHANGED[*]} (skipped, --changed)"
1213
+ [[ -n "${GZIP_HINT:-}" ]] && say " Bundle: $GZIP_HINT"
1214
+ say " Log: $LOG"
1215
+
1216
+ # The record the /deploy page renders. It runs on BOTH exits — a degraded deploy
1217
+ # is the run most worth having a timing for, and a record that only ever holds
1218
+ # green runs is a record that cannot be read for a trend. Guarded so a recorder
1219
+ # fault can never turn a good deploy into a bad exit: `|| true`, and the phases
1220
+ # come from arrays the gates already filled.
1221
+ _record_run() {
1222
+ local verdict="$1"
1223
+ [[ -x "$ROOT/.claude/scripts/deploy-record.sh" ]] || return 0
1224
+ (( DRY )) && return 0
1225
+ local -a args=(--door "deploy${MODE:+ $MODE}" --target "${TARGETS[*]:-—}"
1226
+ --branch "$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
1227
+ --sha "$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '')"
1228
+ --verdict "$verdict" --wall "$(( SECONDS ))"
1229
+ --note "heavy=$( (( ${HEAVY_PARALLEL:-0} )) && echo parallel || echo serial ) memFreeGb=${HEAVY_FREE_GB:-?} needGb=${HEAVY_NEED_GB:-?}")
1230
+ local i st
1231
+ for i in "${!GATE_NAMES[@]}"; do
1232
+ # -1 means the gate never reported. It is recorded as `unrun`, never as a
1233
+ # pass and never as a zero — the page draws it as an outline for that reason.
1234
+ if [[ "${GATE_RC[$i]}" == 0 ]]; then st=pass
1235
+ elif [[ "${GATE_RC[$i]}" == -1 ]]; then st=unrun
1236
+ else st=fail; fi
1237
+ args+=(--phase "${GATE_NAMES[$i]}:$(( GATE_T[i] - GATE_T0 )):$(( GATE_DUR[i] < 0 ? 0 : GATE_DUR[i] )):gate:$st")
1238
+ done
1239
+ bash "$ROOT/.claude/scripts/deploy-record.sh" "${args[@]}" >/dev/null 2>&1 || true
1240
+ # THE TRUNK LEG. The row lands in the PRIMARY worktree, where a commit can
1241
+ # pick it up — and nothing commits it, so /deploy (which renders that file at
1242
+ # BUILD time) never sees it. Two production ships were lost that way in two
1243
+ # days. It is not automated: main receives merges, never edits, and a
1244
+ # recorder that pushed its own row would invent a door past that loop and
1245
+ # need credentials at ship time. So it is made impossible to miss instead —
1246
+ # printed here, at the moment it becomes true. Never fatal: a LOGGING debt
1247
+ # must not fail a ship.
1248
+ local pend; pend="$(bash "$ROOT/.claude/scripts/deploy-record.sh" --pending 2>/dev/null)" || true
1249
+ [[ -n "$pend" ]] && say "$pend"
1250
+ return 0
1251
+ }
1252
+
1253
+ if (( ${#hfail[@]} )); then
1254
+ _emit --stage health --status fail --reason "unhealthy: ${hfail[*]}"
1255
+ _emit_verdict red
1256
+ _record_run degraded
1257
+ # A degraded ship is an outcome. Close it as one.
1258
+ # --only dims,feedback: `deploy` is a label, not a plan slug — without --tid the
1259
+ # task leg resolves it through the `slug:` tag door and would close a plan named
1260
+ # deploy if one ever existed. The deploy record is deploy-record.sh's job; what
1261
+ # is true to write here is the pheromone.
1262
+ bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" deploy --status failed --only dims,feedback || true
1263
+ say ""
1264
+ bad "degraded — unhealthy: ${hfail[*]}"
1265
+ say " rollback: cd one.ie/web && bunx wrangler rollback --name one-prod"
1266
+ exit 1
1267
+ fi
1268
+ _emit --stage health --status ok --detail "probes=${#TARGETS[@]}"
1269
+ _emit_verdict green
1270
+ _record_run green
1271
+ bash "$(dirname "${BASH_SOURCE[0]}")/do-close.sh" deploy --only dims,feedback || true
1272
+ say ""
1273
+ ok "deploy:success"