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=<version> 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).
pull/340/head
Strycher 1 day ago
parent e17f539d7e
commit e595483da6

@ -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 <project>.pages.dev always serves production, so pointing dev at
# dev.<project>.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"

@ -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=<pubspec
# version> 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

@ -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

@ -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"
}
}

@ -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=<your pubspec version> to bootstrap/JS files

@ -1,7 +0,0 @@
#:schema node_modules/wrangler/config-schema.json
name = "meshcore"
compatibility_date = "2025-10-08"
[assets]
directory = "build/web"
Loading…
Cancel
Save

Powered by TurnKey Linux.