Compare commits

...
Author SHA1 Message Date
Bailey Dixon 0fad937c23 fix(release): reuse verified Android preflight artifacts 2026-07-26 17:24:56 -04:00
13 changed files with 1446 additions and 929 deletions
+2
View File
@@ -14,6 +14,8 @@ function classifyCiPaths(paths) {
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
'scripts/android-release-artifacts.py', 'scripts/test_android_release_artifacts.py',
'scripts/release-android.ps1',
'.github/workflows/play-preflight-android.yml',
'.github/workflows/approve-release-android.yml',
'.github/workflows/release-android.yml',
@@ -15,6 +15,9 @@ const none = {
assert.deepEqual(classifyCiPaths(['README.md']), none);
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/android-release-artifacts.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/test_android_release_artifacts.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/release-android.ps1']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
+45 -8
View File
@@ -5,6 +5,7 @@
# stable tag triggers Play submission first, then GitHub publication.
name: Approve Android Release
run-name: Approve Android ${{ inputs.version }}
on:
workflow_dispatch:
@@ -24,8 +25,9 @@ concurrency:
jobs:
approve:
name: Verify preflight and create release tag
name: Verify, tag, and observe public release
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
with:
@@ -56,13 +58,25 @@ jobs:
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
run: |
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
exit 1
fi
echo "Verified Play preflight proof: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "Verified successful Play preflight artifact: $ARTIFACT_NAME (run $RUN_ID)"
- name: Ensure release tag does not already exist
env:
@@ -83,18 +97,41 @@ jobs:
-f ref="refs/tags/android-v${VERSION}" \
-f sha="$GITHUB_SHA"
- name: Start the tag release workflow
- name: Start and observe the tag release workflow
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.metadata.outputs.version }}
run: |
DISPATCHED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
gh workflow run release-android.yml \
--ref=main \
-f version="$VERSION"
RUN_ID=""
for _ in $(seq 1 30); do
RUN_ID=$(gh run list \
--workflow=release-android.yml \
--event=workflow_dispatch \
--limit=20 \
--json databaseId,headSha,createdAt \
--jq ".[] | select(.headSha == \"$GITHUB_SHA\" and .createdAt >= \"$DISPATCHED_AT\") | .databaseId" \
| head -n 1)
if [ -n "$RUN_ID" ]; then
break
fi
sleep 2
done
if [ -z "$RUN_ID" ]; then
echo "::error::Release workflow dispatch succeeded but its run could not be located"
exit 1
fi
echo "Observing Release Android run ${RUN_ID}"
gh run watch "$RUN_ID" --exit-status --interval 15
- name: Approval summary
run: |
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
echo "## Android release complete" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
echo "The immutable release workflow completed successfully: it promoted the preflighted Play draft and published the exact signed preflight artifacts." >> "$GITHUB_STEP_SUMMARY"
+6
View File
@@ -33,6 +33,9 @@ on:
- "scripts/check-android-locales.py"
- "scripts/android-locale-harness.py"
- "scripts/check-android-collection-apis.py"
- "scripts/android-release-artifacts.py"
- "scripts/test_android_release_artifacts.py"
- "scripts/release-android.ps1"
- ".github/workflows/ci-android.yml"
- ".github/workflows/play-preflight-android.yml"
- ".github/workflows/approve-release-android.yml"
@@ -73,6 +76,9 @@ jobs:
- name: Reject unsafe Android collection APIs
run: python3 scripts/check-android-collection-apis.py
- name: Validate immutable release artifact contract
run: python3 -m unittest scripts/test_android_release_artifacts.py
- name: Run Android lint
run: ./gradlew lint --console=plain
+78 -3
View File
@@ -12,6 +12,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
actions: read
contents: read
pull-requests: read
@@ -30,6 +31,7 @@ jobs:
dashboard: ${{ steps.filter.outputs.dashboard }}
contract: ${{ steps.filter.outputs.contract }}
docs: ${{ steps.filter.outputs.docs }}
release_pr: ${{ steps.release.outputs.release_pr }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -39,6 +41,21 @@ jobs:
- name: Test path classifier
run: node .github/scripts/classify-ci-paths.test.cjs
- name: Detect canonical release PR
id: release
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: |
if [ "$BASE_REF" = "main" ] &&
[ "$HEAD_REF" = "dev" ] &&
[ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then
echo "release_pr=true" >> "$GITHUB_OUTPUT"
else
echo "release_pr=false" >> "$GITHUB_OUTPUT"
fi
- name: Classify changed files
id: filter
uses: actions/github-script@v8
@@ -62,9 +79,66 @@ jobs:
android:
needs: changes
if: needs.changes.outputs.android == 'true'
if: needs.changes.outputs.android == 'true' && needs.changes.outputs.release_pr != 'true'
uses: ./.github/workflows/ci-android.yml
release-proof:
name: Verify preflighted release tree
needs: changes
if: needs.changes.outputs.release_pr == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout simulated release merge
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Require unchanged successful Play preflight
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_DEV_COMMIT: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
MERGE_TREE=$(git rev-parse 'HEAD^{tree}')
DEV_COMMIT="$EXPECTED_DEV_COMMIT"
DEV_TREE=$(git rev-parse "${DEV_COMMIT}^{tree}")
if [ "$MERGE_TREE" != "$DEV_TREE" ]; then
echo "::error::The dev-to-main merge changes the preflighted tree ($DEV_TREE -> $MERGE_TREE)"
echo "Rerun Play Preflight from the simulated merge result before approval."
exit 1
fi
VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
ARTIFACT_NAME="play-preflight-${VERSION}-${DEV_TREE}"
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$RUN_ID" ]; then
echo "::error::No Play preflight artifact exists for dev tree $DEV_TREE and version $VERSION"
exit 1
fi
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
PREFLIGHT_COMMIT=$(jq -r '.head_sha' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$PREFLIGHT_COMMIT" != "$DEV_COMMIT" ]; then
echo "::error::Preflight commit ${PREFLIGHT_COMMIT} does not match release dev commit ${DEV_COMMIT}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Play preflight run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "Verified release PR:"
echo " dev commit: $DEV_COMMIT"
echo " unchanged tree: $DEV_TREE"
echo " successful preflight run: $RUN_ID"
desktop:
needs: changes
if: needs.changes.outputs.desktop == 'true'
@@ -110,11 +184,12 @@ jobs:
guard:
name: Required checks
if: always()
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
needs: [changes, android, release-proof, desktop, plugin, dashboard, contract, docs]
runs-on: ubuntu-latest
env:
CHANGES_RESULT: ${{ needs.changes.result }}
ANDROID_RESULT: ${{ needs.android.result }}
RELEASE_PROOF_RESULT: ${{ needs.release-proof.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
PLUGIN_RESULT: ${{ needs.plugin.result }}
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
@@ -125,7 +200,7 @@ jobs:
shell: bash
run: |
failed=0
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
for check in CHANGES ANDROID RELEASE_PROOF DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
result_var="${check}_RESULT"
result="${!result_var}"
echo "$check: $result"
+66 -19
View File
@@ -2,10 +2,10 @@
#
# Run manually from the final dev or untagged main tree before creating
# android-v*. The job
# builds the same signed release artifacts, scans final DEX, and uploads the
# Google Play bundle as a production DRAFT. A successful upload is the automated
# Play gate while no public GitHub Release or sideload APK exists. Console-only
# pre-review and pre-launch reports are informational and do not block release.
# builds the signed release artifacts once, scans final DEX, and uploads the
# Google Play bundle as a production DRAFT. The signed APK/AAB and their manifest
# remain private Actions artifacts until approval publishes those exact bytes.
# Console-only pre-review and pre-launch reports are informational.
name: Play Preflight — Android
@@ -25,6 +25,51 @@ concurrency:
cancel-in-progress: false
jobs:
quality:
name: Validate release quality
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
- name: Validate translation catalogs and collection APIs
run: |
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m unittest scripts/test_android_release_artifacts.py
- name: Run strict Android lint and focused tests
run: |
./gradlew lint :app:testSideloadDebugUnitTest \
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
--tests com.hermesandroid.relay.util.ServerAddressTest \
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
--tests com.hermesandroid.relay.data.AppLanguageTest \
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
--console=plain
preflight:
name: Build and upload private Play draft
runs-on: ubuntu-latest
@@ -121,25 +166,27 @@ jobs:
--resolution-strategy=ignore \
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }}"
- name: Record successful preflight for the exact commit
- name: Package immutable preflight artifacts
run: |
mkdir -p app/build/reports
cat > app/build/reports/play-preflight.json <<EOF
{
"version": "${{ steps.metadata.outputs.version }}",
"versionCode": "${{ steps.metadata.outputs.version_code }}",
"commit": "$GITHUB_SHA",
"tree": "${{ steps.metadata.outputs.tree }}",
"track": "production",
"status": "draft"
}
EOF
python3 scripts/android-release-artifacts.py package \
--version "${{ steps.metadata.outputs.version }}" \
--version-code "${{ steps.metadata.outputs.version_code }}" \
--commit "$GITHUB_SHA" \
--tree "${{ steps.metadata.outputs.tree }}" \
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
--output app/build/preflight-release
python3 scripts/android-release-artifacts.py verify \
--version "${{ steps.metadata.outputs.version }}" \
--version-code "${{ steps.metadata.outputs.version_code }}" \
--tree "${{ steps.metadata.outputs.tree }}" \
--directory app/build/preflight-release
- name: Upload preflight proof
- name: Upload private signed artifacts and proof
uses: actions/upload-artifact@v7
with:
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
path: app/build/reports/play-preflight.json
path: app/build/preflight-release/
if-no-files-found: error
retention-days: 30
@@ -152,4 +199,4 @@ jobs:
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
echo "The signed build, DEX scan, strict quality gate, and Play draft upload passed. The private artifact contains the exact APK/AAB bytes that approval will publish. Ensure this release tree is on main, then run **Approve Android Release** from main." >> "$GITHUB_STEP_SUMMARY"
+75 -45
View File
@@ -2,10 +2,12 @@
#
# Triggered when an Android release tag (android-v*) is pushed.
# Validates the tag matches the app version in libs.versions.toml,
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
# GitHub Release. Server/Python package releases use server-v* tags.
# reuses the exact private APK/AAB that passed stable Play preflight, and creates
# a GitHub Release only after Play accepts the production promotion. Prerelease
# tags still build fresh artifacts because they do not use the Play preflight.
name: Release Android
run-name: Release Android ${{ inputs.version != '' && inputs.version || github.ref_name }}
on:
push:
@@ -34,6 +36,8 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
version_code: ${{ steps.version.outputs.version_code }}
release_tree: ${{ steps.version.outputs.release_tree }}
preflight_artifact_id: ${{ steps.preflight.outputs.artifact_id }}
steps:
- uses: actions/checkout@v7
with:
@@ -58,6 +62,7 @@ jobs:
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
echo "release_tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- name: Verify version sync
run: |
@@ -92,25 +97,41 @@ jobs:
fi
- name: Require successful Play preflight for this exact release tree
id: preflight
if: ${{ !contains(steps.version.outputs.version, '-') }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
RELEASE_TREE: ${{ steps.version.outputs.release_tree }}
run: |
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | length')
if [ "$COUNT" -lt 1 ]; then
ARTIFACT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
--jq '[.artifacts[] | select(.expired == false)] | sort_by(.created_at) | last')
ARTIFACT_ID=$(jq -r '.id // empty' <<<"$ARTIFACT")
RUN_ID=$(jq -r '.workflow_run.id // empty' <<<"$ARTIFACT")
if [ -z "$ARTIFACT_ID" ] || [ -z "$RUN_ID" ]; then
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
echo "Run Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
exit 1
fi
echo "Play preflight proof found: $ARTIFACT_NAME"
RUN=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}")
CONCLUSION=$(jq -r '.conclusion' <<<"$RUN")
WORKFLOW_PATH=$(jq -r '.path' <<<"$RUN")
if [ "$WORKFLOW_PATH" != ".github/workflows/play-preflight-android.yml" ]; then
echo "::error::Artifact came from unexpected workflow ${WORKFLOW_PATH}"
exit 1
fi
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Preflight workflow run ${RUN_ID} concluded ${CONCLUSION}"
exit 1
fi
echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT"
echo "Play preflight artifact verified: $ARTIFACT_NAME (run $RUN_ID, artifact $ARTIFACT_ID)"
ci:
name: CI Checks
name: CI Checks (prerelease only)
needs: validate
if: ${{ contains(needs.validate.outputs.version, '-') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -149,6 +170,7 @@ jobs:
release:
name: Build & Publish Release
needs: [validate, ci]
if: ${{ always() && needs.validate.result == 'success' && (needs.ci.result == 'success' || needs.ci.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -167,54 +189,61 @@ jobs:
with:
cache-read-only: false
- name: Decode release keystore
- name: Download exact stable preflight artifacts
if: ${{ !contains(needs.validate.outputs.version, '-') }}
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_ID: ${{ needs.validate.outputs.preflight_artifact_id }}
run: |
mkdir -p app/build/release-artifacts
gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/preflight.zip"
python3 scripts/android-release-artifacts.py extract \
--archive "$RUNNER_TEMP/preflight.zip" \
--output app/build/release-artifacts
- name: Verify exact stable preflight artifacts
if: ${{ !contains(needs.validate.outputs.version, '-') }}
run: |
python3 scripts/android-release-artifacts.py verify \
--version "${{ needs.validate.outputs.version }}" \
--version-code "${{ needs.validate.outputs.version_code }}" \
--tree "${{ needs.validate.outputs.release_tree }}" \
--directory app/build/release-artifacts
- name: Decode release keystore for prerelease build
env:
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
if: env.HERMES_KEYSTORE_BASE64 != ''
if: ${{ contains(needs.validate.outputs.version, '-') && env.HERMES_KEYSTORE_BASE64 != '' }}
run: |
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
- name: Build release artifacts (APK + AAB)
- name: Build prerelease artifacts
if: ${{ contains(needs.validate.outputs.version, '-') }}
env:
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
# `assembleRelease` and `bundleRelease` are flavor-wide task aliases
# (added by `flavorDimensions += "track"` in app/build.gradle.kts), so
# this one line builds ALL four artifacts at once. Filenames come from
# `archivesName` (set in app/build.gradle.kts) which injects the app
# version, so `<version>` below is `libs.versions.appVersionName`:
# app/build/outputs/apk/googlePlay/release/hermes-relay-<version>-googlePlay-release.apk
# app/build/outputs/apk/sideload/release/hermes-relay-<version>-sideload-release.apk
# app/build/outputs/bundle/googlePlayRelease/hermes-relay-<version>-googlePlay-release.aab
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
run: ./gradlew bundleRelease assembleRelease --console=plain
- name: Scan release DEX for unsupported collection APIs
- name: Scan prerelease DEX
if: ${{ contains(needs.validate.outputs.version, '-') }}
run: |
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
- name: List produced artifacts (debug aid)
- name: Package prerelease artifacts
if: ${{ contains(needs.validate.outputs.version, '-') }}
run: |
echo "=== APK outputs ==="
find app/build/outputs/apk -name '*.apk' -print 2>/dev/null || true
echo "=== AAB outputs ==="
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
- name: Generate checksums
# Flavor dimension adds an extra path segment to the AGP output layout.
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
# (note the concatenated camelCase — AGP path quirk, documented but
# different between APK and AAB). Checksums cover EXACTLY the files
# attached to the GitHub Release (see the 2-asset policy on the
# release step below) so SHA256SUMS.txt matches the assets 1:1.
run: |
cd app/build/outputs
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
cat SHA256SUMS.txt
python3 scripts/android-release-artifacts.py package \
--version "${{ needs.validate.outputs.version }}" \
--version-code "${{ needs.validate.outputs.version_code }}" \
--commit "$(git rev-parse HEAD)" \
--tree "${{ needs.validate.outputs.release_tree }}" \
--sideload-apk app/build/outputs/apk/sideload/release/*.apk \
--google-play-aab app/build/outputs/bundle/googlePlayRelease/*.aab \
--output app/build/release-artifacts
- name: Require Play credentials for stable release
env:
@@ -252,9 +281,9 @@ jobs:
# Deliberate 2-asset policy (#144): attach ONLY the installable
# sideload APK and Play AAB, plus checksums covering those files.
files: |
app/build/outputs/apk/sideload/release/*.apk
app/build/outputs/bundle/googlePlayRelease/*.aab
app/build/outputs/SHA256SUMS.txt
app/build/release-artifacts/*.apk
app/build/release-artifacts/*.aab
app/build/release-artifacts/SHA256SUMS.txt
- name: Release summary
env:
@@ -262,7 +291,9 @@ jobs:
run: |
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
if [[ "${{ needs.validate.outputs.version }}" != *-* ]]; then
echo "✅ **Published the exact signed Play-preflight artifacts**" >> "$GITHUB_STEP_SUMMARY"
elif [ -n "$HERMES_KEYSTORE_BASE64" ]; then
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
else
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
@@ -270,6 +301,5 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Artifacts" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
find app/build/release-artifacts -maxdepth 1 -type f -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
+16
View File
@@ -1,5 +1,21 @@
# Hermes-Relay — Dev Log
## 2026-07-26 — Android release fast path
Stable Android preflight now retains the exact signed APK and Play AAB with a
tree-bound manifest, sizes, and SHA-256 checksums. The release PR verifies that
its simulated merge tree is unchanged and that the originating preflight run
succeeded, while final publication downloads and verifies those private bytes
instead of rebuilding them. Approval observes the immutable-tag release through
Play promotion and GitHub publication.
A PowerShell orchestrator provides the prepared-`dev` to Production path with a
dry run and optional configuration-preserving phone deployment. The canonical
release guide was reduced to policy and executable steps; Android account
setup, emergency Play recovery, track selection, signing behavior, and
troubleshooting moved to `docs/release/android-operations.md`. No application
version changed for this release-engineering update.
## 2026-07-26 — Android 1.5.1 patch reconciliation
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
+269 -854
View File
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
# Android release operations
Setup, fallback, and troubleshooting reference for the canonical Android
release process in [RELEASE.md](../../RELEASE.md).
The normal stable path is:
```powershell
pwsh scripts/release-android.ps1 -Version X.Y.Z
```
The procedures below are initial setup or emergency recovery, not the normal
release checklist.
## Release signing
Generate and protect a dedicated Android release keystore. Never commit the
keystore, its Base64 representation, passwords, service-account JSON, or local
secret files.
Store these GitHub Actions secrets:
| Secret | Value |
|---|---|
| `HERMES_KEYSTORE_BASE64` | Base64-encoded release keystore |
| `HERMES_KEYSTORE_PASSWORD` | Keystore password |
| `HERMES_KEY_ALIAS` | Release-key alias |
| `HERMES_KEY_PASSWORD` | Release-key password |
PowerShell encoding example:
```powershell
[Convert]::ToBase64String(
[IO.File]::ReadAllBytes('release.keystore')
) | Set-Content -Encoding ascii release.keystore.b64
```
Delete the temporary Base64 file after storing the secret.
Google Play App Signing manages the store-distributed signing certificate.
The repository keystore is the upload/release certificate and must remain
stable across releases.
## Play Developer API
`PLAY_SERVICE_ACCOUNT_JSON` is required for stable automated releases.
One-time setup:
1. Enable the Google Play Android Developer API in a Google Cloud project.
2. Create a dedicated service account and JSON key.
3. Grant that account the minimum Play Console application permissions needed
to view the app, create and edit Production releases, and use Play App
Signing.
4. Store the complete JSON document as the GitHub Actions secret
`PLAY_SERVICE_ACCOUNT_JSON`.
5. Confirm **Play Preflight — Android** can create a Production draft.
The service account is not optional for the stable workflow. Manual Console
upload is an emergency fallback only.
## Managed Publishing
For unattended releases, keep Play Console Managed Publishing disabled.
- Disabled: `completed` submits the production change; availability follows
Google review and propagation.
- Enabled: an approved change remains under **Changes ready to publish** until
a Console operator publishes it.
The Play Developer API reports acceptance of the edit; review reports and
storefront propagation remain asynchronous.
## Track intent
- **Production**: stable `android-vX.Y.Z` releases.
- **Open testing / beta**: intentional public beta.
- **Closed testing / alpha**: intentional private beta.
- **Internal**: throwaway test distribution.
Tracks are options, not a mandatory ladder. Stable releases go directly to
Production unless the release plan explicitly says otherwise.
## Emergency manual Play recovery
Use this only when the automated workflow is unavailable and the release owner
has explicitly approved a manual recovery.
To upload the prepared Google Play bundle as a Production draft:
```powershell
.\gradlew.bat publishGooglePlayReleaseBundle `
--track=production `
--release-status=draft `
--resolution-strategy=ignore `
--release-name='Hermes-Relay X.Y.Z'
```
To promote an existing Production draft:
```powershell
.\gradlew.bat promoteGooglePlayReleaseArtifact `
--update=production `
--version-code=N `
--release-status=completed `
--release-name='Hermes-Relay X.Y.Z'
```
Both commands require `play-service-account.json` in the configured local
location. Remove the file after use.
If API automation is unavailable, the final fallback is Play Console:
1. Open **Release → Production**.
2. Create or edit the release.
3. Upload the `-googlePlay-release.aab`.
4. Use the locale-specific files under
`app/src/googlePlay/play/release-notes/` for What's New.
5. Review and start the rollout.
Record the exact versionCode and resulting Play status in the release issue.
## Private preflight artifact
Stable preflight stores a private Actions artifact named:
```text
play-preflight-X.Y.Z-<git-tree>
```
Retention is 30 days. The release workflow rejects an expired artifact, an
artifact from a failed run, a tree/version mismatch, unexpected files, or any
size/SHA-256 mismatch.
If the artifact expires before approval, rerun preflight from the unchanged
source tree. Never recreate the artifact locally and upload it under the proof
name.
## Phone signing behavior
Android will not update an installed package when the signing certificate
changes.
- Public sideload releases use the release key.
- Rapid development builds use the development/debug key.
- Both currently use `com.axiomlabs.hermesrelay.sideload`.
Therefore, a public release APK cannot update a development-signed installation
without uninstalling it and erasing app data. The orchestrator's `-DeployPhone`
option preserves configuration by installing a development-signed build from
the exact release source tree. Use a release-signed installation when the goal
is to test the downloadable public APK itself.
Never automatically uninstall the app as a signing workaround.
## Troubleshooting
### No successful preflight artifact
Confirm:
- preflight ran from the final `dev` or untagged `main` tree;
- requested version matches `appVersionName`;
- Play draft upload succeeded;
- both preflight jobs concluded successfully;
- the artifact has not expired;
- the release PR merge tree is unchanged.
### Version code already used
Increase `appVersionCode` with `scripts/bump-android-version.sh`. Play version
codes are permanent and cannot be reused even when a draft is discarded.
### Play rejects signing
Verify all keystore secrets exist and inspect the artifact certificate. Stable
preflight intentionally fails when `HERMES_KEYSTORE_BASE64` is absent; it never
falls back to a debug-signed Play upload.
### Approval succeeded but publication failed
Approval now waits for the release workflow and inherits its failure. Inspect
the linked **Release Android** run. If workflow code—not application content—is
the problem, repair the workflow on `main`, dispatch it for the existing
version, and keep every job checked out at the immutable tag. Never move the
tag.
### Release PR still runs the old full Android matrix
`pull_request` workflows use the definition on the PR base branch. The first
release carrying the fast path may therefore run the older `main` workflow.
After that release lands, canonical `dev` → `main` release PRs use the
preflight-proof job.
### Phone install reports `INSTALL_FAILED_UPDATE_INCOMPATIBLE`
The installed app and APK have different signing certificates. Use the
orchestrator's compatible `-DeployPhone` path, use a release-signed test
installation, or explicitly export configuration before an approved clean
install. Do not silently uninstall.
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Package and verify the immutable Android Play-preflight artifact set."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
import zipfile
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
MANIFEST_NAME = "play-preflight.json"
CHECKSUMS_NAME = "SHA256SUMS.txt"
EXPECTED_ROLES = {
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
}
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
class ArtifactError(ValueError):
"""Raised when a release artifact set violates its contract."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_artifact_name(name: str) -> str:
candidate = Path(name)
if candidate.name != name or name in {"", ".", ".."}:
raise ArtifactError(f"Unsafe artifact filename: {name!r}")
return name
def expected_checksum_text(artifacts: list[dict[str, Any]]) -> str:
lines = [
f"{artifact['sha256']} {artifact['name']}"
for artifact in sorted(artifacts, key=lambda item: item["name"])
]
return "\n".join(lines) + "\n"
def package_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
raise ArtifactError(f"Invalid commit id: {args.commit!r}")
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"Invalid tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"Invalid versionCode: {args.version_code!r}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"Output directory must be empty: {output}")
sources = {
"sideload-apk": args.sideload_apk.resolve(),
"google-play-aab": args.google_play_aab.resolve(),
}
artifacts: list[dict[str, Any]] = []
for role, source in sources.items():
if not source.is_file():
raise ArtifactError(f"Missing {role}: {source}")
expected_name = EXPECTED_ROLES[role].format(version=args.version)
if source.name != expected_name:
raise ArtifactError(
f"{role} must be named {expected_name}, got {source.name}"
)
destination = output / safe_artifact_name(source.name)
shutil.copy2(source, destination)
artifacts.append(
{
"role": role,
"name": destination.name,
"sha256": sha256(destination),
"size": destination.stat().st_size,
}
)
manifest = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"commit": args.commit,
"tree": args.tree,
"track": "production",
"status": "draft",
"artifacts": sorted(artifacts, key=lambda item: item["role"]),
}
(output / MANIFEST_NAME).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(output / CHECKSUMS_NAME).write_text(
expected_checksum_text(artifacts),
encoding="utf-8",
newline="\n",
)
print(json.dumps(manifest, sort_keys=True))
def load_manifest(directory: Path) -> dict[str, Any]:
manifest_path = directory / MANIFEST_NAME
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ArtifactError(f"Missing {MANIFEST_NAME} in {directory}") from exc
except json.JSONDecodeError as exc:
raise ArtifactError(f"Invalid {MANIFEST_NAME}: {exc}") from exc
if not isinstance(manifest, dict):
raise ArtifactError(f"{MANIFEST_NAME} must contain a JSON object")
return manifest
def extract_archive(args: argparse.Namespace) -> None:
archive_path = args.archive.resolve()
if not archive_path.is_file():
raise ArtifactError(f"Artifact archive does not exist: {archive_path}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"Output directory must be empty: {output}")
seen: set[str] = set()
try:
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir():
raise ArtifactError(
f"Artifact archive contains a directory: {info.filename!r}"
)
name = safe_artifact_name(info.filename)
if name in seen:
raise ArtifactError(f"Duplicate archive entry: {name}")
unix_type = (info.external_attr >> 16) & 0o170000
if unix_type == 0o120000:
raise ArtifactError(f"Artifact archive contains a symlink: {name}")
seen.add(name)
with archive.open(info) as source, (output / name).open("wb") as target:
shutil.copyfileobj(source, target)
except zipfile.BadZipFile as exc:
raise ArtifactError(f"Invalid artifact ZIP: {exc}") from exc
def verify_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"Invalid expected tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"Invalid expected versionCode: {args.version_code!r}")
directory = args.directory.resolve()
if not directory.is_dir():
raise ArtifactError(f"Artifact directory does not exist: {directory}")
manifest = load_manifest(directory)
commit = manifest.get("commit")
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
raise ArtifactError(f"Manifest commit is invalid: {commit!r}")
expected_metadata = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"tree": args.tree,
"track": "production",
"status": "draft",
}
for key, expected in expected_metadata.items():
actual = manifest.get(key)
if str(actual) != str(expected):
raise ArtifactError(
f"Manifest {key} mismatch: expected {expected!r}, got {actual!r}"
)
entries = manifest.get("artifacts")
if not isinstance(entries, list):
raise ArtifactError("Manifest artifacts must be a list")
by_role: dict[str, dict[str, Any]] = {}
for entry in entries:
if not isinstance(entry, dict):
raise ArtifactError("Every manifest artifact must be an object")
role = entry.get("role")
if role not in EXPECTED_ROLES:
raise ArtifactError(f"Unexpected artifact role: {role!r}")
if role in by_role:
raise ArtifactError(f"Duplicate artifact role: {role}")
by_role[role] = entry
if set(by_role) != set(EXPECTED_ROLES):
missing = sorted(set(EXPECTED_ROLES) - set(by_role))
raise ArtifactError(f"Missing artifact roles: {', '.join(missing)}")
verified: list[dict[str, Any]] = []
for role, expected_pattern in EXPECTED_ROLES.items():
entry = by_role[role]
name = safe_artifact_name(str(entry.get("name", "")))
expected_name = expected_pattern.format(version=args.version)
if name != expected_name:
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
path = directory / name
if not path.is_file():
raise ArtifactError(f"Missing artifact file: {name}")
actual_hash = sha256(path)
if actual_hash != entry.get("sha256"):
raise ArtifactError(f"SHA-256 mismatch for {name}")
if path.stat().st_size != entry.get("size"):
raise ArtifactError(f"Size mismatch for {name}")
verified.append(entry)
checksum_path = directory / CHECKSUMS_NAME
try:
checksum_text = checksum_path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise ArtifactError(f"Missing {CHECKSUMS_NAME}") from exc
if checksum_text != expected_checksum_text(verified):
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
allowed_names = {
MANIFEST_NAME,
CHECKSUMS_NAME,
*(entry["name"] for entry in verified),
}
directory_entries = list(directory.iterdir())
non_files = sorted(path.name for path in directory_entries if not path.is_file())
if non_files:
raise ArtifactError(
"Artifact directory contains non-file entries: " + ", ".join(non_files)
)
actual_names = {path.name for path in directory_entries}
if actual_names != allowed_names:
unexpected = sorted(actual_names - allowed_names)
missing = sorted(allowed_names - actual_names)
details = []
if unexpected:
details.append(f"unexpected: {', '.join(unexpected)}")
if missing:
details.append(f"missing: {', '.join(missing)}")
raise ArtifactError("Artifact set mismatch (" + "; ".join(details) + ")")
print(json.dumps(manifest, sort_keys=True))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
package = subparsers.add_parser("package", help="Create a preflight artifact set")
package.add_argument("--version", required=True)
package.add_argument("--version-code", required=True)
package.add_argument("--commit", required=True)
package.add_argument("--tree", required=True)
package.add_argument("--sideload-apk", required=True, type=Path)
package.add_argument("--google-play-aab", required=True, type=Path)
package.add_argument("--output", required=True, type=Path)
package.set_defaults(func=package_artifacts)
extract = subparsers.add_parser(
"extract", help="Safely extract a downloaded Actions artifact"
)
extract.add_argument("--archive", required=True, type=Path)
extract.add_argument("--output", required=True, type=Path)
extract.set_defaults(func=extract_archive)
verify = subparsers.add_parser("verify", help="Verify a preflight artifact set")
verify.add_argument("--version", required=True)
verify.add_argument("--version-code", required=True)
verify.add_argument("--tree", required=True)
verify.add_argument("--directory", required=True, type=Path)
verify.set_defaults(func=verify_artifacts)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except ArtifactError as exc:
parser.error(str(exc))
return 0
if __name__ == "__main__":
sys.exit(main())
+285
View File
@@ -0,0 +1,285 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')]
[string]$Version,
[switch]$DeployPhone,
[string]$DeviceSerial,
[switch]$DryRun,
[string]$Repository = 'Codename-11/hermes-relay'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
Set-Location $RepoRoot
function Invoke-Captured {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
$output = & $Command @Arguments 2>&1
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed:`n$($output -join [Environment]::NewLine)"
}
return ($output -join [Environment]::NewLine).Trim()
}
function Invoke-Streaming {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE"
}
}
function Start-GitHubWorkflow {
param(
[Parameter(Mandatory = $true)][string]$Workflow,
[Parameter(Mandatory = $true)][string]$Ref,
[Parameter(Mandatory = $true)][hashtable]$Inputs
)
$arguments = @('workflow', 'run', $Workflow, '--repo', $Repository, '--ref', $Ref)
foreach ($entry in $Inputs.GetEnumerator() | Sort-Object Key) {
$arguments += @('-f', "$($entry.Key)=$($entry.Value)")
}
$output = Invoke-Captured -Command 'gh' -Arguments $arguments
if ($output -notmatch '/actions/runs/(?<id>\d+)') {
throw "GitHub did not return a workflow run URL:`n$output"
}
return $Matches.id
}
function Wait-GitHubRun {
param([Parameter(Mandatory = $true)][string]$RunId)
Invoke-Streaming -Command 'gh' -Arguments @(
'run', 'watch', $RunId,
'--repo', $Repository,
'--exit-status',
'--interval', '15'
)
}
function Require-CleanPreparedDev {
$branch = Invoke-Captured -Command 'git' -Arguments @('branch', '--show-current')
if ($branch -ne 'dev') {
throw "Android publication must start from dev; current branch is $branch"
}
$status = Invoke-Captured -Command 'git' -Arguments @('status', '--porcelain')
if ($status) {
throw "Working tree is not clean:`n$status"
}
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'dev', 'main', '--tags')
$head = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
$originDev = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/dev')
if ($head -ne $originDev) {
throw "Local dev ($head) does not match origin/dev ($originDev)"
}
$existingTag = & git ls-remote --tags origin "refs/tags/android-v$Version"
if ($LASTEXITCODE -ne 0) {
throw 'Unable to query existing Android tags'
}
if ($existingTag) {
throw "android-v$Version already exists"
}
}
function Require-ReleaseMetadata {
$versionsFile = Get-Content 'gradle/libs.versions.toml' -Raw
if ($versionsFile -notmatch 'appVersionName\s*=\s*"(?<version>[^"]+)"') {
throw 'Unable to read appVersionName from gradle/libs.versions.toml'
}
if ($Matches.version -ne $Version) {
throw "Requested version $Version does not match appVersionName $($Matches.version)"
}
if ($versionsFile -notmatch 'appVersionCode\s*=\s*"(?<code>\d+)"') {
throw 'Unable to read appVersionCode from gradle/libs.versions.toml'
}
$versionCode = $Matches.code
if (-not (Select-String -Path 'CHANGELOG.md' -Pattern "^## \[(Android )?$([regex]::Escape($Version))\]" -Quiet)) {
throw "CHANGELOG.md has no Android release heading for $Version"
}
return $versionCode
}
function Deploy-CompatiblePhoneBuild {
param([Parameter(Mandatory = $true)][string]$VersionCode)
$sdk = if ($env:ANDROID_HOME) {
$env:ANDROID_HOME
} else {
Join-Path $env:LOCALAPPDATA 'Android\Sdk'
}
$adb = Join-Path $sdk 'platform-tools\adb.exe'
if (-not (Test-Path -LiteralPath $adb)) {
throw "adb was not found at $adb"
}
$env:ANDROID_HOME = $sdk
$env:ANDROID_SDK_ROOT = $sdk
Invoke-Streaming -Command '.\gradlew.bat' -Arguments @(
':app:assembleSideloadDebug',
'--no-daemon',
'--console=plain'
)
$apk = Get-ChildItem 'app\build\outputs\apk\sideload\debug' -Filter "*-$Version-sideload-debug.apk" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $apk) {
throw "No compatible sideload debug APK was produced for $Version"
}
$adbArguments = @()
if ($DeviceSerial) {
$adbArguments += @('-s', $DeviceSerial)
}
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @('install', '-r', $apk.FullName))
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
'shell', 'am', 'force-stop', 'com.axiomlabs.hermesrelay.sideload'
))
Invoke-Streaming -Command $adb -Arguments ($adbArguments + @(
'shell', 'am', 'start', '-n',
'com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity'
))
$package = Invoke-Captured -Command $adb -Arguments ($adbArguments + @(
'shell', 'dumpsys', 'package', 'com.axiomlabs.hermesrelay.sideload'
))
if ($package -notmatch "versionCode=$([regex]::Escape($VersionCode))\b" -or
$package -notmatch "versionName=$([regex]::Escape($Version))-sideload\b") {
throw "Phone package verification failed for version $Version / code $VersionCode"
}
Write-Host "Phone verified at $Version-sideload (versionCode $VersionCode)."
}
$versionCode = Require-ReleaseMetadata
if ($DryRun) {
Write-Host "Dry run: Android $Version (versionCode $versionCode)"
Write-Host '1. Require clean origin/dev and an unused Android tag.'
Write-Host '2. Run and await Play Preflight from dev.'
Write-Host '3. Open or reuse the dev-to-main release PR, await checks, and merge.'
Write-Host '4. Verify the main tree equals the preflighted dev tree.'
Write-Host '5. Run and await Approve Android Release from main.'
Write-Host '6. Verify the immutable tag and GitHub release.'
if ($DeployPhone) {
Write-Host '7. Build/install the compatible sideload-debug APK without erasing phone data.'
}
exit 0
}
Invoke-Streaming -Command 'gh' -Arguments @('auth', 'status')
Require-CleanPreparedDev
$devCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD')
$devTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'HEAD^{tree}')
Write-Host "Starting Android $Version from dev $devCommit (tree $devTree)."
$preflightRun = Start-GitHubWorkflow -Workflow 'play-preflight-android.yml' -Ref 'dev' -Inputs @{
version = $Version
}
Write-Host "Play preflight run: https://github.com/$Repository/actions/runs/$preflightRun"
Wait-GitHubRun -RunId $preflightRun
$pullRequests = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'list',
'--repo', $Repository,
'--base', 'main',
'--head', 'dev',
'--state', 'open',
'--limit', '1',
'--json', 'number,url,headRefOid'
) | ConvertFrom-Json
if (-not $pullRequests) {
$body = @"
Promote the exact Play-preflighted Android $Version release tree from ``dev`` to ``main``.
- dev SHA: ``$devCommit``
- release tree: ``$devTree``
- Play preflight: https://github.com/$Repository/actions/runs/$preflightRun
Approval will verify this unchanged tree before creating ``android-v$Version``.
"@
$prUrl = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'create',
'--repo', $Repository,
'--base', 'main',
'--head', 'dev',
'--title', "release(android): promote android-v$Version",
'--body', $body
)
$prNumber = [regex]::Match($prUrl, '/pull/(?<number>\d+)').Groups['number'].Value
} else {
if ([string]$pullRequests[0].headRefOid -ne $devCommit) {
throw "Existing release PR head $($pullRequests[0].headRefOid) does not match preflighted dev $devCommit"
}
$prNumber = [string]$pullRequests[0].number
$prUrl = [string]$pullRequests[0].url
}
if (-not $prNumber) {
throw "Unable to determine release PR number from $prUrl"
}
Write-Host "Release PR: $prUrl"
Invoke-Streaming -Command 'gh' -Arguments @(
'pr', 'checks', $prNumber,
'--repo', $Repository,
'--watch',
'--fail-fast'
)
$checkedHead = Invoke-Captured -Command 'gh' -Arguments @(
'pr', 'view', $prNumber,
'--repo', $Repository,
'--json', 'headRefOid',
'--jq', '.headRefOid'
)
if ($checkedHead -ne $devCommit) {
throw "Release PR advanced to $checkedHead after preflight; rerun from the new dev tip"
}
Invoke-Streaming -Command 'gh' -Arguments @(
'pr', 'merge', $prNumber,
'--repo', $Repository,
'--merge'
)
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', 'main', '--tags')
$mainCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main')
$mainTree = Invoke-Captured -Command 'git' -Arguments @('rev-parse', 'origin/main^{tree}')
if ($mainTree -ne $devTree) {
throw "Merged main tree $mainTree does not match preflighted dev tree $devTree"
}
$approvalRun = Start-GitHubWorkflow -Workflow 'approve-release-android.yml' -Ref 'main' -Inputs @{
version = $Version
}
Write-Host "Approval run: https://github.com/$Repository/actions/runs/$approvalRun"
Wait-GitHubRun -RunId $approvalRun
Invoke-Streaming -Command 'git' -Arguments @('fetch', 'origin', '--tags')
$tagCommit = Invoke-Captured -Command 'git' -Arguments @('rev-parse', "android-v$Version")
if ($tagCommit -ne $mainCommit) {
throw "android-v$Version points to $tagCommit instead of main $mainCommit"
}
$release = Invoke-Captured -Command 'gh' -Arguments @(
'release', 'view', "android-v$Version",
'--repo', $Repository,
'--json', 'url,tagName,publishedAt'
) | ConvertFrom-Json
Write-Host "Published release: $($release.url)"
if ($DeployPhone) {
Deploy-CompatiblePhoneBuild -VersionCode $versionCode
}
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import importlib.util
import io
import json
import tempfile
import unittest
import zipfile
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
SCRIPT_PATH = Path(__file__).with_name("android-release-artifacts.py")
SPEC = importlib.util.spec_from_file_location("android_release_artifacts", SCRIPT_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class AndroidReleaseArtifactsTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.apk = self.root / "hermes-relay-1.5.2-sideload-release.apk"
self.aab = self.root / "hermes-relay-1.5.2-googlePlay-release.aab"
self.apk.write_bytes(b"apk payload")
self.aab.write_bytes(b"aab payload")
self.output = self.root / "release"
self.package_args = Namespace(
version="1.5.2",
version_code="35",
commit="a" * 40,
tree="b" * 40,
sideload_apk=self.apk,
google_play_aab=self.aab,
output=self.output,
)
self.verify_args = Namespace(
version="1.5.2",
version_code="35",
tree="b" * 40,
directory=self.output,
)
def tearDown(self) -> None:
self.temp.cleanup()
def test_package_and_verify_round_trip(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
MODULE.verify_artifacts(self.verify_args)
manifest = json.loads(
(self.output / MODULE.MANIFEST_NAME).read_text(encoding="utf-8")
)
self.assertEqual("1.5.2", manifest["version"])
self.assertEqual("35", manifest["versionCode"])
self.assertEqual(
{"google-play-aab", "sideload-apk"},
{artifact["role"] for artifact in manifest["artifacts"]},
)
def test_verify_rejects_modified_artifact(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
packaged_apk = self.output / self.apk.name
packaged_apk.write_bytes(b"tampered")
with self.assertRaisesRegex(MODULE.ArtifactError, "SHA-256 mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_verify_rejects_wrong_release_tree(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
self.verify_args.tree = "c" * 40
with self.assertRaisesRegex(MODULE.ArtifactError, "tree mismatch"):
MODULE.verify_artifacts(self.verify_args)
def test_package_rejects_wrong_extension(self) -> None:
self.package_args.sideload_apk = self.root / "not-an-apk.txt"
self.package_args.sideload_apk.write_text("no", encoding="utf-8")
with self.assertRaisesRegex(MODULE.ArtifactError, "must be named"):
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
def test_verify_rejects_unexpected_file(self) -> None:
with redirect_stdout(io.StringIO()):
MODULE.package_artifacts(self.package_args)
(self.output / "extra.bin").write_bytes(b"unexpected")
with self.assertRaisesRegex(MODULE.ArtifactError, "unexpected"):
MODULE.verify_artifacts(self.verify_args)
def test_extract_rejects_path_traversal(self) -> None:
archive = self.root / "artifact.zip"
with zipfile.ZipFile(archive, "w") as handle:
handle.writestr("../outside.txt", "unsafe")
with self.assertRaisesRegex(MODULE.ArtifactError, "Unsafe artifact filename"):
MODULE.extract_archive(
Namespace(archive=archive, output=self.root / "extracted")
)
self.assertFalse((self.root / "outside.txt").exists())
if __name__ == "__main__":
unittest.main()