ADFA-2602: Keep the asset zips on Google Drive, not R2 #33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Print release signing certificate fingerprint | |
| # Emits the SHA-256 certificate fingerprint of the release signing key, plus a | |
| # ready-to-deploy Digital Asset Links file for Android App Link verification. | |
| # | |
| # The keystore only exists inside CI: SigningKeyUtils.downloadSigningKey() | |
| # base64-decodes IDE_SIGNING_KEY_BIN into build/signing/signing-key.jks. This | |
| # workflow decodes the same secret directly, so no Gradle build is needed. | |
| # | |
| # A certificate fingerprint is public data - it is published in assetlinks.json. | |
| # No private key material is printed. | |
| # | |
| # With deploy=true the file is written to the "well-known" R2 bucket at key | |
| # .well-known/assetlinks.json. The Worker in infra/well-known-worker serves that | |
| # bucket at https://<host>/.well-known/assetlinks.json, deriving the object key | |
| # from the request path, so the key has to match the path exactly. The Worker is | |
| # deployed by deploy-well-known-worker.yml; this workflow owns only the object. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| hosts: | |
| description: 'Hosts serving assetlinks.json (comma-separated). Drives the deploy checklist and, when deploy is enabled, which hosts are verified. The file itself is host-independent.' | |
| required: false | |
| default: 'appdevforall.org,www.appdevforall.org' | |
| extra_fingerprints: | |
| description: 'Additional SHA-256 fingerprints to include (comma-separated, colon-hex). Use for developer debug keys when testing App Links locally.' | |
| required: false | |
| default: '' | |
| deploy: | |
| description: 'Upload the generated file to R2 and verify it is served. Leave off to only produce the artifact.' | |
| type: boolean | |
| required: false | |
| default: false | |
| # Dispatch is only offered for workflows on the default branch, so run on push | |
| # of this file to let it work from a feature branch before it reaches stage. | |
| push: | |
| paths: | |
| - '.github/workflows/signing-fingerprint.yml' | |
| permissions: | |
| contents: read | |
| jobs: | |
| fingerprint: | |
| name: Fingerprint release signing key | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| IDE_SIGNING_ALIAS: ${{ secrets.IDE_SIGNING_ALIAS }} | |
| IDE_SIGNING_STORE_PASS: ${{ secrets.IDE_SIGNING_STORE_PASS }} | |
| IDE_SIGNING_KEY_BIN: ${{ secrets.IDE_SIGNING_KEY_BIN }} | |
| R2_BUCKET: well-known | |
| R2_KEY: .well-known/assetlinks.json | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up JDK | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: '17' | |
| - name: Materialize keystore from IDE_SIGNING_KEY_BIN | |
| run: | | |
| set -euo pipefail | |
| # The runner context is step-scoped, so derive these here rather than in | |
| # the job-level env block, where ${{ runner.temp }} would be empty. | |
| echo "KEYSTORE=$RUNNER_TEMP/signing-key.jks" >> "$GITHUB_ENV" | |
| echo "ASSETLINKS=$RUNNER_TEMP/assetlinks.json" >> "$GITHUB_ENV" | |
| KEYSTORE="$RUNNER_TEMP/signing-key.jks" | |
| for var in IDE_SIGNING_KEY_BIN IDE_SIGNING_ALIAS IDE_SIGNING_STORE_PASS; do | |
| if [ -z "${!var:-}" ]; then | |
| echo "ERROR: $var is not set. Check the repository secrets." >&2 | |
| exit 1 | |
| fi | |
| done | |
| # Keystore lives outside the workspace so it cannot be picked up by an | |
| # artifact upload or a later checkout. | |
| printf '%s' "$IDE_SIGNING_KEY_BIN" | base64 -d > "$KEYSTORE" | |
| chmod 600 "$KEYSTORE" | |
| if [ ! -s "$KEYSTORE" ]; then | |
| echo "ERROR: decoded keystore is empty - IDE_SIGNING_KEY_BIN is not valid base64." >&2 | |
| exit 1 | |
| fi | |
| echo "Decoded keystore: $(stat -c %s "$KEYSTORE") bytes" | |
| - name: Extract SHA-256 fingerprint | |
| id: fp | |
| run: | | |
| set -euo pipefail | |
| if ! keytool -list -v \ | |
| -keystore "$KEYSTORE" \ | |
| -storepass "$IDE_SIGNING_STORE_PASS" \ | |
| -alias "$IDE_SIGNING_ALIAS" > "$RUNNER_TEMP/keytool.txt" 2>&1; then | |
| echo "ERROR: keytool failed. Alias in IDE_SIGNING_ALIAS may not match the keystore." >&2 | |
| sed 's/^/ /' "$RUNNER_TEMP/keytool.txt" >&2 | |
| echo "Entries present in the keystore:" >&2 | |
| keytool -list -keystore "$KEYSTORE" -storepass "$IDE_SIGNING_STORE_PASS" \ | |
| | grep -E 'Entry|entry' >&2 || true | |
| exit 1 | |
| fi | |
| fingerprint=$(awk '/SHA256:/ { print $2; exit }' "$RUNNER_TEMP/keytool.txt") | |
| if [ -z "$fingerprint" ]; then | |
| echo "ERROR: no SHA256 line in keytool output." >&2 | |
| exit 1 | |
| fi | |
| # Cross-check via OpenSSL against the exported certificate. A wrong | |
| # fingerprint fails App Link verification silently, so verify it twice. | |
| openssl_fp=$(keytool -exportcert -rfc \ | |
| -keystore "$KEYSTORE" \ | |
| -storepass "$IDE_SIGNING_STORE_PASS" \ | |
| -alias "$IDE_SIGNING_ALIAS" \ | |
| | openssl x509 -noout -fingerprint -sha256 \ | |
| | cut -d= -f2) | |
| if [ "$fingerprint" != "$openssl_fp" ]; then | |
| echo "ERROR: keytool and openssl disagree:" >&2 | |
| echo " keytool: $fingerprint" >&2 | |
| echo " openssl: $openssl_fp" >&2 | |
| exit 1 | |
| fi | |
| echo "SHA-256: $fingerprint" | |
| echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" | |
| # Certificate identity, useful for confirming this is the key you expect. | |
| grep -E '^(Owner|Issuer|Valid from):' "$RUNNER_TEMP/keytool.txt" || true | |
| - name: Resolve application ID | |
| id: pkg | |
| run: | | |
| set -euo pipefail | |
| config=composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/BuildConfig.kt | |
| pkg=$(sed -n 's/.*PACKAGE_NAME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -1) | |
| if [ -z "$pkg" ]; then | |
| echo "ERROR: could not read PACKAGE_NAME from $config" >&2 | |
| exit 1 | |
| fi | |
| echo "Application ID: $pkg" | |
| echo "package=$pkg" >> "$GITHUB_OUTPUT" | |
| - name: Generate assetlinks.json | |
| env: | |
| PKG: ${{ steps.pkg.outputs.package }} | |
| FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} | |
| EXTRA: ${{ inputs.extra_fingerprints }} | |
| run: | | |
| set -euo pipefail | |
| # Normalize to uppercase colon-separated hex, drop blanks and duplicates. | |
| # Strip spaces/tabs/CR only - deleting newlines here would splice the | |
| # fingerprints into one unmatchable string. | |
| fingerprints=$(printf '%s,%s' "$FINGERPRINT" "$EXTRA" \ | |
| | tr ',' '\n' \ | |
| | tr -d ' \t\r' \ | |
| | tr '[:lower:]' '[:upper:]' \ | |
| | grep -E '^([0-9A-F]{2}:){31}[0-9A-F]{2}$' \ | |
| | awk '!seen[$0]++' \ | |
| | jq -R . | jq -s .) | |
| jq -n --arg pkg "$PKG" --argjson fps "$fingerprints" '[ | |
| { | |
| relation: ["delegate_permission/common.handle_all_urls"], | |
| target: { | |
| namespace: "android_app", | |
| package_name: $pkg, | |
| sha256_cert_fingerprints: $fps | |
| } | |
| } | |
| ]' > "$ASSETLINKS" | |
| cat "$ASSETLINKS" | |
| - name: Validate assetlinks.json | |
| env: | |
| PKG: ${{ steps.pkg.outputs.package }} | |
| run: | | |
| set -euo pipefail | |
| # A malformed or mismatched file fails App Link verification silently on | |
| # device, so assert the full shape here rather than discover it later. | |
| jq -e 'type == "array" and length == 1' "$ASSETLINKS" > /dev/null \ | |
| || { echo "ERROR: expected a single-entry array." >&2; exit 1; } | |
| jq -e --arg pkg "$PKG" ' | |
| .[0] as $e | |
| | ($e.relation | index("delegate_permission/common.handle_all_urls")) != null | |
| and $e.target.namespace == "android_app" | |
| and $e.target.package_name == $pkg | |
| and ($e.target.sha256_cert_fingerprints | length) >= 1 | |
| and ($e.target.sha256_cert_fingerprints | |
| | all(test("^([0-9A-F]{2}:){31}[0-9A-F]{2}$"))) | |
| ' "$ASSETLINKS" > /dev/null \ | |
| || { echo "ERROR: entry does not describe $PKG with valid SHA-256 fingerprints." >&2; exit 1; } | |
| echo "Validated: $(jq -r '.[0].target.sha256_cert_fingerprints | length' "$ASSETLINKS") fingerprint(s) for $PKG" | |
| - name: Upload assetlinks.json | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: assetlinks | |
| path: ${{ env.ASSETLINKS }} | |
| if-no-files-found: error | |
| - name: Deploy assetlinks.json to R2 | |
| if: github.event_name == 'workflow_dispatch' && inputs.deploy | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} | |
| AWS_DEFAULT_REGION: auto | |
| R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} | |
| # AWS CLI v2 sends CRC32 integrity headers by default, which R2 rejects | |
| # with "Header 'x-amz-checksum-algorithm' ... not implemented". | |
| AWS_REQUEST_CHECKSUM_CALCULATION: when_required | |
| AWS_RESPONSE_CHECKSUM_VALIDATION: when_required | |
| run: | | |
| set -euo pipefail | |
| # The Worker derives the object key from the request path, so the key | |
| # must stay ".well-known/assetlinks.json". | |
| aws s3 cp "$ASSETLINKS" "s3://$R2_BUCKET/$R2_KEY" \ | |
| --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ | |
| --content-type application/json | |
| - name: Verify the deployed file is served | |
| if: github.event_name == 'workflow_dispatch' && inputs.deploy | |
| env: | |
| HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} | |
| run: | | |
| set -euo pipefail | |
| expected=$(jq -S -c . "$ASSETLINKS") | |
| failed=0 | |
| for host in ${HOSTS//,/ }; do | |
| url="https://$host/$R2_KEY" | |
| hdr="$RUNNER_TEMP/hdr" body="$RUNNER_TEMP/body" | |
| # Cloudflare can take a moment to pick up a freshly written object. | |
| served=0 | |
| for _ in 1 2 3 4 5; do | |
| if curl -fsS --max-time 20 -D "$hdr" -o "$body" "$url"; then | |
| served=1 | |
| break | |
| fi | |
| sleep 5 | |
| done | |
| if [ "$served" -ne 1 ]; then | |
| echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is the well-known Worker not being deployed or not routed for this host (see deploy-well-known-worker.yml)." >&2 | |
| failed=1 | |
| continue | |
| fi | |
| if [ "$(jq -S -c . < "$body")" != "$expected" ]; then | |
| echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the Worker route points elsewhere)." >&2 | |
| failed=1 | |
| continue | |
| fi | |
| ctype=$(tr -d '\r' < "$hdr" | awk -F': ' 'tolower($1) == "content-type" { print tolower($2) }' | tail -1) | |
| case "$ctype" in | |
| application/json*) | |
| echo "OK $url ($ctype)" | |
| ;; | |
| *) | |
| echo "FAIL $url served Content-Type '$ctype'; Digital Asset Links requires application/json." >&2 | |
| failed=1 | |
| ;; | |
| esac | |
| done | |
| exit "$failed" | |
| - name: Write job summary | |
| env: | |
| PKG: ${{ steps.pkg.outputs.package }} | |
| FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} | |
| HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} | |
| DEPLOYED: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Release signing certificate" | |
| echo | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Application ID | \`$PKG\` |" | |
| echo "| SHA-256 | \`$FINGERPRINT\` |" | |
| echo | |
| echo "### assetlinks.json" | |
| echo | |
| echo '```json' | |
| cat "$ASSETLINKS" | |
| echo '```' | |
| echo | |
| echo "### Deploy" | |
| echo | |
| if [ "$DEPLOYED" = "true" ]; then | |
| echo "Written to \`s3://$R2_BUCKET/$R2_KEY\` and confirmed served on:" | |
| echo | |
| for host in ${HOSTS//,/ }; do | |
| echo "- \`https://$host/$R2_KEY\`" | |
| done | |
| else | |
| echo "Not deployed. Re-run with **deploy** enabled, or publish the artifact by hand. The file is host-independent - the same bytes serve every host in the intent filter:" | |
| echo | |
| for host in ${HOSTS//,/ }; do | |
| echo "- \`https://$host/$R2_KEY\` - HTTPS, \`Content-Type: application/json\`, no redirect" | |
| done | |
| fi | |
| echo | |
| echo "### Verify on device" | |
| echo | |
| echo '```bash' | |
| for host in ${HOSTS//,/ }; do | |
| echo "curl -sSI https://$host/$R2_KEY # expect 200, application/json, no 3xx" | |
| done | |
| echo "adb shell pm verify-app-links --re-verify $PKG" | |
| echo "adb shell pm get-app-links $PKG" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Remove keystore | |
| if: always() | |
| run: | | |
| # KEYSTORE may be unset if the decode step failed before exporting it. | |
| ks="${KEYSTORE:-$RUNNER_TEMP/signing-key.jks}" | |
| shred -u "$ks" 2>/dev/null || rm -f "$ks" | |
| rm -f "$RUNNER_TEMP/keytool.txt" |