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/344/head
parent
e17f539d7e
commit
83c965ef44
@ -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",
|
"name": "meshcore-open",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "dart run build_pipe:build",
|
"build": "dart run build_pipe:build"
|
||||||
"deploy": "bun x wrangler deploy"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
#:schema node_modules/wrangler/config-schema.json
|
|
||||||
name = "meshcore"
|
|
||||||
compatibility_date = "2025-10-08"
|
|
||||||
|
|
||||||
[assets]
|
|
||||||
directory = "build/web"
|
|
||||||
|
|
||||||
Loading…
Reference in new issue