Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fad937c23 |
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user