From 602776c601c3214dcee31630b4e4dfedf48ad556 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 18:31:59 -0400 Subject: [PATCH] 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/.md non-empty -> GitHub release body play/.txt non-empty, <=500 chars (Play limit) discord/.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) --- .github/scripts/release-gate.sh | 75 ++++++++ .github/workflows/release-gate.yml | 27 +++ .github/workflows/release-signed.yml | 245 ++++++++++++++++----------- CHANGELOG.md | 45 +++++ discord/README.md | 14 ++ play/README.md | 16 ++ release-notes/README.md | 22 +++ 7 files changed, 349 insertions(+), 95 deletions(-) create mode 100644 .github/scripts/release-gate.sh create mode 100644 .github/workflows/release-gate.yml create mode 100644 discord/README.md create mode 100644 play/README.md create mode 100644 release-notes/README.md 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/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 index 6a255b0..f363a11 100644 --- a/.github/workflows/release-signed.yml +++ b/.github/workflows/release-signed.yml @@ -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." 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/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/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/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.