feat(#342): full-platform release automation + four-file notes gate

Today a tag builds and signs an APK/AAB but creates no release and
attaches nothing. That is the same partial-release failure mode as
rc.2/rc.3 (Play-only, no changelog). This closes it.

release-signed.yml becomes a complete release pipeline off a v* tag:

  gate      -> release-notes gate (backstop; fails in seconds)
  android   -> signed APK + AAB, cert-verified (environment-gated)
  windows   -> zipped Release build
  linux     -> tarred bundle
  release   -> create GitHub release, body = release notes, attach all four

Security invariant preserved: only the android job references the
release-signing Environment, so only it receives the keystore. The
windows/linux/release jobs never see it. Still no pull_request trigger.

The release job runs ONLY after every build passes, so a signing
failure yields no release rather than an empty one. Published directly
because the notes were reviewed in the cut PR.

Four-file notes gate (.github/scripts/release-gate.sh), run on every PR
(release-gate.yml) and as job one of the tag workflow:

  CHANGELOG.md            section for the pubspec version
  release-notes/<v>.md    non-empty  -> GitHub release body
  play/<v>.txt            non-empty, <=500 chars (Play limit)
  discord/<v>.md          non-empty

play and discord are human-pasted; CI validates, never sends. Any
missing/oversize file fails the build, so a noteless release (rc.2/rc.3)
is unmergeable.

Gate script lives in .github/scripts/ because /scripts is in
.git/info/exclude (would not commit). Verified locally: fail surfaces
all four problems at once, pass is clean, 500 passes, 501 fails.

Backfills the combined rc.2/rc.3 CHANGELOG entry the two versions
shipped without.

release-notes/, play/, discord/ seeded with README conventions.

No ref input needed: the rc.3 backfill branch will carry these workflow
files, so tagging that branch runs them at that commit.

Part of epic #312, plan #321. Agent: SapphireCompass (session 8d755b5e)
pull/347/head
Strycher 17 hours ago
parent 1f128174b7
commit 602776c601

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

@ -1,62 +1,63 @@
name: Release (signed)
name: Release (signed, all platforms)
# Produces RELEASE-SIGNED Android artifacts using the project's real keystore.
# 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 this workflow has access to the signing
# keystore, which is the single most irreplaceable credential in the project.
# If it leaks, the app can never be updated again on any channel (GitHub,
# F-Droid, Play all require a matching signature).
# 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.
#
# Therefore:
# - NEVER add a `pull_request` trigger here. A PR (including from a fork)
# must never be able to run a job that can read these secrets.
# - The job is pinned to the `release-signing` Environment, which carries a
# required reviewer, so a signing run cannot proceed unattended.
# - The keystore is written to disk only for the duration of the build and
# deleted in a step that runs even when the build fails.
# - Signing values are passed via `env:`, never interpolated into a command
# line (where they would be visible in process listings) and never echoed.
# - 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 continue to come from
# build.yml and are unaffected by this workflow. See #330, epic #312.
# Debug-signed artifacts for day-to-day PR testing still come from build.yml
# and are unaffected. See #330 / #342, epic #312.
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
concurrency:
group: release-signed-${{ github.ref }}
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
android-signed:
# 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
# Required-reviewer gate. Do not remove.
environment: release-signing
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"
# Pinned to the bench toolchain, matching every other workflow (#331).
# A release artifact in particular must not be built by whatever
# version `stable` happens to point at on the day.
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.44.1"
cache: true
- name: Cache Gradle
uses: actions/cache@v4
with:
@ -67,9 +68,8 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
# The keystore is PKCS12 (despite the .jks extension), which does not
# support a key password distinct from the store password. One secret
# therefore correctly populates both fields. See #330.
# 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 }}
@ -81,8 +81,6 @@ jobs:
exit 1
fi
printf '%s' "${KEYSTORE_BASE64}" | base64 -d > android/app/release.jks
# Fail loudly if the decode produced something implausible rather
# than letting Gradle fall back to debug signing silently.
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."
@ -100,94 +98,151 @@ jobs:
echo "Keystore decoded (${SIZE} bytes), key.properties written."
- run: flutter pub get
# Same mechanism as build.yml (#331). A signed release that shipped
# without the Giphy key would have a dead GIF picker.
- 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
# The load-bearing check. A green build does NOT prove the artifact is
# release-signed: build.gradle.kts silently falls back to the debug
# signing config when key.properties is missing or malformed (#111),
# which is exactly the failure this workflow exists to prevent. So we
# read the certificate off the built APK and fail if it is the debug key.
- name: Verify APK is signed with the release certificate
# 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:
# SHA-256 of the strycher-personal.jks signing certificate, taken
# from artifacts already published and installed by users: releases
# b55 and b58, and a local release build. Established without the
# keystore password, since reading a certificate off a signed APK
# needs no password.
#
# Pinning the exact value (rather than only rejecting the debug key)
# also catches a DIFFERENT key being substituted, which would break
# cross-channel updates against Play just as badly as debug signing.
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)
APKSIGNER="${BUILD_TOOLS}/apksigner"
echo "Using ${APKSIGNER}"
CERTS=$("${APKSIGNER}" verify --print-certs "${APK}")
# Debug fallback is the #111 failure mode and the reason this check
# exists: build.gradle.kts silently signs with the debug config when
# key.properties is absent, and a green build hides it completely.
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 was 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 [ -z "${ACTUAL}" ]; then
echo "::error::Could not read a certificate SHA-256 from the APK."
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 certificate MISMATCH."
echo "::error::expected ${EXPECTED_SHA256}"
echo "::error::actual ${ACTUAL}"
echo "::error::This APK would not install over an existing Offband install."
echo "::error::Signing cert MISMATCH. expected ${EXPECTED_SHA256} actual ${ACTUAL}"
exit 1
fi
echo "Signing certificate verified: ${ACTUAL}"
- name: Upload signed APK
uses: actions/upload-artifact@v4
with:
name: offband-signed-apk-r${{ github.run_number }}-${{ github.sha }}
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: error
retention-days: 30
- name: Upload signed AAB
uses: actions/upload-artifact@v4
- 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: offband-signed-aab-r${{ github.run_number }}-${{ github.sha }}
path: build/app/outputs/bundle/release/app-release.aab
name: release-android
path: dist/*
if-no-files-found: error
retention-days: 30
retention-days: 7
# Runs even when an earlier step failed. Without `if: always()` a build
# failure would leave the keystore and its password on the runner.
- name: Remove keystore material
if: always()
run: |
rm -f android/app/release.jks android/key.properties dart_defines.json
echo "Keystore material and dart_defines removed."
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/
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
TAG="${GITHUB_REF_NAME}"
# Idempotent: if a run is retried, update rather than fail.
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" out/* --clobber
gh release edit "${TAG}" --notes-file "${{ steps.v.outputs.notes }}"
else
gh release create "${TAG}" out/* \
--title "Offband Meshcore ${{ steps.v.outputs.version }}" \
--notes-file "${{ steps.v.outputs.notes }}" \
--prerelease
fi
echo "Release ${TAG} published with $(ls out | wc -l) assets."

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

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

@ -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,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.
Loading…
Cancel
Save

Powered by TurnKey Linux.