Skip to content

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 API implies 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/orders returned 500 for hours because merchant_customers, ready_notification_outbox and order_notifications were never created. The thirteen steps are now one migrate:all with a ledger and a summary naming the failure and listing what never ran.
  • 2026-08-07migrateMerchantMemberIdentity asserted 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-filter are 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 API adds 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 200 with ok:false on a parse error. Always assert .ok; a bare 200 is 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 testingvitest 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/cache keys on hashFiles('bun.lock'). With no such file the key is constant, so the dependency cache is never invalidated — runners have restored node_modules weeks 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.