Release signed APK #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release signed APK | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.2) | |
| required: true | |
| type: string | |
| prerelease: | |
| description: Mark the GitHub release as a prerelease | |
| required: true | |
| default: true | |
| type: boolean | |
| expected_version_code: | |
| description: Android versionCode reviewed for this release | |
| required: true | |
| type: string | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: release-${{ inputs.tag }} | |
| cancel-in-progress: false | |
| jobs: | |
| release: | |
| name: Verify, sign and publish FOSS APK | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| environment: release | |
| steps: | |
| - name: Validate tag format | |
| env: | |
| RELEASE_TAG: ${{ inputs.tag }} | |
| EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }} | |
| run: | | |
| if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then | |
| echo "Tag must begin with a numeric x.y.z version so Android versionName is deterministic." >&2 | |
| exit 1 | |
| fi | |
| if [[ ! "$EXPECTED_VERSION_CODE" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "expected_version_code must be a positive integer." >&2 | |
| exit 1 | |
| fi | |
| - name: Checkout release tag with Humla | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: refs/tags/${{ inputs.tag }} | |
| submodules: recursive | |
| fetch-depth: 0 | |
| - name: Verify required signing secrets | |
| env: | |
| MINIMUM_RELEASE_KEYSTORE_BASE64: ${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }} | |
| MINIMUM_RELEASE_STORE_PASSWORD: ${{ secrets.MINIMUM_RELEASE_STORE_PASSWORD }} | |
| MINIMUM_RELEASE_KEY_ALIAS: ${{ secrets.MINIMUM_RELEASE_KEY_ALIAS }} | |
| MINIMUM_RELEASE_KEY_PASSWORD: ${{ secrets.MINIMUM_RELEASE_KEY_PASSWORD }} | |
| MINIMUM_RELEASE_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }} | |
| run: | | |
| for value in \ | |
| MINIMUM_RELEASE_KEYSTORE_BASE64 \ | |
| MINIMUM_RELEASE_STORE_PASSWORD \ | |
| MINIMUM_RELEASE_KEY_ALIAS \ | |
| MINIMUM_RELEASE_KEY_PASSWORD \ | |
| MINIMUM_RELEASE_APPLICATION_ID; do | |
| if [[ -z "${!value}" ]]; then | |
| echo "Required release secret $value is not configured." >&2 | |
| exit 1 | |
| fi | |
| done | |
| - name: Set up Java 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "21" | |
| cache: gradle | |
| - name: Set up Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Install pinned NDK | |
| run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;25.1.8937393" | |
| - name: Materialize signing configuration | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.MINIMUM_RELEASE_KEYSTORE_BASE64 }} | |
| STORE_PASSWORD: ${{ secrets.MINIMUM_RELEASE_STORE_PASSWORD }} | |
| KEY_ALIAS: ${{ secrets.MINIMUM_RELEASE_KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.MINIMUM_RELEASE_KEY_PASSWORD }} | |
| run: | | |
| printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/minimum-release.jks" | |
| cat > app/signing.gradle <<'GRADLE' | |
| android { | |
| signingConfigs { | |
| release { | |
| storeFile file(System.getenv('MINIMUM_KEYSTORE_PATH')) | |
| storePassword System.getenv('MINIMUM_STORE_PASSWORD') | |
| keyAlias System.getenv('MINIMUM_KEY_ALIAS') | |
| keyPassword System.getenv('MINIMUM_KEY_PASSWORD') | |
| } | |
| } | |
| } | |
| GRADLE | |
| echo "MINIMUM_KEYSTORE_PATH=$RUNNER_TEMP/minimum-release.jks" >> "$GITHUB_ENV" | |
| echo "MINIMUM_STORE_PASSWORD=$STORE_PASSWORD" >> "$GITHUB_ENV" | |
| echo "MINIMUM_KEY_ALIAS=$KEY_ALIAS" >> "$GITHUB_ENV" | |
| echo "MINIMUM_KEY_PASSWORD=$KEY_PASSWORD" >> "$GITHUB_ENV" | |
| - name: Build patched Spongy Castle jars | |
| working-directory: libraries/humla/libs/humla-spongycastle | |
| run: | | |
| chmod +x ../../gradlew | |
| ../../gradlew jar --no-daemon | |
| - name: Test and assemble signed FOSS release | |
| run: | | |
| chmod +x gradlew | |
| ./gradlew :app:testFossDebugUnitTest :app:assembleFossRelease --no-daemon --stacktrace | |
| - name: Verify APK signature and identity | |
| env: | |
| RELEASE_TAG: ${{ inputs.tag }} | |
| EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }} | |
| EXPECTED_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }} | |
| run: | | |
| set -o pipefail | |
| APK=app/build/outputs/apk/foss/release/mumla-foss-release.apk | |
| "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK" | tee "$RUNNER_TEMP/apk-signature.txt" | |
| mapfile -t signer_shas < <(sed -n 's/^Signer #[0-9][0-9]* certificate SHA-256 digest: //p' "$RUNNER_TEMP/apk-signature.txt") | |
| if [[ ${#signer_shas[@]} -ne 1 || ! "${signer_shas[0]}" =~ ^[0-9a-fA-F]{64}$ ]]; then | |
| echo "Release APK must have exactly one reviewed signing certificate; missing or extra signers are refused." >&2 | |
| exit 1 | |
| fi | |
| signer_sha=${signer_shas[0]} | |
| echo "MINIMUM_APK_SIGNER_SHA256=${signer_sha^^}" >> "$GITHUB_ENV" | |
| "$ANDROID_HOME/build-tools/36.0.0/aapt" dump badging "$APK" | tee "$RUNNER_TEMP/apk-badging.txt" | |
| grep -F "package: name='$EXPECTED_APPLICATION_ID'" "$RUNNER_TEMP/apk-badging.txt" | |
| grep -F "versionCode='$EXPECTED_VERSION_CODE'" "$RUNNER_TEMP/apk-badging.txt" | |
| grep -F "versionName='$RELEASE_TAG'" "$RUNNER_TEMP/apk-badging.txt" | |
| cp "$APK" "minimum-${RELEASE_TAG}-foss.apk" | |
| sha256sum "minimum-${RELEASE_TAG}-foss.apk" > "minimum-${RELEASE_TAG}-foss.apk.sha256" | |
| - name: Test Windows updater logic | |
| shell: pwsh | |
| run: | | |
| $errors = $null | |
| $tokens = $null | |
| [System.Management.Automation.Language.Parser]::ParseFile( | |
| (Resolve-Path 'scripts/update-minimum-device.ps1'), | |
| [ref]$tokens, | |
| [ref]$errors) | Out-Null | |
| if ($errors.Count -gt 0) { | |
| $errors | Format-List * | |
| throw "Updater failed PowerShell AST parsing." | |
| } | |
| ./tools/verify-cellular-policy.ps1 | |
| $env:MINIMUM_TEST_SIGNED_APK = (Resolve-Path 'app/build/outputs/apk/foss/release/mumla-foss-release.apk').Path | |
| $env:MINIMUM_TEST_EXPECTED_APPLICATION_ID = '${{ vars.MINIMUM_RELEASE_APPLICATION_ID }}' | |
| $env:MINIMUM_TEST_EXPECTED_VERSION_CODE = '${{ inputs.expected_version_code }}' | |
| $env:MINIMUM_TEST_EXPECTED_VERSION_NAME = '${{ inputs.tag }}' | |
| ./tests/update-minimum-device.Tests.ps1 | |
| - name: Build temporary Wi-Fi provisioner | |
| run: | | |
| ./gradlew -p tools/t99-wifi-provisioner :app:assembleDebug --no-daemon --stacktrace | |
| test -f tools/t99-wifi-provisioner/app/build/outputs/apk/debug/app-debug.apk | |
| - name: Package standalone Windows provisioning bundle | |
| env: | |
| RELEASE_TAG: ${{ inputs.tag }} | |
| run: | | |
| BUNDLE_NAME="minimum-provisioning-${RELEASE_TAG}" | |
| BUNDLE_DIR="$RUNNER_TEMP/$BUNDLE_NAME" | |
| OUTPUT_ZIP="$GITHUB_WORKSPACE/$BUNDLE_NAME.zip" | |
| EXTRACT_DIR="$RUNNER_TEMP/${BUNDLE_NAME}-extracted" | |
| # Do not let a rerun package stale files or leave a previous artifact | |
| # looking like the output of this invocation. | |
| rm -rf -- "$BUNDLE_DIR" "$EXTRACT_DIR" | |
| rm -f -- "$OUTPUT_ZIP" "$OUTPUT_ZIP.sha256" | |
| mkdir -p "$BUNDLE_DIR/scripts" "$BUNDLE_DIR/assets" | |
| cp "Provision Minimum Device.cmd" "$BUNDLE_DIR/" | |
| cp "Update Minimum Device.cmd" "$BUNDLE_DIR/" | |
| cp "minimum-${RELEASE_TAG}-foss.apk" "$BUNDLE_DIR/minimum-foss.apk" | |
| cp "minimum-${RELEASE_TAG}-foss.apk.sha256" "$BUNDLE_DIR/minimum-foss.apk.sha256" | |
| sed -i "s#minimum-${RELEASE_TAG}-foss.apk#minimum-foss.apk#" "$BUNDLE_DIR/minimum-foss.apk.sha256" | |
| cp scripts/provision-minimum-device.ps1 "$BUNDLE_DIR/scripts/" | |
| cp scripts/update-minimum-device.ps1 "$BUNDLE_DIR/scripts/" | |
| cp scripts/manage-cellular.ps1 "$BUNDLE_DIR/scripts/" | |
| cp scripts/prepare-t99.ps1 "$BUNDLE_DIR/scripts/" | |
| cp scripts/prepare-t56.ps1 "$BUNDLE_DIR/scripts/" | |
| cp scripts/prepare-ryks.ps1 "$BUNDLE_DIR/scripts/" | |
| cp tools/t99-wifi-provisioner/app/build/outputs/apk/debug/app-debug.apk \ | |
| "$BUNDLE_DIR/assets/t99-wifi-provisioner.apk" | |
| cp docs/PROVISIONING_BUNDLE_README.txt "$BUNDLE_DIR/README.txt" | |
| cp docs/UPDATER_RUNBOOK.md "$BUNDLE_DIR/UPDATER-README.md" | |
| cp docs/CELLULAR_PROVISIONING.md "$BUNDLE_DIR/CELLULAR-README.md" | |
| printf '%s\n' "$RELEASE_TAG" > "$BUNDLE_DIR/VERSION.txt" | |
| apk_sha=$(sha256sum "$BUNDLE_DIR/minimum-foss.apk" | cut -d' ' -f1) | |
| file_entries="$RUNNER_TEMP/release-manifest-files.json" | |
| find "$BUNDLE_DIR" -type f -printf '%P\n' | LC_ALL=C sort | while IFS= read -r relative; do | |
| jq -cn --arg path "$relative" --arg sha256 "$(sha256sum "$BUNDLE_DIR/$relative" | cut -d' ' -f1 | tr '[:lower:]' '[:upper:]')" \ | |
| '{path:$path,sha256:$sha256}' | |
| done | jq -s . > "$file_entries" | |
| jq -n \ | |
| --arg releaseTag "$RELEASE_TAG" \ | |
| --argjson versionCode '${{ inputs.expected_version_code }}' \ | |
| --arg apkSha256 "${apk_sha^^}" \ | |
| --arg signerSha256 "$MINIMUM_APK_SIGNER_SHA256" \ | |
| --slurpfile files "$file_entries" \ | |
| '{schemaVersion:1,releaseTag:$releaseTag,applicationId:"se.lublin.mumla",versionCode:$versionCode,versionName:$releaseTag,apkFile:"minimum-foss.apk",apkSha256:$apkSha256,signerSha256:$signerSha256,rebootRequired:false,migrations:[{id:"CELLULAR_POLICY_V1_T56",fromVersionCodeMax:3070300,toVersionCode:3070301,profiles:["T56"],rebootRequired:true,irreversible:false}],files:$files[0]}' \ | |
| > "$BUNDLE_DIR/RELEASE-MANIFEST.json" | |
| expected_files="$RUNNER_TEMP/provisioning-bundle-expected-files.txt" | |
| expected_dirs="$RUNNER_TEMP/provisioning-bundle-expected-dirs.txt" | |
| printf '%s\n' \ | |
| "$BUNDLE_NAME/Provision Minimum Device.cmd" \ | |
| "$BUNDLE_NAME/Update Minimum Device.cmd" \ | |
| "$BUNDLE_NAME/README.txt" \ | |
| "$BUNDLE_NAME/CELLULAR-README.md" \ | |
| "$BUNDLE_NAME/RELEASE-MANIFEST.json" \ | |
| "$BUNDLE_NAME/UPDATER-README.md" \ | |
| "$BUNDLE_NAME/VERSION.txt" \ | |
| "$BUNDLE_NAME/minimum-foss.apk" \ | |
| "$BUNDLE_NAME/minimum-foss.apk.sha256" \ | |
| "$BUNDLE_NAME/scripts/prepare-ryks.ps1" \ | |
| "$BUNDLE_NAME/scripts/manage-cellular.ps1" \ | |
| "$BUNDLE_NAME/scripts/prepare-t56.ps1" \ | |
| "$BUNDLE_NAME/scripts/prepare-t99.ps1" \ | |
| "$BUNDLE_NAME/scripts/provision-minimum-device.ps1" \ | |
| "$BUNDLE_NAME/scripts/update-minimum-device.ps1" \ | |
| "$BUNDLE_NAME/assets/t99-wifi-provisioner.apk" > "$expected_files" | |
| sort -o "$expected_files" "$expected_files" | |
| printf '%s\n' \ | |
| "$BUNDLE_NAME/" \ | |
| "$BUNDLE_NAME/assets/" \ | |
| "$BUNDLE_NAME/scripts/" > "$expected_dirs" | |
| sort -o "$expected_dirs" "$expected_dirs" | |
| find "$BUNDLE_DIR" -mindepth 1 \( -type l -o ! \( -type f -o -type d \) \) -print -quit > "$RUNNER_TEMP/provisioning-bundle-special-files.txt" | |
| if [[ -s "$RUNNER_TEMP/provisioning-bundle-special-files.txt" ]]; then | |
| echo "Provisioning bundle contains a symlink or non-regular entry (filename only):" | |
| cat "$RUNNER_TEMP/provisioning-bundle-special-files.txt" | |
| exit 1 | |
| fi | |
| find "$BUNDLE_DIR" -type f -printf '%P\n' | sed "s#^#$BUNDLE_NAME/#" | sort > "$RUNNER_TEMP/provisioning-bundle-staged-files.txt" | |
| { echo "$BUNDLE_NAME/"; find "$BUNDLE_DIR" -mindepth 1 -type d -printf '%P/\n' | sed "s#^#$BUNDLE_NAME/#"; } | sort > "$RUNNER_TEMP/provisioning-bundle-staged-dirs.txt" | |
| if ! diff -u "$expected_files" "$RUNNER_TEMP/provisioning-bundle-staged-files.txt"; then | |
| echo "Provisioning bundle staged file manifest differs from the reviewed allowlist." | |
| exit 1 | |
| fi | |
| if ! diff -u "$expected_dirs" "$RUNNER_TEMP/provisioning-bundle-staged-dirs.txt"; then | |
| echo "Provisioning bundle staged directory manifest differs from the reviewed allowlist." | |
| exit 1 | |
| fi | |
| text_pattern='BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|MINIMUM_(RELEASE_)?(KEYSTORE|STORE|KEY_PASSWORD|KEY_ALIAS)(_[A-Z0-9_]+)?=.+' | |
| for text_root in "$BUNDLE_DIR"; do | |
| while IFS= read -r text_file; do | |
| if grep -IlE "$text_pattern" "$text_file" > /dev/null; then | |
| echo "Credential-like content pattern found in staged text file (filename only): ${text_file#$BUNDLE_DIR/}" | |
| exit 1 | |
| fi | |
| done < <(find "$text_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print) | |
| done | |
| ( | |
| cd "$RUNNER_TEMP" | |
| zip -q -r "$OUTPUT_ZIP" "$BUNDLE_NAME" | |
| ) | |
| # Compare the ZIP's raw member list, retaining duplicate lines, to | |
| # the reviewed file+directory allowlist. Extracted-tree checks alone | |
| # cannot detect duplicate archive members because extraction merges | |
| # them into one path. | |
| expected_members="$RUNNER_TEMP/provisioning-bundle-expected-members.txt" | |
| cat "$expected_dirs" "$expected_files" | sort > "$expected_members" | |
| unzip -Z1 "$OUTPUT_ZIP" | sort > "$RUNNER_TEMP/provisioning-bundle-paths.txt" | |
| if ! diff -u "$expected_members" "$RUNNER_TEMP/provisioning-bundle-paths.txt"; then | |
| echo "Provisioning ZIP raw member list differs from the reviewed allowlist (duplicates or extras are not allowed)." | |
| exit 1 | |
| fi | |
| if grep -Eq '(^/|(^|/)\.\.?(/|$)|(^|/)\.(secrets|git)(/|$))' "$RUNNER_TEMP/provisioning-bundle-paths.txt"; then | |
| echo "Provisioning ZIP contains an absolute, traversal or hidden private path (path names only)." >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "$EXTRACT_DIR" | |
| unzip -q "$OUTPUT_ZIP" -d "$EXTRACT_DIR" | |
| extracted_root="$EXTRACT_DIR/$BUNDLE_NAME" | |
| if [[ ! -d "$extracted_root" ]]; then | |
| echo "Provisioning ZIP did not extract the expected root directory." | |
| exit 1 | |
| fi | |
| find "$extracted_root" -mindepth 1 \( -type l -o ! \( -type f -o -type d \) \) -print -quit > "$RUNNER_TEMP/provisioning-bundle-extracted-special-files.txt" | |
| if [[ -s "$RUNNER_TEMP/provisioning-bundle-extracted-special-files.txt" ]]; then | |
| echo "Extracted provisioning ZIP contains a symlink or non-regular entry (filename only):" | |
| cat "$RUNNER_TEMP/provisioning-bundle-extracted-special-files.txt" | |
| exit 1 | |
| fi | |
| find "$extracted_root" -type f -printf '%P\n' | sed "s#^#$BUNDLE_NAME/#" | sort > "$RUNNER_TEMP/provisioning-bundle-extracted-files.txt" | |
| { echo "$BUNDLE_NAME/"; find "$extracted_root" -mindepth 1 -type d -printf '%P/\n' | sed "s#^#$BUNDLE_NAME/#"; } | sort > "$RUNNER_TEMP/provisioning-bundle-extracted-dirs.txt" | |
| if ! diff -u "$expected_files" "$RUNNER_TEMP/provisioning-bundle-extracted-files.txt"; then | |
| echo "Freshly extracted provisioning ZIP file manifest differs from the reviewed allowlist." | |
| exit 1 | |
| fi | |
| if ! diff -u "$expected_dirs" "$RUNNER_TEMP/provisioning-bundle-extracted-dirs.txt"; then | |
| echo "Freshly extracted provisioning ZIP directory manifest differs from the reviewed allowlist." | |
| exit 1 | |
| fi | |
| while IFS= read -r text_file; do | |
| if grep -IlE "$text_pattern" "$text_file" > /dev/null; then | |
| echo "Credential-like content pattern found in extracted text file (filename only): ${text_file#$extracted_root/}" | |
| exit 1 | |
| fi | |
| done < <(find "$extracted_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print) | |
| pwsh -NoLogo -NoProfile -Command \ | |
| ". '$extracted_root/scripts/update-minimum-device.ps1' -LibraryOnly; Read-ReleaseBundle -Root '$extracted_root' | Out-Null; \$identity = Get-ApkManifestIdentity -ApkPath '$extracted_root/minimum-foss.apk'; if (\$identity.ApplicationId -cne 'se.lublin.mumla' -or \$identity.VersionName -cne '$RELEASE_TAG') { throw 'Extracted updater APK identity verification failed.' }; \$signers = @(Get-ApkSignerDigests -ApkPath '$extracted_root/minimum-foss.apk'); if (\$signers.Count -ne 1 -or \$signers[0] -cne '$MINIMUM_APK_SIGNER_SHA256') { throw 'Extracted updater APK signer-set verification failed.' }" | |
| echo "Provisioning bundle verification passed: exact allowlist, regular files, no symlinks, safe paths, staged and extracted content checks." | |
| sha256sum "$OUTPUT_ZIP" > "$OUTPUT_ZIP.sha256" | |
| - name: Prepare reviewed release notes | |
| env: | |
| RELEASE_TAG: ${{ inputs.tag }} | |
| run: | | |
| notes="$RUNNER_TEMP/release-notes.md" | |
| changes="$RUNNER_TEMP/release-changes.txt" | |
| current_commit=$(git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG^{commit}" || true) | |
| previous_tag="" | |
| if [[ -n "$current_commit" ]]; then | |
| # Version sorting alone can select an unrelated tag from another | |
| # branch. Consider only a tag that resolves to a commit reachable | |
| # from the exact release tag before using a range. | |
| while IFS= read -r candidate; do | |
| [[ "$candidate" == "$RELEASE_TAG" ]] && continue | |
| candidate_commit=$(git rev-parse --verify --quiet "refs/tags/$candidate^{commit}" || true) | |
| if [[ -n "$candidate_commit" ]] && git merge-base --is-ancestor "$candidate_commit" "$current_commit"; then | |
| previous_tag="$candidate" | |
| break | |
| fi | |
| done < <(git tag --sort=-version:refname) | |
| fi | |
| if [[ -n "$previous_tag" ]] && git merge-base --is-ancestor "$previous_tag" "$RELEASE_TAG"; then | |
| if ! git log --format='- %h %s' "$previous_tag..$RELEASE_TAG" > "$changes"; then | |
| git log --format='- %h %s' -n 25 "$RELEASE_TAG" > "$changes" | |
| fi | |
| else | |
| # A missing/mismatched tag or unusable range gets a bounded commit | |
| # summary rather than causing release-note generation to fail. | |
| git log --format='- %h %s' -n 25 "${current_commit:-HEAD}" > "$changes" | |
| fi | |
| sed -E -i 's/(token|password|secret|private[_ -]?key)[=:][^[:space:]]+/\1=<redacted>/Ig' "$changes" | |
| { | |
| echo "# Minimum $RELEASE_TAG" | |
| echo | |
| echo "## Reviewed support matrix" | |
| echo "- T56: UNIPRO / ZX" | |
| echo "- T99: Youdotech / QM011" | |
| echo "- RYKS: ELINK / ym_258" | |
| echo "- Unknown or ambiguous hardware is rejected before provisioning changes." | |
| echo | |
| echo "## Physical-test status and known limitations" | |
| echo "- Physical acceptance is operator-gated; this workflow does not claim PTT, audio, room-switching or Location success without reviewed device evidence." | |
| echo "- Provisioning PASS requires same-ID Ready after reboot; \`-SkipReboot\` is explicitly INCOMPLETE." | |
| echo "- The existing-device updater verifies the exact bundle/APK/signer, preserves app data, and refuses unknown migrations or debug-to-release signer changes." | |
| echo "- Updater acceptance must be recorded separately for every model/signing channel claimed; no release workflow run itself proves physical acceptance." | |
| echo "- T56 network Location requires on-device consent within 120 seconds and remains subject to the documented manual safety boundary." | |
| echo | |
| echo "## Generated changes" | |
| if [[ -s "$changes" ]]; then cat "$changes"; else echo "- No generated commit summary available."; fi | |
| } > "$notes" | |
| - name: Publish GitHub release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ inputs.tag }} | |
| PRERELEASE: ${{ inputs.prerelease }} | |
| run: | | |
| prerelease_flag=() | |
| if [[ "$PRERELEASE" == "true" ]]; then prerelease_flag+=(--prerelease); fi | |
| gh release create "$RELEASE_TAG" \ | |
| "minimum-${RELEASE_TAG}-foss.apk" \ | |
| "minimum-${RELEASE_TAG}-foss.apk.sha256" \ | |
| "minimum-provisioning-${RELEASE_TAG}.zip" \ | |
| "minimum-provisioning-${RELEASE_TAG}.zip.sha256" \ | |
| --verify-tag --notes-file "$RUNNER_TEMP/release-notes.md" --title "Minimum $RELEASE_TAG" "${prerelease_flag[@]}" |