Deploy pipeline¶
.github/workflows/deploy.yml deploys production on every push to master. This page records how it is gated, the failure mode that has bitten twice, and what it reports.
For variables and secrets, see Production environment variables.
Shape¶
Detect Changes (paths-filter)
└─ Tests
├─ Deploy API → migrations, then Cloudflare Workers
├─ Deploy Wallet → Cloudflare Pages
├─ Deploy Dashboard → Cloudflare Pages
└─ Deploy Landing → Cloudflare Pages
└─ Notify Telegram (always)
Detect Changes path-filters each surface, so a commit touching only apps/api/** deploys only the API and skips the rest. The workflow itself is not in its own paths filter — editing deploy.yml does not trigger a deploy.
Migrations gate the Worker deploy¶
Deploy API runs migrate:all before wrangler deploy. Any migration failing aborts every later migration and the Worker deploy.
This is deliberate — it fails closed rather than shipping code against a schema that never migrated — but it means:
- a red
Deploy APIimplies production may be mid-migration and the Worker was not deployed; - one bad migration silently blocks every migration behind it.
Both have happened:
- 2026-08-04 — the first of thirteen separate migration steps failed, the other twelve were skipped along with the deploy, and nothing said so.
/api/ordersreturned500for hours becausemerchant_customers,ready_notification_outboxandorder_notificationswere never created. The thirteen steps are now onemigrate:allwith a ledger and a summary naming the failure and listing what never ran. - 2026-08-07 —
migrateMerchantMemberIdentityasserted one merchant membership per identity, which real accounts violate. It sat 6th of 25 and blocked the 19 behind it. It has since been removed entirely; multi-merchant membership is supported.
When a deploy fails at migrations, read the summary line: it names the failing script and lists everything that never ran.
Telegram reporting¶
One message per master deploy, success or failure, posted to the logs topic. The notify job runs on always() — before 2026-08-07 it ran only on failure(), so a green deploy was indistinguishable from one that never ran.
A successful deploy reports:
✅ Deployed to production from master ← links to the branch
• API ← links to api.sagio.io
Unchanged: Wallet, Dashboard, Landing
What shipped — 9 commits, 31 files changed, 808 insertions(+), 257 deletions(-)
▸ expandable list of the non-merge commits, each #NNN linked to its PR
🗄 8 migration file(s) in this deploy
Compare 4400207…09d62b3 · 31 file(s)
Commit 09d62b3 — Merge pull request #372 from …
By Lulzx ← links to the GitHub profile
View run
Details worth knowing:
- Surfaces skipped by
paths-filterare reported as Unchanged. A surface that did change but never ran is reported separately as blocked by the failure above — calling those "unchanged" would understate a bad run. - The migration flag counts real migrations only; test files under
migrations/are excluded, or the number overstates the schema risk. - "What shipped" diffs from
github.event.before. That is all-zeroes on a first push and unreliable after a force-push, so it is guarded and the section is omitted rather than rendering a broken range. - A failed
Deploy APIadds the explicit warning that migrations may be partially applied.
Telegram formatting constraints¶
parse_mode is HTML with a narrow tag set: <b> <i> <u> <s> <a> <code> <pre> <blockquote> <blockquote expandable> <span class="tg-spoiler">.
- Markdown is not parsed.
##headings and|tables|render literally. - Any raw
<,>or&makes Telegram reject the whole message, so commit subjects and author names are escaped. Escape before linkifying — the other order eats the anchor tags. - Telegram answers
200withok:falseon a parse error. Always assert.ok; a bare200is not proof of delivery. - Messages hard-fail past 4096 characters, which is why the commit list caps at 20 and long content uses
<blockquote expandable>.
Ad-hoc announcements¶
The credentials are repository secrets, so nothing can post from a developer machine. Use Actions → Telegram Announce → Run workflow (.github/workflows/telegram-announce.yml) with the message body as input.
CI on pull requests¶
.github/workflows/test.yml runs on PRs into master and dev, path-filtered per app. Since 2026-08-07 it typechecks before testing — vitest transpiles without checking types, so a type error previously shipped green. Each app exposes bun run --cwd apps/<app> typecheck.
That gate caught a real problem on its first run; see below.
There is no committed lockfile¶
bun.lock is listed in .gitignore. Two consequences:
bun install --frozen-lockfile, used in every job, has nothing to freeze and resolves semver ranges fresh on each run.actions/cachekeys onhashFiles('bun.lock'). With no such file the key is constant, so the dependency cache is never invalidated — runners have restorednode_modulesweeks old.
This is why "passes locally" is not evidence that CI resolves the same tree. It bit on 2026-08-07: @tsndr/cloudflare-worker-jwt was declared ^3.2.1, the runner resolved 3.2.2, and 3.2.2 is a broken publish — its package.json still declares "types": "index.d.ts" but the tarball omits the file. Every import became an implicit any and tsc failed on CI while passing locally.
Until a lockfile is committed, an exact version in package.json is the only real pin. When a build fails only on CI, compare installed versions before anything else.
Production smoke¶
.github/workflows/production-api-smoke.yml checks health, Google OAuth initiation and JWKS every ten minutes, and posts to Telegram on failure.
It runs on a schedule, not on push, so it can fire mid-deploy — check timing before blaming a deploy.
It had never passed once between being added on 2026-08-06 and being fixed on 2026-08-07. The CORS header assertions used \r?$ to absorb the CRLF terminator, but POSIX EREs have no \r escape: GNU grep (Ubuntu runners) reads it as a literal r and never matches, while BSD grep on macOS treats it as a carriage return and does. The workflow was verified on a Mac and could not have passed on Linux. It now strips the CR with tr -d '\r' before matching, reports each assertion individually, and dumps status, headers and body on failure — the previous version exited 1 in 0.4 s with no output at all.
Treat \r inside a POSIX ERE as a portability bug anywhere it appears.