Skip to content

Repository files navigation

TechTok

TechTok turns tech & science news into a TikTok-style swipeable feed. Articles are pulled in automatically, condensed into short cards with an LLM, and translated into your language — swipe through headlines, tap into a full compact article when one grabs you, and bookmark the rest for later. Sign in with Google and your read history, bookmarks, and preferences follow you across devices.

This README covers running, developing, and deploying the project day to day. For the full architecture and decision history, see CLAUDE.md, docs/DESIGN.md, and docs/IMPLEMENTATION_PLAN.md. The public project site — topics, sources, release history, and the latest Android APK download — lives at techtokapp.eu (apps/site).

Status: phases 0–20 are code complete and the backend runs on both the dev and production stages. The current front is the free-first public Play launch (phase 23, D75): store listing, legal surface, and the 14-day closed-test clock run against the app as it exists today, with Play Billing (phase 21) and the paid extended compact (phase 22) shipping as later store updates. See CLAUDE.md for the phase-by-phase table and what's still maintainer-gated.


What the app does

  • Feed — full-screen swipeable cards (image, hook title, 2–3 sentence summary, "why it matters", source, topic chip), newest-unread-first, filtered to your topics and excluding muted sources.
  • Compact reader — tap a card for a ~400–600 word structured condensation with the article's own figures, generated ahead of time in all 4 languages, ending in a prominent "Read original" link-out. Bookmark, copy the article text, or share it from the action bar.
  • Localizeden · ru · uk · pl, for both card content (LLM-translated) and the app's own chrome.
  • History, bookmarks & search — everything you've read or saved, searchable via ?q= on both list endpoints.
  • Listen modeexpo-speech TTS in the feed action bar and the reader.
  • Offline — feed, bookmarks and already-opened article content persist for a day, so a cold start reads without a network hit. Images (not article content) read ahead 3 cards on wifi.
  • Stats — reading streak plus top topics/sources, computed client-side from history pages.
  • Plans — Free and Plus (€2.99/mo · €24.99/yr, D73). Free is capped server-side at 100 card reads and 20 reader opens per local day; Plus lifts both. Entitlement is provider-agnostic (D70), so it can be granted by hand today — Play Billing arrives in phase 21.

Architecture

flowchart LR
  classDef client fill:#6366f1,stroke:#4338ca,color:#fff,stroke-width:2px
  classDef compute fill:#0ea5e9,stroke:#0369a1,color:#fff,stroke-width:2px
  classDef queue fill:#f59e0b,stroke:#b45309,color:#1f2937,stroke-width:2px
  classDef data fill:#10b981,stroke:#047857,color:#fff,stroke-width:2px
  classDef external fill:#ec4899,stroke:#be185d,color:#fff,stroke-width:2px
  classDef trigger fill:#8b5cf6,stroke:#6d28d9,color:#fff,stroke-width:2px

  MOBILE(["Mobile App<br/>feed · reader · settings"]):::client

  subgraph AWS["AWS eu-central-1 (SST v4)"]
    direction LR
    CRON((ingest schedule<br/>60 min prod · 6 h dev)):::trigger
    PIPE[["Ingest Pipeline<br/>Step Functions"]]:::compute
    JWT{{"API Gateway<br/>Google JWT authorizer"}}:::queue
    API["API Lambdas<br/>/v1"]:::compute
    QUEUES{{"Transform → Translate → Content<br/>queues + DLQs"}}:::queue
    WORKERS["Pipeline Lambdas<br/>fetch · card · translate · compact"]:::compute
    DB[("Neon Postgres<br/>16-table normalized schema, Drizzle")]:::data
    STORE[("S3 + CloudFront<br/>images · articles")]:::data
    ALARM(("CloudWatch<br/>+ SNS")):::trigger
  end

  RSS[/RSS Feeds/]:::external
  LLM[/OpenRouter LLM/]:::external
  GOOGLE[/Google Sign-In/]:::external
  SENTRY[/Sentry/]:::external

  CRON --> PIPE
  PIPE -- fetch --> RSS
  PIPE -- "new posts" --> QUEUES
  QUEUES --> WORKERS
  WORKERS -- "LLM calls" --> LLM
  WORKERS --> DB
  WORKERS --> STORE
  MOBILE -- "ID token" --> GOOGLE
  MOBILE -- "/v1 API" --> JWT
  JWT --> API
  API --> DB
  MOBILE -. "images / content" .-> STORE
  MOBILE -. crashes .-> SENTRY
  QUEUES -.-> ALARM
  API -.-> ALARM

  subgraph LEGEND["Legend"]
    direction TB
    L1(["Client"]):::client
    L2["Compute (Lambda)"]:::compute
    L3{{"Queue / gateway"}}:::queue
    L4[("Data store")]:::data
    L5[/"External service"/]:::external
    L6(("Trigger / Alert")):::trigger
  end
Loading

Data flow, start to finish:

  1. Ingest — an EventBridge schedule kicks a Step Functions state machine (every 60 min on production, every 6 h on dev, which also runs a reduced source preset to keep an idle stage cheap): LoadSources scans enabled sources → Map over them (concurrency 4, per-item catch so one bad feed never fails the run) → FetchSource does a conditional GET on each RSS feed, dedups entries on the canonicalized URL (unique(canonical_url) on posts, so an already-seen entry is a no-op insert), and enqueues only genuinely new posts to TransformQueueSummarize emits run metrics.
  2. Transform — a consumer fetches the article page (robots.txt-respecting, 10s/2MB-capped), archives the raw HTML to S3, extracts text + an og:image fallback, mirrors the image to CloudFront (rejecting anything under 600px, D28), and calls the configured LLM provider (OpenRouter by default, Bedrock as a dormant fallback, D32) for the card copy + topic classification. Failures here degrade to an excerpt card rather than failing the post. It then eagerly enqueues a TranslateQueue job for each of the 3 non-English languages (D27) and a ContentQueue job for all 4 languages (D36) — both fire regardless of whether the card LLM call degraded, so every post gets its translations and its compact articles queued before it ever reaches a feed response or a reader tap.
  3. Translate — a consumer LLM-translates the card (self-critique in one call) and writes the result into an i18n map on the same Posts item; failures simply leave the post on its English fallback.
  4. Serve — the API is plain request/response Lambdas over Neon Postgres (Drizzle, neon-http), behind an API Gateway JWT authorizer that verifies a Google ID token on every route except the two public catalogs (GET /v1/topics, GET /v1/sources, D68). Routes cover feed, read markers, topic/language/muted-source prefs, history, bookmarks, entitlement, client log/analytics events (D76), and account deletion. GET /v1/feed serves each card in the user's language with an English fallback, ranks by recency × source weight × topic affinity, and interleaves by both topic and source so no single one crowds out the rest (D77).
  5. Compact reader (eager, D36) — the content consumer processes one ContentQueue message per language, per post. On the first message for a given post it extracts + mirrors up to 5 in-body figures once and stores them in post_figures; every other language reuses that list instead of re-extracting/re-mirroring. Each language then gets a ~400–600 word structured compact article (single LLM pass, compress + translate together) cached as content/<contentKey>/<lang>.json behind CloudFront, where contentKey is the sha-256 of the post's canonical URL — object keys stay unguessable now that post ids are sequential integers (D94). Tapping a card in the app calls GET /v1/posts/{id}/content?lang= — a plain S3 cache read, no LLM call on the request path: a hit returns the blocks/figures immediately, a miss returns a typed available: false (the compact-reader kill switch, or the rare case a just-ingested post's eager job hasn't finished yet).
  6. Observability & cost — CloudWatch alarms (DLQ depth, Step Functions failures, API 5xx, ingest stalled) page via one SNS topic; alarms are production-only, since CloudWatch's free tiers are per-account and a personal dev stage doesn't warrant them. There is no ops dashboard and no queue-backlog alarms: both were removed as pure cost (D89) — CloudWatch's free tier is 3 dashboards and 10 alarms per account, and this account was already at the dashboard limit, so the dashboard was a flat $3/mo. The mobile app reports crashes to Sentry. A $25/mo AWS Budget alarm is an infrastructure-drift signal only (D74, amending D11's $10 ceiling) — LLM caps were removed (D31) and OpenRouter bills separately, so it never sees LLM spend (D32).

Component reference

Component Role
apps/mobile Expo/React Native app (expo-router): vertical card pager, compact reader, onboarding, sign-in, settings, history, saved, stats. React Native Paper (MD3), Sentry crash reporting, committed bare android/ project (D18).
apps/site Public Astro site on GitHub Pages: landing page in all 4 languages, topics/sources, release history, APK download + QR, plus the privacy-policy and account-deletion pages Play requires.
API Gateway + Google JWT authorizer Verifies a Google ID token before a request ever reaches a Lambda (D68); GET /v1/topics and GET /v1/sources are the only public routes. CORS disabled — this is a native-client-only API.
API Lambdas One Lambda per route (packages/functions/src/api/handlers/*), thin handlers over packages/core repos, validated by packages/shared zod schemas.
IngestPipeline (Step Functions) Fans out RSS fetching across all sources on a schedule; isolates per-source failures.
TransformQueue → Transform Lambda Article fetch, archive, image mirror, card-copy LLM call, eager translate/compact enqueue.
TranslateQueue → Translate Lambda Per-language card translation, eager per post (D27).
ContentQueue → Content Lambda Eager compact-article generation + once-per-post figure mirroring, for all 4 languages (D23/D36).
Neon Postgres (16 tables, Drizzle) Normalized schema — sources/source_states, posts + post_translations/post_topics/post_compacts/post_figures, users + per-user prefs/quota/entitlement/reads/bookmarks. See docs/DESIGN.md §6 for the table-by-table layout.
S3 + CloudFront Private raw-HTML archive; public mirrored images + compact-article JSON behind one Router. All three buckets expire objects on the same lifecycle — 90 days on production, 7 on dev.
LLM provider OpenRouter (google/gemini-3.1-flash-lite, D38) primary; AWS Bedrock kept wired as a dormant, env-switchable fallback (LLM_PROVIDER).
CloudWatch + SNS + AWS Budget Seven production-only alarms — DLQ depth (×3), Step Functions failure, API 5xx, ingest stalled, Lambda throttled — to one email-subscribed SNS topic; no ops dashboard (D89); $25/mo budget as an infra-drift signal (D74).

Repository layout

techtok/
├── sst.config.ts             # stages, imports infra/
├── infra/                    # SST components: api.ts, auth.ts, storage.ts, pipeline.ts, monitoring.ts
├── packages/
│   ├── shared/                # zod v4 contracts, topic/language taxonomy — zero runtime deps beyond zod
│   ├── core/                  # domain logic: RSS mapping, URL canonicalization, feed scoring/merge,
│   │                          #   entitlement, db/ schema + repos/ queries over models/ table models,
│   │                          #   LLM client/providers, pipeline stages
│   ├── functions/             # thin Lambda handlers: api/*, pipeline/*, ops/* (parse → call core → serialize)
│   └── e2e/                   # live-stage suites (D34): backend pipeline, API contract, Maestro emulator flows
├── apps/
│   ├── mobile/                # Expo app (expo-router), committed bare `android/` project (D18)
│   └── site/                  # Astro static site → GitHub Pages
├── scripts/                   # ops/CI scripts: wipeUsers, grantEntitlement, bumpMobileVersion,
│                              #   setupMail.sh + mail/, setupSiteDns.sh (domain mail + Pages DNS, D103)
└── docs/                      # DESIGN.md, IMPLEMENTATION_PLAN.md, DISTRIBUTION.md, RUNBOOK.md, DATA_SAFETY.md

functions handlers stay thin; all business logic lives in core, unit-testable without AWS (aws-sdk-client-mock + recorded LLM golden fixtures — no live AWS/LLM calls in the PR-triggered CI path).


Prerequisites

  • Node 22 (nvm use)
  • pnpm (corepack enable picks up the pinned version automatically)
  • An AWS account with credentials configured locally, for sst dev/sst deploy
  • An OpenRouter API key, for the LLM pipeline (see below)
  • A Google Cloud OAuth consent screen plus Web and Android OAuth client IDs (D68) — see infra/auth.ts for exactly what to create, including the Play-managed-vs-local-debug SHA-1 trap
  • JDK 17 + the Android SDK — no longer optional: Google Sign-In is a native module, so the everyday dev loop builds the app rather than running it in Expo Go
  • Optional, for the mobile E2E suite: the Maestro CLI

Setup

pnpm install

Backend (AWS, via SST)

pnpm dev              # sst dev --stage dev — live Lambda reload on the personal "dev" stage
pnpm deploy:dev       # sst deploy --stage dev — one-off deploy of the dev stage

The first run bootstraps your AWS account and prints the API's URL (Api: https://....execute-api.eu-central-1.amazonaws.com) — copy it for the mobile app's .env, below. Leave sst dev running; it keeps your stage in sync with local changes.

LLM provider secret (D32): the transform/translate/content Lambdas call OpenRouter by default and need a per-stage secret set once before they can complete an LLM call:

npx sst secret set OpenRouterApiKey <your-key> --stage dev

Set LLM_PROVIDER=bedrock (per stage, via infra/pipeline.ts's env vars) to fall back to the dormant Bedrock path instead — no code change needed, just IAM-based auth via the existing bedrock:InvokeModel grants.

Google OAuth client ID (D68): the API Gateway JWT authorizer checks its audience against this, so a deploy without it rejects every real Google ID token. It's a plain env var, not a secret (an OAuth client ID is public by design):

GOOGLE_OAUTH_WEB_CLIENT_ID=<web-client-id>.apps.googleusercontent.com npx sst deploy --stage dev

Production is deployed by CI only (see Deployment) — never sst deploy --stage production from a laptop.

Mobile app (Expo)

cd apps/mobile
cp .env.example .env    # then set EXPO_PUBLIC_API_URL to the sst dev API URL above,
                         # and EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID to the Google OAuth
                         # "Web application" client ID (D68 — see infra/auth.ts)
cd ../..
pnpm --filter mobile start

Google Sign-In ends the plain Expo Go loop (D68). @react-native-google-signin/google-signin is a native module Expo Go can't load, so scanning the QR code into Expo Go no longer works for this app. Use the committed bare android/ project instead:

pnpm --filter mobile prebuild:android   # only needed after app.json/plugin changes
cd apps/mobile/android && ./gradlew installDebug

or pnpm --filter mobile android (expo run:android), which builds and installs the debug APK directly. Either way needs an Android emulator or a device connected via adb.

Storybook runs the real components and screens in isolation on the web:

pnpm --filter mobile storybook   # http://localhost:6006

Every src/components/*.tsx needs a sibling *.stories.tsx, and every src/app/ route needs a page story under src/stories/pages/ — a PostToolUse hook flags missing files.

The repo also bans comments in code: no comments in .ts/.tsx/.js/.jsx/.mjs/.cjs/.astro apart from tool directives (biome-ignore, /// <reference>, @ts-expect-error, test-runner pragmas). pnpm lint enforces it via scripts/checkNoComments.ts, and a PostToolUse hook blocks edits that introduce one. Rationale for a change belongs in docs/DESIGN.md's decision log and the commit message.

Mobile builds (Android)

The app ships a committed, bare android/ project (Expo prebuild output — DESIGN §2 D18), so release artifacts build with the standard Gradle toolchain. Needs JDK 17 + the Android SDK locally.

pnpm --filter mobile build:android       # release AAB -> android/app/build/outputs/bundle/release/
pnpm --filter mobile build:android:apk   # release APK -> android/app/build/outputs/apk/release/
pnpm --filter mobile prebuild:android    # regenerate android/ after app.json / plugin / SDK changes

Release signing reads a gitignored apps/mobile/android/keystore.properties (falls back to the debug key when absent). Keystore creation, Google Play publishing, and the bare-workflow caveats are documented in docs/DISTRIBUTION.md.

eas.json's production profile builds an AAB (distribution: store), the artifact Mobile release (Play Store) submits to Play.

Verifying an OTA update landed

Mobile build (or, for a JS-only merge, CI's mobile-ota-update job) publishes the JS bundle to the preview EAS Update channel.

AndroidManifest.xml sets EXPO_UPDATES_CHECK_ON_LAUNCH=ALWAYS with a 0 ms wait, so the native side never blocks startup on a download — on its own that means a new bundle is fetched on one launch and only runs on the next one. state/updates.ts closes that gap (D84): it fetches on launch too, then applies the downloaded bundle by calling Updates.reloadAsync() the next time the app returns to the foreground after 5 minutes or more in the background. Shorter excursions never reload, so a Google sign-in hand-off or an article opened in the in-app browser is never interrupted; a signed-out session never reloads either.

Settings → Build is the on-device marker. It reads expo-updates and shows whether the running JS came over the air or shipped inside the APK, plus the short update id, publish time, channel, and runtime version:

Row Embedded launch After an OTA update
Running Bundle shipped with the app Over-the-air update
Update ID first 8 chars of the EAS update id
Published publish time, UTC

To confirm: background the app for 5 minutes and reopen it (or relaunch twice, which the native check-on-launch still covers), then match the update id against eas update:list --channel preview (or the EAS dashboard). In Expo Go every field except the bundle version reads , since there is no update runtime.

An OTA push only reaches devices whose installed APK has the same runtimeVersion (apps/mobile/app.json, bumped by mobile-version-bump on every native rebuild — D87/D101) — a native-affecting change needs a fresh APK, not an OTA.

Ops scripts

Both talk to live AWS and discover their target table by stage tag, so pass --stage explicitly.

pnpm wipe-users -- --stage dev                                  # dry run: counts what would be deleted
pnpm wipe-users -- --stage dev --confirm                        # actually delete every user + their activity
pnpm grant-entitlement -- --stage dev --user-id <id> --plan plus

wipe-users is the D68 migration path (pre-Google-identity rows carry no sub); grant-entitlement is the manual/comped path into D70's provider-agnostic entitlement layer, and the only way to reach a Plus account until Play Billing ships.

Project domain & mail

techtokapp.eu is registered in Route 53 Domains and receives mail through SES (D103): privacy@, support@ and noreply@techtokapp.eu are forwarded to the maintainer's Gmail by the techtok-mail-forwarder Lambda, with the raw copy kept for 30 days in s3://techtok-mail-inbound-<account>/inbound/. These are account-level resources, not per-stage, so they live outside infra/ and are provisioned by a standalone script:

bash scripts/setupMail.sh --domain techtokapp.eu --forward-to <gmail address>

Idempotent; needs admin credentials plus jq and zip — run it from AWS CloudShell, since the local techtok profile is read-only. It publishes SPF/DKIM/DMARC first, builds the S3 → Lambda → receipt-rule pipeline, waits for the SES identity to verify, and only then publishes MX; if verification is still pending after 10 minutes it exits 2 without MX and a re-run finishes the job. The Lambda source is scripts/mail/{forwarder,rewrite}.mjs (no bundling — the nodejs22.x runtime supplies the SDK); re-running the script redeploys it. Still manual, in the SES console: production access (the account is in the sandbox — 200 sends/day, verified recipients only) and SMTP credentials for Gmail "Send mail as" (email-smtp.eu-central-1.amazonaws.com, port 587, STARTTLS).

The same domain fronts the public site: scripts/setupSiteDns.sh --domain techtokapp.eu --pages-host tormozz48.github.io publishes GitHub Pages' apex A/AAAA records and the www CNAME (same admin-credential requirement, same idempotency). The custom domain itself is set in the repo's Settings → Pages — an Actions-based deploy ignores any CNAME file — and must be set together with the astro.config.ts site/base values, since a build for the old /techtok subpath 404s at the domain root.

Operational playbooks — stuck DLQs, compact-generation failures, the per-source compact kill switch, LLM spend — live in docs/RUNBOOK.md.

Quality gates

pnpm lint        # Biome + the no-comments check — no ESLint/Prettier in this repo (D7)
pnpm lint:comments  # just the no-comments check; accepts file paths to narrow it
pnpm typecheck   # tsc --noEmit, every package + scripts + sst.config.ts/infra (the latter only after a first `pnpm dev`)
pnpm test        # vitest (shared/core/functions) + jest (mobile)

All three must be green before considering a change done. Two extras, each a cheap proxy for a real device/browser pass:

pnpm --filter mobile exec expo export --platform android   # Metro bundle check, for any apps/mobile change
pnpm --filter site run build                               # for any apps/site change

Every request/response shape in packages/shared is also checked against a committed schema snapshot — a removed field, narrowed type, or removed enum value fails CI unless the snapshot is regenerated deliberately (D34):

pnpm --filter @techtok/shared run schema:check      # verify
pnpm --filter @techtok/shared run schema:snapshot   # regenerate, deliberately

End-to-end tests

packages/e2e is the only suite that touches real AWS — never triggered by a PR (D34). All three need credentials for the target stage.

pnpm --filter @techtok/e2e run backend-pipeline   # starts a real IngestPipeline run on dev, asserts Postgres/SQS state
pnpm --filter @techtok/e2e run api-contract       # calls the deployed dev API, parses every response through packages/shared

pnpm --filter @techtok/e2e run build-mobile-e2e-apk   # debug APK with the sign-in bypass, pointed at a real stage
pnpm --filter @techtok/e2e run mobile-e2e             # drives it on an emulator via Maestro

The authenticated suites need a dedicated test Google account's GOOGLE_TEST_REFRESH_TOKEN plus GOOGLE_OAUTH_WEB_CLIENT_ID/GOOGLE_OAUTH_WEB_CLIENT_SECRET (setup in packages/e2e/src/googleTestAuth.ts). Without them they skip cleanly rather than fail. Google deliberately blocks automating its consent UI, so the Maestro flows mint a real ID token and deep-link it in via a build-time bypass (EXPO_PUBLIC_E2E_AUTH=1) that no shipping profile sets — the API still verifies every token, so the bypass grants no access a normal sign-in wouldn't.

Deployment (CI/CD)

Eight GitHub Actions workflows. CI is the entry point; the seven others are reusable workflows it calls in sequence, each also runnable standalone via workflow_dispatch.

  • CI (ci.yml) — on PR + push to main. Six independent check jobs run in parallel, each doing its own cached pnpm install (D51 retired the shared-install setup job — the cache made it a net loss): lint, typecheck, test, schema-check, mobile-icon-check (D59 — catches source icon/splash assets drifting from the committed android/ project), and mobile-security-scan (mobsfscan over the app source and release manifest). A new push cancels an in-flight run only on PRs — main-branch runs queue instead, so a merge can never kill a release pipeline mid-deploy (D91).
  • Deploy dev (deploy-dev.yml) — deliberately has no needs, so it races the check jobs instead of queueing behind them (D52); dev is throwaway. Uses its own least-privilege OIDC role. Runs pnpm db:migrate against Neon (direct connection) before sst deploy (D92), so a pending schema migration always reaches dev automatically, then invokes the SeedSources Lambda after it — idempotent, and the only thing that refills sources after a destructive migration (D94), since LoadSources merely lists what is already there.
  • E2E (e2e.yml) — daily schedule, manual dispatch, and step 3 of the main-branch pipeline. Exercises the real dev stage via a narrowly-scoped, read/invoke-only OIDC role (D34). Its backend-pipeline job also takes NEON_DATABASE_URL_DEV_DIRECT so it can assert every enabled source was refetched, and fails rather than skips when that secret is missing (D97). Its Maestro mobile-emulator job is skipped in the release pipeline (skip_mobile_emulator: true) — UI flake shouldn't block a production deploy.
  • Deploy production (deploy-production.yml) — gated on E2E and all six checks. Runs the same automatic pnpm db:migrate step as Deploy dev (D92), against production's own direct connection string, before sst deploy, and the same post-deploy SeedSources invoke after it. Emits the real API URL from its own .sst/outputs.json, and since D100 warns (non-fatally) when the PRODUCTION_API_URL repository variable no longer matches it.
  • mobile-version-bump (a plain job inside ci.yml, not its own workflow file) — owns mobile versioning (D42/D44, extracted into its own job by D99): bumps versionCode and runtimeVersion unconditionally off their current values (D101 — every native rebuild needs a fresh pair, or Play rejects the AAB and two APKs share an OTA channel), computes a conventional-commit semver bump for version/versionName baselined against the last mobile-v* tag, and pushes the lot straight to main with [skip ci] and a rebase-retry loop. Gated on the same should_build signal a preceding mobile-changes job computes (D53), so it's skipped entirely — routing a JS-only merge to the mobile-ota-update job instead — when nothing mobile-relevant landed since that tag. Mobile build and Mobile release (Play Store) both depend only on this job and run in parallel (D99), since both just need the bumped versionCode it already pushed. Since D100 the whole mobile chain hangs off mobile-changes, which needs the six checks directly and so runs in parallel with the deploy/E2E chain rather than behind it — the API URL comes from the PRODUCTION_API_URL repository variable, and mobile-changes fails fast if that variable is unset.
  • Mobile build (mobile-build.yml) — builds an Android APK with eas build --local (on the GitHub runner, so it consumes no EAS cloud-build credits), publishes the JS bundle to the preview EAS Update channel (D60), attaches the APK to a GitHub Release with conventional-commit release notes (D58), and tags the release. Also runnable standalone via workflow_dispatch, in which case it bumps the version itself (skip_version_bump only defaults true when ci.yml calls it, since mobile-version-bump already did it).
  • Mobile release (Play Store) (mobile-release.yml) — builds the production eas.json profile as a Play-uploadable AAB with eas build --local, gated on the same should_build signal (D53) as Mobile build, so it only fires when a merge actually needed a new native build (D98), and runs in parallel with it (D99). Submits the AAB to the Play Console internal track via the Play Developer API when PlayServiceAccountKey (D71) is set; until then it uploads the AAB as a downloadable Actions artifact only, the same clean-skip convention as SENTRY_AUTH_TOKEN. Also runnable standalone via workflow_dispatch for an ad hoc rebuild against a specific API URL.
  • Deploy site (deploy-site.yml) — builds apps/site and publishes to GitHub Pages, checking out main at full depth so the version badge and release feed see the tag the mobile build just pushed.
  • Release cleanup (release-cleanup.yml) — keeps only the 3 most recent android-build-* releases and mobile-v* tags (D57), since every merge would otherwise leave a full APK behind forever.

The main-branch release pipeline, in order:

deploy-dev ─→ e2e ─→ deploy-production

checks ─→ mobile-changes ─┬─→ mobile-version-bump ─┬─→ mobile-build
                          │                        └─→ mobile-play-release
                          └─→ mobile-ota-update

deploy-site       needs deploy-production + mobile-build
release-cleanup   needs mobile-build

Two chains run in parallel (D100). The backend chain is unchanged — dev deploy, then E2E against dev, then the production deploy, each gating the next. The mobile chain no longer waits on it: it reads the API URL from the PRODUCTION_API_URL repository variable rather than Deploy production's workflow output, so the ~10 min native builds overlap the deploy and E2E instead of queueing behind them. mobile-build (APK) and mobile-play-release (AAB) both depend only on mobile-version-bump and run in parallel with each other too (D99). mobile-ota-update is mutually exclusive with those two (D53's should_build/should_ota split). deploy-site is the one join point — it needs deploy-production to have succeeded and mobile-build to have succeeded or been skipped; release-cleanup gates on mobile-build alone.

Required repository secrets

Secret Used by Notes
AWS_DEV_DEPLOY_ROLE_ARN Deploy dev OIDC role scoped to techtok-dev-* only
AWS_DEPLOY_ROLE_ARN Deploy production OIDC, production deploy
AWS_E2E_ROLE_ARN E2E OIDC, read/invoke only — never grant it write/deploy
NEON_DATABASE_URL_DEV_DIRECT Deploy dev, E2E Direct (non-pooled) Neon connection string for dev; runs pnpm db:migrate (D92) and, since D97, backs the E2E backend-pipeline job's source-freshness assertion — dev only, production's equivalent is never exposed to E2E — distinct from the pooled NeonDatabaseUrl sst.Secret the Lambdas link at runtime
NEON_DATABASE_URL_PRODUCTION_DIRECT Deploy production Same as above, for production
GOOGLE_OAUTH_WEB_CLIENT_ID both deploys, Mobile build Not sensitive; without it every ID token fails on audience mismatch
EXPO_TOKEN Mobile build Free Expo account; also supplies the signing credentials
SENTRY_AUTH_TOKEN Mobile build, mobile-ota-update Optional — its absence just skips the source-map/symbol upload
PlayServiceAccountKey Mobile release Google Cloud service account JSON with Play Developer API access (D71); its absence just skips the Play upload and leaves the AAB as a downloadable artifact
GOOGLE_TEST_REFRESH_TOKEN, GOOGLE_OAUTH_WEB_CLIENT_SECRET E2E The authenticated suites skip cleanly until these exist

No long-lived AWS keys anywhere — every AWS-touching job assumes a role via OIDC, and every PR-triggered job runs with no credentials at all.

Required repository variables

Variable Used by Notes
PRODUCTION_API_URL mobile-changes, Mobile build, Mobile release, mobile-ota-update The production API Gateway base URL, baked into every APK/AAB/OTA bundle as EXPO_PUBLIC_API_URL. A repository variable, not a secret: it is public by construction (extractable from any shipped APK), secrets can't be referenced in a job-level if:, and log masking would reduce the guard's error message to ***. Replaces the Deploy productionMobile build workflow output that used to carry it, which is what lets the mobile chain run in parallel with the deploy (D100). mobile-changes fails the whole mobile chain when it's unset; Deploy production warns when it no longer matches the URL it just deployed. Find the current value in that workflow's most recent run output.

About

Read tech and science news in form of simple cards and rels

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages