diff --git a/.github/scripts/release-gate.sh b/.github/scripts/release-gate.sh new file mode 100644 index 0000000..118565d --- /dev/null +++ b/.github/scripts/release-gate.sh @@ -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}." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7f6a0d3..238f426 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev pull_request: jobs: @@ -17,7 +18,7 @@ jobs: java-version: "17" - uses: subosito/flutter-action@v2 with: - channel: "stable" + flutter-version: "3.44.1" cache: true - name: Cache Gradle uses: actions/cache@v4 @@ -29,7 +30,18 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - run: flutter pub get - - run: flutter build apk --release --no-pub + - 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 apk --release --no-pub --dart-define-from-file=dart_defines.json + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: offband-android-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }} + path: build/app/outputs/flutter-apk/app-release.apk + if-no-files-found: error + retention-days: 14 ios: runs-on: macos-latest @@ -37,7 +49,7 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - channel: "stable" + flutter-version: "3.44.1" cache: true - run: flutter pub get - run: flutter build ios --release --no-codesign --no-pub @@ -48,12 +60,23 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - channel: "stable" + 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 - - run: flutter build linux --release --no-pub + - 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: Upload Linux bundle + uses: actions/upload-artifact@v4 + with: + name: offband-linux-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }} + path: build/linux/x64/release/bundle/ + if-no-files-found: error + retention-days: 14 macos: runs-on: macos-latest @@ -61,7 +84,7 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - channel: "stable" + flutter-version: "3.44.1" cache: true - run: flutter pub get - run: flutter build macos --release --no-pub @@ -72,10 +95,21 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - channel: "stable" + flutter-version: "3.44.1" cache: true - run: flutter pub get - - run: flutter build web --release --no-pub + - 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 web --release --no-pub --dart-define-from-file=dart_defines.json + - name: Upload web build + uses: actions/upload-artifact@v4 + with: + name: offband-web-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }} + path: build/web/ + if-no-files-found: error + retention-days: 14 windows: runs-on: windows-latest @@ -83,7 +117,22 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - channel: "stable" + flutter-version: "3.44.1" cache: true - run: flutter pub get - - run: flutter build windows --release --no-pub + # Windows runner defaults to PowerShell; jq is not guaranteed there, + # so build the JSON with ConvertTo-Json, which escapes correctly. + - 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: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: offband-windows-r${{ github.run_number }}-${{ github.event.pull_request.head.sha || github.sha }} + path: build/windows/x64/runner/Release/ + if-no-files-found: error + retention-days: 14 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 d53a271..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: - 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 diff --git a/.github/workflows/flutter_dart.yml b/.github/workflows/flutter_dart.yml index 117eb4f..bff800d 100644 --- a/.github/workflows/flutter_dart.yml +++ b/.github/workflows/flutter_dart.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - dev jobs: analyze: @@ -16,7 +17,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - channel: stable + flutter-version: "3.44.1" - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 0000000..d6ef81f --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -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 diff --git a/.github/workflows/release-signed.yml b/.github/workflows/release-signed.yml new file mode 100644 index 0000000..5d6ae8b --- /dev/null +++ b/.github/workflows/release-signed.yml @@ -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." diff --git a/CHANGELOG.md b/CHANGELOG.md index 76aaf62..b36c288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ All notable changes to Offband Meshcore. Pre-releases are tagged `-beta.N` / `-rc.N`. +## [1.1.2-rc.3] - 2026-07-17 + +Combined entry for rc.2 (versionCode 59) and rc.3 (versionCode 60). Both shipped +to the Play closed test with only a version bump; this backfills the changelog +they should have carried. + +### Added +- Block / mute system: block a contact or a channel sender from contacts, DMs, + discovery, or a channel message; blocked DMs and adverts are suppressed with a + visible marker, and a name-only block promotes to the sender's public key on + the next advert or DM and ages out after 30 days. Managed from Settings → + Blocked. Synced to firmware on connect where the radio supports it (#168–#180, + #172–#174, #169, #170). +- Per-channel notification modes: a 3-way selector (all / mentions / mute) on the + Channels screen, applied at the notification gate and stored keyed to the + channel PSK (#260, #261, #262). +- Path trace and topology: width-matched path tracing, a topology service and + readout, RX ingestion, and a route-builder UX that groups hops by hash width + rather than raw bytes (#150, #186, #156, #155, #224). +- Channel QR: share a channel as a scannable QR and scan one to add it, unified + with the community scanner and using the reference-app format (#163). +- Own device public key is now full, selectable, and one-tap copyable (#234). +- Reply-to chip shown above a reply-gif, with inline `@[Name]` mention chips + (#235). +- GPS refresh falls back to stock-firmware self-telemetry when the fork-specific + query is unavailable, chosen by firmware capability (#123). +- Queue-sync diagnostics: a file log of queue-drain activity plus a manual + force-drain action (#51). + +### Fixed +- USB open no longer wedges ESP32-C6 boards: the DTR open pulse is gated by USB + vendor ID, so it fires only for nRF52 reconnect and is skipped for chips it + would reset into ROM download mode (#248). +- Outgoing DMs to a blocked contact are dropped at the send path (#252). +- Contact path-length byte decodes at the device's hash width instead of being + read as a raw byte count (#224). +- Stale channel slot history is cleared on channel reassignment, and the message + store's device-key scope is aligned (#193, #194). +- Enter selects the highlighted emoji or mention in autocomplete (#238). +- Manual path entry honors the configured path-hash width (#155). + +### Changed +- The fork-only GPS query is gated on firmware capability, so stock and + non-Offband radios degrade gracefully instead of erroring (#123, #144). + ## [1.1.2-rc.1] - 2026-06-28 ### Added diff --git a/discord/1.1.2-rc.3.md b/discord/1.1.2-rc.3.md new file mode 100644 index 0000000..8df3296 --- /dev/null +++ b/discord/1.1.2-rc.3.md @@ -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. diff --git a/discord/README.md b/discord/README.md new file mode 100644 index 0000000..84bad3e --- /dev/null +++ b/discord/README.md @@ -0,0 +1,14 @@ +# Discord announcement + +One file per release: `discord/.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. 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/play/1.1.2-rc.3.txt b/play/1.1.2-rc.3.txt new file mode 100644 index 0000000..1e5ae05 --- /dev/null +++ b/play/1.1.2-rc.3.txt @@ -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 diff --git a/play/README.md b/play/README.md new file mode 100644 index 0000000..e6c339d --- /dev/null +++ b/play/README.md @@ -0,0 +1,16 @@ +# Play Console "What's new" + +One file per release: `play/.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. diff --git a/release-notes/1.1.2-rc.3.md b/release-notes/1.1.2-rc.3.md new file mode 100644 index 0000000..c5c07c2 --- /dev/null +++ b/release-notes/1.1.2-rc.3.md @@ -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. diff --git a/release-notes/README.md b/release-notes/README.md new file mode 100644 index 0000000..1d70330 --- /dev/null +++ b/release-notes/README.md @@ -0,0 +1,22 @@ +# GitHub release notes + +One file per release: `release-notes/.md`, where `` 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/.txt`** — plain, ≤500 chars, for the store. +- **`discord/.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. 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" -