You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/.github/workflows/release-signed.yml

289 lines
12 KiB

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
- run: dart run build_runner build --delete-conflicting-outputs
- 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
- run: dart run build_runner build --delete-conflicting-outputs
- 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
- run: dart run build_runner build --delete-conflicting-outputs
- 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."

Powered by TurnKey Linux.