From e595483da68478890d7c10a17647b866be4abe0e Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 15:07:03 -0400 Subject: [PATCH] feat(#339): deploy web client to offband.app via Cloudflare Pages Publishes the Flutter web client. offband.org stays the Hugo marketing site in OffbandMesh/offband-site, a separate Pages project, untouched. push to main -> --branch=main -> production -> offband.app push to dev -> --branch=dev -> dev tier -> dev.offband.app Direct upload only. Cloudflare's dashboard "Connect to Git" must not be enabled for this project; it would create a second git-integrated project racing this workflow. Matches how offband-site deploys. Because direct-upload projects cannot configure production branch controls in the Cloudflare dashboard, the "never auto-deploy arbitrary branches to prod" requirement from #122 is enforced by the workflow's trigger list instead. That is the safety mechanism, and it is better placed here than in dashboard state: reviewable and version controlled. Builds via build_pipe rather than a plain `flutter build web`, because build_pipe appends ?v= to the bootstrap and manifest files. Without it, browsers serve stale bundles after a deploy. Two fixes folded in: - pubspec.yaml build_pipe build_command did not pass --dart-define-from-file, so a deployed site would have shipped a dead GIF picker even though #331 wired the key into build.yml. The deploy path and the CI path used different build commands. - Retired the inherited zjs81 Workers deploy: removed deploy.yml and wrangler.toml (which targeted THEIR account's "meshcore" project) and dropped the now-dead `deploy` script from package.json. A root wrangler.toml carrying Workers config can also interfere with `wrangler pages deploy`. Verified locally by running the real build with the bench toolchain: build_pipe accepts the new flag, cache busting is applied (flutter_bootstrap.js?v=e2019703...), output is 46 files with a 10.2 MB largest asset (Pages limits are 25 MiB per asset and 20000 files), and the Giphy key is confirmed compiled into main.dart.js. Not verifiable locally: whether the repo's CLOUDFLARE_API_TOKEN carries Pages:Edit scope. The workstation token is DNS-only and 403s on the Pages API. A 403 on first deploy would indicate wrong scope; it fails safely. Part of epic #312, plan #321 (Phase 4). --- .github/workflows/configure-web-domains.yml | 94 +++++++++++++++++ .github/workflows/deploy-web.yml | 111 ++++++++++++++++++++ .github/workflows/deploy.yml | 41 -------- package.json | 3 +- pubspec.yaml | 2 +- wrangler.toml | 7 -- 6 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/configure-web-domains.yml create mode 100644 .github/workflows/deploy-web.yml delete mode 100644 .github/workflows/deploy.yml delete mode 100644 wrangler.toml diff --git a/.github/workflows/configure-web-domains.yml b/.github/workflows/configure-web-domains.yml new file mode 100644 index 0000000..f0cb672 --- /dev/null +++ b/.github/workflows/configure-web-domains.yml @@ -0,0 +1,94 @@ +name: Configure offband.app domains + +# One-time (idempotent) setup: attaches custom domains to the offband-app +# Pages project and creates the proxied CNAME records. +# +# offband.app -> offband-app.pages.dev (production branch) +# dev.offband.app -> dev.offband-app.pages.dev (dev branch alias) +# +# The dev record deliberately targets the BRANCH ALIAS rather than the +# project root. Cloudflare's documented behaviour: a custom domain CNAME'd +# to .pages.dev always serves production, so pointing dev at +# dev..pages.dev is what makes it serve the dev branch. This only +# works with Cloudflare-managed DNS and a PROXIED record; an unproxied or +# external record silently falls through to production. +# +# Manual trigger only. Domains do not need reconfiguring on every deploy, +# and this touches DNS. +# +# Mirrors OffbandMesh/offband-site/.github/workflows/configure-domains.yml, +# which is the working reference for offband.org. +# +# NOTE: offband.org belongs to a DIFFERENT Pages project (offband-site) in +# the same Cloudflare account. This workflow only ever touches the +# offband.app zone. See #339. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + domains: + runs-on: ubuntu-latest + env: + API: https://api.cloudflare.com/client/v4 + ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + PAGES_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + DNS_TOKEN: ${{ secrets.CLOUDFLARE_DNS_ZONE_TOKEN }} + PROJECT: offband-app + ZONE: offband.app + steps: + - name: Attach custom domains and create DNS records + run: | + set -euo pipefail + + ZID=$(curl -s -H "Authorization: Bearer $DNS_TOKEN" \ + "$API/zones?name=$ZONE" | jq -r '.result[0].id // empty') + if [ -z "$ZID" ]; then + echo "::error::Could not resolve zone id for $ZONE. Check CLOUDFLARE_DNS_ZONE_TOKEN has Zone:Read." + exit 1 + fi + echo "zone $ZONE -> ${ZID:0:6}…" + + # name -> CNAME target. Apex serves production; dev serves the + # dev branch alias. Cloudflare flattens the apex CNAME. + attach() { + NAME="$1"; TARGET="$2" + echo "── $NAME -> $TARGET ──" + + REC=$(curl -s -H "Authorization: Bearer $DNS_TOKEN" \ + "$API/zones/$ZID/dns_records?type=CNAME&name=$NAME" \ + | jq -r '.result[0].id // empty') + BODY=$(jq -n --arg n "$NAME" --arg c "$TARGET" \ + '{type:"CNAME",name:$n,content:$c,proxied:true}') + + if [ -n "$REC" ]; then + curl -s -X PUT -H "Authorization: Bearer $DNS_TOKEN" \ + -H "Content-Type: application/json" \ + "$API/zones/$ZID/dns_records/$REC" --data "$BODY" \ + | jq -r 'if .success then " DNS: updated" else " DNS ERR: " + (.errors|tostring) end' + else + curl -s -X POST -H "Authorization: Bearer $DNS_TOKEN" \ + -H "Content-Type: application/json" \ + "$API/zones/$ZID/dns_records" --data "$BODY" \ + | jq -r 'if .success then " DNS: created" else " DNS ERR: " + (.errors|tostring) end' + fi + + curl -s -X POST -H "Authorization: Bearer $PAGES_TOKEN" \ + -H "Content-Type: application/json" \ + "$API/accounts/$ACCOUNT_ID/pages/projects/$PROJECT/domains" \ + --data "$(jq -n --arg n "$NAME" '{name:$n}')" \ + | jq -r 'if .success then " Pages: attached (" + (.result.status // "pending") + ")" else " Pages: " + (.errors|tostring) end' + } + + attach "$ZONE" "${PROJECT}.pages.dev" + attach "dev.$ZONE" "dev.${PROJECT}.pages.dev" + + curl -s -X PATCH -H "Authorization: Bearer $DNS_TOKEN" \ + -H "Content-Type: application/json" \ + "$API/zones/$ZID/settings/always_use_https" --data '{"value":"on"}' \ + | jq -r 'if .success then "AlwaysHTTPS: on" else "AlwaysHTTPS (skip): " + (.errors|tostring) end' + + echo "configure-web-domains complete" diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..21a5c8a --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,111 @@ +name: Deploy web (Cloudflare Pages) + +# Publishes the Flutter web client to Cloudflare Pages. +# +# push to main -> --branch=main -> PRODUCTION -> offband.app +# push to dev -> --branch=dev -> dev tier -> dev.offband.app +# +# WHY THE TRIGGER LIST IS THE SAFETY MECHANISM: +# Direct-upload Pages projects cannot configure production branch controls +# in the Cloudflare dashboard, so the "never auto-deploy arbitrary branches +# to prod" requirement (#122) is enforced HERE, by the trigger scope. Adding +# a branch to `on.push.branches` is what grants it deploy rights. Do not add +# one casually, and never add `pull_request`. +# +# DO NOT enable Cloudflare's dashboard "Connect to Git" for this project. It +# creates a second, git-integrated project that races with this workflow. +# Deploys are Actions direct-upload only, matching offband-site. +# +# The marketing site (offband.org) is a SEPARATE Pages project in the same +# account, deployed from OffbandMesh/offband-site. Nothing here touches it. +# +# See #339, epic #312. + +on: + push: + branches: + - main + - dev + workflow_dispatch: + +concurrency: + # Serialise per target so two pushes cannot race the same environment, + # but let dev and main deploy independently. + group: deploy-web-${{ github.ref_name }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Pinned to the bench toolchain, same as every other workflow (#331). + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.1" + cache: true + + - run: flutter pub get + + # Same mechanism as build.yml (#331). build_pipe's build_command in + # pubspec.yaml passes --dart-define-from-file, so this file must exist + # before the build runs or the GIF picker ships dead. + - name: Write dart_defines.json + env: + GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} + run: jq -n --arg k "$GIPHY_API_KEY" '{GIPHY_API_KEY:$k}' > dart_defines.json + + # build_pipe, NOT a plain `flutter build web`. It appends ?v= to the bootstrap/JS files, which is what stops browsers + # serving stale code after a deploy. A plain flutter build has no cache + # busting and would leave users on old bundles. + - name: Build web (versioned, cache-busted) + run: dart run build_pipe:build + + - name: Verify build output + run: | + set -eu + test -f build/web/index.html || { echo "::error::build/web/index.html missing"; exit 1; } + COUNT=$(find build/web -type f | wc -l) + BIGGEST=$(find build/web -type f -printf '%s\n' | sort -rn | head -1) + echo "files: ${COUNT}, largest: $((BIGGEST / 1048576)) MB" + # Cloudflare Pages hard limits: 25 MiB per asset, 20000 files. + # Fail here with a clear message rather than mid-upload. + if [ "${BIGGEST}" -gt 26214400 ]; then + echo "::error::An asset exceeds the Cloudflare Pages 25 MiB limit." + find build/web -type f -size +25M -printf ' %s %p\n' + exit 1 + fi + if [ "${COUNT}" -gt 20000 ]; then + echo "::error::More than 20000 files; exceeds the Cloudflare Pages limit." + exit 1 + fi + grep -q 'v=' build/web/index.html \ + && echo "cache-busting query params present" \ + || echo "::warning::no ?v= found in index.html; check build_pipe config" + + - name: Deploy to Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -eu + npx --yes wrangler@3 pages project create offband-app \ + --production-branch=main \ + || echo "project already exists, continuing" + npx --yes wrangler@3 pages deploy build/web \ + --project-name=offband-app \ + --branch="${GITHUB_REF_NAME}" \ + --commit-dirty=true + + - name: Report target + run: | + if [ "${GITHUB_REF_NAME}" = "main" ]; then + echo "Deployed PRODUCTION -> https://offband.app" + else + echo "Deployed dev tier -> https://dev.offband.app" + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 42ed701..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Deploy to Cloudflare Workers - -# Manual-only. This workflow was inherited from upstream zjs81 and deploys the web -# build to *its* Cloudflare account; this fork has no Cloudflare secrets, so the old -# `push: tags: ['*']` trigger failed on every tag (missing CLOUDFLARE_API_TOKEN). -# Disabled the auto-trigger to stop the per-tag failures (chore #120). The job is -# preserved for the deliberate Offband web-app launch (Feature #121 / Epic #122), -# which will define the proper trigger scope + secrets. -on: - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - # Pinned to the bench toolchain (Dart 3.12.1). Was 3.41.2 with a - # comment claiming local parity that had not been true for months. - flutter-version: "3.44.1" - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Get dependencies - run: flutter pub get - - - name: Build Web - run: bun run build - - - name: Deploy to Cloudflare - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: deploy diff --git a/package.json b/package.json index 1684721..1f818b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "meshcore-open", "scripts": { - "build": "dart run build_pipe:build", - "deploy": "bun x wrangler deploy" + "build": "dart run build_pipe:build" } } diff --git a/pubspec.yaml b/pubspec.yaml index 0415a36..c458c79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -163,7 +163,7 @@ build_pipe: platforms: web: build: - build_command: flutter build web --release --pwa-strategy=none + build_command: flutter build web --release --pwa-strategy=none --dart-define-from-file=dart_defines.json # Strongly recommended: disables the default service worker which often causes more cache headaches add_version_query_param: true # This is the key flag! It appends ?v= to bootstrap/JS files diff --git a/wrangler.toml b/wrangler.toml deleted file mode 100644 index f3c57d9..0000000 --- a/wrangler.toml +++ /dev/null @@ -1,7 +0,0 @@ -#:schema node_modules/wrangler/config-schema.json -name = "meshcore" -compatibility_date = "2025-10-08" - -[assets] -directory = "build/web" -