chore(#345): overlay CI + release pipeline onto rc.3 for all-platform backfill
Branched off v1.1.2-rc.3 (630886e). Overlays dev's CI/release workflows,
the four-file notes gate + rc.3 notes, and the combined rc.2/rc.3
CHANGELOG. App code (pubspec 1.1.2-rc.3+60, lib/, android/) is untouched
— this is genuinely rc.3, only the release infrastructure is added.
Retires the inherited upstream Workers deploy (deploy.yml, wrangler.toml).
Tagging this triggers release-signed.yml: gate -> build android/windows/
linux -> sign -> verify APK+AAB cert -> publish. First live keystore
signing; pauses at the release-signing environment gate for approval.
Play already has the +60 AAB; this fills Windows/Linux/GitHub-APK/web.
Acceptance test for #342 / epic #312. Agent: SapphireCompass (session 8d755b5e)
release/1.1.2-rc.3-allplatforms
parent
630886eac0
commit
e1e10c4cf0
@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Release-notes gate. Verifies that every human-authored release text exists and
|
||||||
|
# is well-formed for the version currently in pubspec.yaml. Run BOTH on the cut
|
||||||
|
# PR (so it goes red before merge) and as the first job of the tag workflow (a
|
||||||
|
# cheap backstop before build/sign).
|
||||||
|
#
|
||||||
|
# rc.2 and rc.3 shipped with only a pubspec bump and no notes. This makes that
|
||||||
|
# state impossible: no notes, no release.
|
||||||
|
#
|
||||||
|
# Exit 0 = all present and valid. Exit 1 = a hard failure (prints every problem
|
||||||
|
# before exiting, so one run surfaces all of them, not one at a time).
|
||||||
|
#
|
||||||
|
# #342, epic #312.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PLAY_MAX=500
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
note() { printf ' %s\n' "$1"; }
|
||||||
|
bad() { printf '::error::%s\n' "$1"; fail=1; }
|
||||||
|
|
||||||
|
# --- version from pubspec (strip the +build suffix) -------------------------
|
||||||
|
VERSION=$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')
|
||||||
|
if [ -z "${VERSION}" ]; then
|
||||||
|
bad "Could not read a version from pubspec.yaml."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release gate for version: ${VERSION}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- 1. CHANGELOG has a section for this version ----------------------------
|
||||||
|
# Matches a markdown heading containing the version, e.g. "## [1.1.2-rc.4]".
|
||||||
|
if grep -qE "^#+.*\[?${VERSION//./\\.}\]?" CHANGELOG.md; then
|
||||||
|
note "CHANGELOG.md: section for ${VERSION} present"
|
||||||
|
else
|
||||||
|
bad "CHANGELOG.md has no section for ${VERSION}. Add one before releasing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. GitHub release notes exist and are non-empty ------------------------
|
||||||
|
RN="release-notes/${VERSION}.md"
|
||||||
|
if [ -s "${RN}" ]; then
|
||||||
|
note "release-notes: ${RN} present"
|
||||||
|
else
|
||||||
|
bad "${RN} is missing or empty. This becomes the GitHub release body."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Play 'What's New' exists and is within the length limit -------------
|
||||||
|
PLAY="play/${VERSION}.txt"
|
||||||
|
if [ ! -s "${PLAY}" ]; then
|
||||||
|
bad "${PLAY} is missing or empty. Required for the Play Console 'What's new'."
|
||||||
|
else
|
||||||
|
# Unicode-aware count (wc -m), matching Google's per-character limit.
|
||||||
|
LEN=$(wc -m < "${PLAY}" | tr -d '[:space:]')
|
||||||
|
if [ "${LEN}" -gt "${PLAY_MAX}" ]; then
|
||||||
|
bad "${PLAY} is ${LEN} chars; Play limit is ${PLAY_MAX}. Trim it."
|
||||||
|
else
|
||||||
|
note "play: ${PLAY} present (${LEN}/${PLAY_MAX} chars)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. Discord announcement exists and is non-empty ------------------------
|
||||||
|
DISCORD="discord/${VERSION}.md"
|
||||||
|
if [ -s "${DISCORD}" ]; then
|
||||||
|
note "discord: ${DISCORD} present"
|
||||||
|
else
|
||||||
|
bad "${DISCORD} is missing or empty. Paste-ready community announcement."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "${fail}" -ne 0 ]; then
|
||||||
|
echo "Release gate FAILED for ${VERSION}. Fix the above before tagging."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release gate passed for ${VERSION}."
|
||||||
@ -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:
|
|
||||||
channel: 'stable'
|
|
||||||
# Match local development version which provides Dart 3.11.0
|
|
||||||
flutter-version: '3.41.2'
|
|
||||||
|
|
||||||
- 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
|
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
name: Release gate
|
||||||
|
|
||||||
|
# Fails a PR whose pubspec version lacks any of the four human-authored release
|
||||||
|
# texts (CHANGELOG section, release-notes, play blurb <=500 chars, discord).
|
||||||
|
#
|
||||||
|
# This runs on EVERY PR so a release-cut PR goes red before merge if a note is
|
||||||
|
# missing. It is cheap and platform-agnostic. The tag workflow runs the same
|
||||||
|
# script as its first job (a backstop before build/sign).
|
||||||
|
#
|
||||||
|
# rc.2 and rc.3 shipped with only a pubspec bump. This makes that unmergeable.
|
||||||
|
#
|
||||||
|
# #342, epic #312.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check release texts for the pubspec version
|
||||||
|
run: bash .github/scripts/release-gate.sh
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
name: Release (signed, all platforms)
|
||||||
|
|
||||||
|
# Produces a COMPLETE, published release from a `v*` tag: signed Android APK +
|
||||||
|
# AAB, Windows, and Linux, attached to a GitHub release whose body is the
|
||||||
|
# human-authored release notes. This is what makes a partial release (the
|
||||||
|
# rc.2/rc.3 failure) impossible.
|
||||||
|
#
|
||||||
|
# SECURITY: this repo is PUBLIC and the `android` job has the signing keystore,
|
||||||
|
# the single most irreplaceable credential in the project. If it leaks the app
|
||||||
|
# can never be updated again on any channel.
|
||||||
|
#
|
||||||
|
# - NEVER add a `pull_request` trigger. A PR (incl. from a fork) must never
|
||||||
|
# run a job that can read the keystore.
|
||||||
|
# - Only the `android` job references the `release-signing` Environment, so
|
||||||
|
# ONLY it receives the keystore secrets, and only after the required
|
||||||
|
# reviewer approves. The windows/linux/release jobs never see them.
|
||||||
|
# - Keystore written to disk only for the build, deleted with `if: always()`.
|
||||||
|
# - Signing values passed via env:, never on a command line, never echoed.
|
||||||
|
#
|
||||||
|
# Debug-signed artifacts for day-to-day PR testing still come from build.yml
|
||||||
|
# and are unaffected. See #330 / #342, epic #312.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Cheap backstop: the same gate that runs on the cut PR, re-run here so a tag
|
||||||
|
# can never publish without the four release texts. Fails in seconds, before
|
||||||
|
# any expensive build.
|
||||||
|
gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Release-notes gate
|
||||||
|
run: bash .github/scripts/release-gate.sh
|
||||||
|
|
||||||
|
android:
|
||||||
|
needs: gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: release-signing # required-reviewer gate; do not remove
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: "17"
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: "3.44.1"
|
||||||
|
cache: true
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
# PKCS12 keystore (despite the .jks extension) has one password for both
|
||||||
|
# store and key. See #330.
|
||||||
|
- name: Decode signing keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -z "${KEYSTORE_BASE64}" ] || [ -z "${KEYSTORE_PASSWORD}" ]; then
|
||||||
|
echo "::error::Signing secrets are not available to this run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s' "${KEYSTORE_BASE64}" | base64 -d > android/app/release.jks
|
||||||
|
SIZE=$(stat -c%s android/app/release.jks)
|
||||||
|
if [ "${SIZE}" -lt 1000 ]; then
|
||||||
|
echo "::error::Decoded keystore is ${SIZE} bytes; expected ~2.8 KB. Secret is malformed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
cat > android/key.properties <<'PROPS'
|
||||||
|
storeFile=release.jks
|
||||||
|
keyAlias=meshcore
|
||||||
|
PROPS
|
||||||
|
{
|
||||||
|
printf 'storePassword=%s\n' "${KEYSTORE_PASSWORD}"
|
||||||
|
printf 'keyPassword=%s\n' "${KEYSTORE_PASSWORD}"
|
||||||
|
} >> android/key.properties
|
||||||
|
echo "Keystore decoded (${SIZE} bytes), key.properties written."
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
- 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
|
||||||
|
- name: Build signed APK
|
||||||
|
run: flutter build apk --release --no-pub --dart-define-from-file=dart_defines.json
|
||||||
|
- name: Build signed AAB
|
||||||
|
run: flutter build appbundle --release --no-pub --dart-define-from-file=dart_defines.json
|
||||||
|
|
||||||
|
# A green build does NOT prove release signing: build.gradle.kts silently
|
||||||
|
# falls back to the debug config when key.properties is absent (#111). Read
|
||||||
|
# the cert off the APK and fail on the debug key or any mismatch.
|
||||||
|
- name: Verify APK signing certificate
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA256: e7da8cd5bf22ac3eda7cb954b8b120a18b6c4382d1b6e6fdd204ddedddaf5488
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
APK=build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/* | sort -V | tail -1)
|
||||||
|
CERTS=$("${BUILD_TOOLS}/apksigner" verify --print-certs "${APK}")
|
||||||
|
if echo "${CERTS}" | grep -qi "CN=Android Debug"; then
|
||||||
|
echo "::error::APK is DEBUG-SIGNED. key.properties not picked up; see #111."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ACTUAL=$(echo "${CERTS}" | grep -i "Signer #1 certificate SHA-256 digest" | head -1 | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||||
|
if [ "${ACTUAL}" != "${EXPECTED_SHA256}" ]; then
|
||||||
|
echo "::error::Signing cert MISMATCH. expected ${EXPECTED_SHA256} actual ${ACTUAL}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "APK signing certificate verified: ${ACTUAL}"
|
||||||
|
|
||||||
|
# The AAB is the artifact that goes to Play, so verify IT directly rather
|
||||||
|
# than trusting the APK as a proxy. apksigner cannot read an AAB; keytool
|
||||||
|
# can, and reports the same SHA-256 fingerprint. Same pin as the APK.
|
||||||
|
# (Gemini review, #342.)
|
||||||
|
- name: Verify AAB signing certificate
|
||||||
|
env:
|
||||||
|
EXPECTED_SHA256: e7da8cd5bf22ac3eda7cb954b8b120a18b6c4382d1b6e6fdd204ddedddaf5488
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
AAB=build/app/outputs/bundle/release/app-release.aab
|
||||||
|
# keytool comes from the JDK that setup-java put on PATH in this job.
|
||||||
|
AAB_SHA=$(keytool -printcert -jarfile "${AAB}" \
|
||||||
|
| grep -i 'SHA256:' | head -1 \
|
||||||
|
| sed 's/.*SHA256:[[:space:]]*//' | tr -d ': ' | tr 'A-F' 'a-f')
|
||||||
|
if [ -z "${AAB_SHA}" ]; then
|
||||||
|
echo "::error::Could not read a SHA-256 from the AAB signing cert."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${AAB_SHA}" != "${EXPECTED_SHA256}" ]; then
|
||||||
|
echo "::error::AAB signing cert MISMATCH. expected ${EXPECTED_SHA256} actual ${AAB_SHA}"
|
||||||
|
echo "::error::This AAB would be rejected by Play App Signing or break cross-channel updates."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "AAB signing certificate verified: ${AAB_SHA}"
|
||||||
|
|
||||||
|
- name: Stage signed artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp build/app/outputs/flutter-apk/app-release.apk dist/
|
||||||
|
cp build/app/outputs/bundle/release/app-release.aab dist/
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-android
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Remove keystore material
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -f android/app/release.jks android/key.properties dart_defines.json
|
||||||
|
echo "Keystore material removed."
|
||||||
|
|
||||||
|
windows:
|
||||||
|
needs: gate
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: "3.44.1"
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Write dart_defines.json
|
||||||
|
env:
|
||||||
|
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
|
||||||
|
run: |
|
||||||
|
@{ GIPHY_API_KEY = $env:GIPHY_API_KEY } | ConvertTo-Json -Compress |
|
||||||
|
Set-Content -Path dart_defines.json -Encoding utf8 -NoNewline
|
||||||
|
- run: flutter build windows --release --no-pub --dart-define-from-file=dart_defines.json
|
||||||
|
- name: Zip Windows build
|
||||||
|
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath offband-windows-x64.zip
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-windows
|
||||||
|
path: offband-windows-x64.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
linux:
|
||||||
|
needs: gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: "3.44.1"
|
||||||
|
cache: true
|
||||||
|
- name: Install Linux build deps
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
|
||||||
|
- run: flutter pub get
|
||||||
|
- 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
|
||||||
|
- run: flutter build linux --release --no-pub --dart-define-from-file=dart_defines.json
|
||||||
|
- name: Tar Linux bundle
|
||||||
|
run: tar -czf offband-linux-x64.tar.gz -C build/linux/x64/release/bundle .
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-linux
|
||||||
|
path: offband-linux-x64.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Creates the release ONLY after all platform builds (and the signing cert
|
||||||
|
# check) have passed, so a signing failure yields no release rather than an
|
||||||
|
# empty one. Published directly: the notes were reviewed in the cut PR.
|
||||||
|
release:
|
||||||
|
needs: [android, windows, linux]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # create the release + upload assets
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Resolve version + notes
|
||||||
|
id: v
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
VERSION=$(grep -E '^version:' pubspec.yaml | head -1 | sed 's/version:[[:space:]]*//' | sed 's/+.*//' | tr -d '[:space:]')
|
||||||
|
NOTES="release-notes/${VERSION}.md"
|
||||||
|
test -s "${NOTES}" || { echo "::error::${NOTES} missing at release time"; exit 1; }
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "notes=${NOTES}" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: incoming
|
||||||
|
- name: Collect assets
|
||||||
|
run: |
|
||||||
|
mkdir -p out
|
||||||
|
cp incoming/release-android/* out/
|
||||||
|
cp incoming/release-windows/* out/
|
||||||
|
cp incoming/release-linux/* out/
|
||||||
|
echo "Assets for the release:"
|
||||||
|
ls -la out/
|
||||||
|
# Draft-first so publication is ATOMIC: the release is not visible to
|
||||||
|
# anyone until every asset is uploaded. Creating-then-uploading would
|
||||||
|
# leave a public release with missing assets if an upload stalls or the
|
||||||
|
# runner dies mid-way — the exact partial-release failure this pipeline
|
||||||
|
# exists to prevent. `gh release create --help` documents this pattern.
|
||||||
|
# The final `--draft=false` is applied in BOTH branches so a run retried
|
||||||
|
# after a mid-way death still ends published, never stuck as a draft.
|
||||||
|
- name: Create GitHub release (draft → upload → publish)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NOTES: ${{ steps.v.outputs.notes }}
|
||||||
|
VERSION: ${{ steps.v.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
if gh release view "${TAG}" >/dev/null 2>&1; then
|
||||||
|
# Retry / re-run: refresh assets and notes, then ensure published.
|
||||||
|
gh release upload "${TAG}" out/* --clobber
|
||||||
|
gh release edit "${TAG}" --notes-file "${NOTES}" --prerelease --draft=false
|
||||||
|
else
|
||||||
|
gh release create "${TAG}" \
|
||||||
|
--draft \
|
||||||
|
--title "Offband Meshcore ${VERSION}" \
|
||||||
|
--notes-file "${NOTES}" \
|
||||||
|
--prerelease
|
||||||
|
gh release upload "${TAG}" out/*
|
||||||
|
gh release edit "${TAG}" --draft=false
|
||||||
|
fi
|
||||||
|
echo "Release ${TAG} published with $(ls out | wc -l) assets."
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
**Offband Meshcore 1.1.2-rc.3 is out** 📡
|
||||||
|
|
||||||
|
Big one this round:
|
||||||
|
|
||||||
|
🚫 **Block & mute** — block contacts or channel senders from just about anywhere (contacts, DMs, discovery, a channel message). Blocked stuff is hidden with a marker, name-only blocks upgrade to a real key when the sender shows up again, and there's a Blocked screen in Settings to manage it all. Syncs to the radio where firmware supports it.
|
||||||
|
|
||||||
|
🔔 **Per-channel notifications** — set each channel to all / mentions only / muted.
|
||||||
|
|
||||||
|
🗺️ **Path trace + topology** — width-matched traces, a topology readout, and a route builder that groups hops properly. Plus share/add channels by QR.
|
||||||
|
|
||||||
|
🔧 Fixes: USB no longer wedges some ESP32-C6 boards, DMs to a blocked contact actually stop sending, and more radios report GPS without flooding the link.
|
||||||
|
|
||||||
|
Download for your platform below. Full changelog in the repo.
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
# Discord announcement
|
||||||
|
|
||||||
|
One file per release: `discord/<version>.md`, e.g. `discord/1.1.2-rc.4.md`.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Casual, paste-ready community announcement. What landed, in plain excited-but-honest
|
||||||
|
terms, with the download link. Emoji fine. This is the message the owner posts to Discord.
|
||||||
|
|
||||||
|
**The owner pastes this into Discord manually.** CI validates the file exists and is
|
||||||
|
non-empty; it never posts. Posting to a community is an external, human-triggered action.
|
||||||
|
|
||||||
|
The release gate (`scripts/release-gate.sh`) fails the build if this file is missing or
|
||||||
|
empty for the version being tagged.
|
||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
What's new in 1.1.2-rc.3
|
||||||
|
|
||||||
|
- Block or mute contacts and channel senders, with a Blocked management screen
|
||||||
|
- Per-channel notifications: all, mentions only, or muted
|
||||||
|
- Better path tracing and mesh topology readout
|
||||||
|
- Share and add channels by QR code
|
||||||
|
- Fix: USB no longer wedges some ESP32 boards on connect
|
||||||
|
- Fix: messages to a blocked contact are no longer sent
|
||||||
|
- More radios report GPS position reliably
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
# Play Console "What's new"
|
||||||
|
|
||||||
|
One file per release: `play/<version>.txt`, e.g. `play/1.1.2-rc.4.txt`.
|
||||||
|
|
||||||
|
**Hard limit: 500 Unicode characters.** The release gate (`scripts/release-gate.sh`)
|
||||||
|
fails the build if this file is missing, empty, or over 500 chars — so the limit is caught
|
||||||
|
here, not as a Console rejection at upload time.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Plain language for end users. No issue numbers, no internal terms. Not promotional
|
||||||
|
(Google's Metadata policy forbids "free", "#1", "best", etc. and ALL-CAPS except a brand
|
||||||
|
name).
|
||||||
|
|
||||||
|
**The owner pastes this into Play Console manually.** CI validates the file; it never
|
||||||
|
uploads. Play distribution is an external, human-triggered action.
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
## Offband Meshcore 1.1.2-rc.3
|
||||||
|
|
||||||
|
This release brings a full **block / mute system**, richer **path tracing and topology**, and **per-channel notification control**, along with a batch of connectivity and UX fixes. It combines the rc.2 and rc.3 closed-test builds.
|
||||||
|
|
||||||
|
### Blocking and muting
|
||||||
|
You can now block a contact or a channel sender from contacts, DMs, discovery, or directly from a channel message. Blocked DMs and adverts are suppressed with a visible marker, a name-only block is promoted to the sender's public key the next time they advert or DM you, and stale blocks age out after 30 days. Everything is managed from Settings → Blocked, and where the radio's firmware supports it, your block list syncs to the device on connect.
|
||||||
|
|
||||||
|
### Notifications, your way
|
||||||
|
Each channel gets a three-way notification selector — all messages, mentions only, or muted — applied right at the notification gate and remembered per channel.
|
||||||
|
|
||||||
|
### Path tracing and topology
|
||||||
|
Path traces are now width-matched to the mesh, with a topology readout and a route-builder that groups hops the way the network actually addresses them rather than as raw bytes. Channel QR codes let you share a channel or add one by scanning, using the reference-app format.
|
||||||
|
|
||||||
|
### Fixes worth calling out
|
||||||
|
- USB no longer wedges ESP32-C6 boards: the open-time DTR pulse is gated by device type, so it only fires where it's needed.
|
||||||
|
- Outgoing DMs to a blocked contact are dropped at the send path.
|
||||||
|
- GPS refresh falls back to stock-firmware telemetry when the fork-specific query isn't available, so more radios report position without flooding the link.
|
||||||
|
|
||||||
|
See `CHANGELOG.md` for the full itemized list.
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# GitHub release notes
|
||||||
|
|
||||||
|
One file per release: `release-notes/<version>.md`, where `<version>` is the marketing
|
||||||
|
version from `pubspec.yaml` (the part before `+`), e.g. `release-notes/1.1.2-rc.4.md`.
|
||||||
|
|
||||||
|
CI sets the GitHub release body from this file verbatim. It is **published directly** —
|
||||||
|
the notes are reviewed in the release-cut PR, so there is no draft step. What is in this
|
||||||
|
file at tag time is what the world sees.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Narrative prose, not a bulleted changelog. Written for someone deciding whether to install
|
||||||
|
or update: what's new, what got fixed, anything they should know. Full Markdown is fine.
|
||||||
|
|
||||||
|
This is distinct from:
|
||||||
|
- **`CHANGELOG.md`** — cumulative, terse, issue-referenced, for developers.
|
||||||
|
- **`play/<version>.txt`** — plain, ≤500 chars, for the store.
|
||||||
|
- **`discord/<version>.md`** — casual community announcement.
|
||||||
|
|
||||||
|
Same underlying changes, four different voices. The release gate
|
||||||
|
(`scripts/release-gate.sh`) fails the build if this file is missing or empty for the
|
||||||
|
version being tagged.
|
||||||
@ -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