From 79f6c5e55b71ac458479ceb3c3bcea2ae5f924cd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 11:19:20 -0400 Subject: [PATCH] ci: give Dependabot PRs a working test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub does not expose repository Actions secrets to `pull_request` runs opened by dependabot[bot] (Dependabot reads from its own secret store) or by forks, so every `secrets.*` in the CI job resolved to an empty string on those runs. The job died at the `timheuer/base64-to-file@v1` step with "encodedString value is not set" — before Gradle ever ran. Every dependency PR was permanently red, so a genuinely broken bump looked exactly like a healthy one and merges had to go through `--admin`. The `flipcash-tests` lane only runs `generateEmojiList flipcashTestDebug :apps:flipcash:app:lintDebug` — it compiles, unit-tests and lints, and never contacts Firebase, Bugsnag, Mixpanel or Coinbase — so it does not need real credentials. Fall back to committed placeholders when a secret is empty: - google-services.json: replace the third-party base64 action with a plain `base64 --decode`, falling back to .github/ci/google-services.placeholder.json. - local.properties: an *empty* value is not a safe fallback either, because the secrets Gradle plugin copies each entry into BuildConfig verbatim and emits `public static final String BUGSNAG_API_KEY = ;`. Each key gets a zero-filled placeholder instead, substituted independently so a run with partial secrets still uses the real ones it has. Both fall back on the secret being empty rather than on `github.actor`, which covers fork PRs too. Runs with real secrets produce a byte-identical local.properties to before. The job logs a notice naming every value it faked. Secrets are now passed through the environment instead of being interpolated into the shell script, so a value containing a quote or backtick cannot break or escape the command. Also adds gradle/actions/wrapper-validation@v4 as a supply-chain guard on gradle-wrapper.jar (the committed jar matches the published Gradle 9.7.0 checksum, and it is the only wrapper jar in the tree). Verified by running the exact Fastlane lane task set locally with only the placeholders in place: BUILD SUCCESSFUL, lint found no new issues. --- .github/ci/README.md | 48 ++++++++++++ .github/ci/google-services.placeholder.json | 29 +++++++ .github/workflows/ci.yml | 87 ++++++++++++++++----- 3 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 .github/ci/README.md create mode 100644 .github/ci/google-services.placeholder.json diff --git a/.github/ci/README.md b/.github/ci/README.md new file mode 100644 index 0000000000..7058c9779c --- /dev/null +++ b/.github/ci/README.md @@ -0,0 +1,48 @@ +# CI fixtures + +Files here exist so that the `flipcash-tests` job in [`../workflows/ci.yml`](../workflows/ci.yml) +can build, unit-test and lint the app **without any credentials**. + +## Why + +GitHub does not expose repository Actions secrets to `pull_request` runs opened by +`dependabot[bot]` — Dependabot reads from its own secret store — or by forks. On those runs every +`secrets.*` expression resolves to an empty string. Two things then break before Gradle produces +anything useful: + +1. `google-services.json` never gets written, so the `com.google.gms.google-services` plugin fails + to configure `:apps:flipcash:app`. +2. The secrets Gradle plugin copies each `local.properties` entry into `BuildConfig` verbatim, so + an empty value emits `public static final String BUGSNAG_API_KEY = ;` and + `compileDebugJavaWithJavac` fails. + +The result was that every dependency PR was red for a reason unrelated to the bump, which destroys +the signal: a genuinely broken bump looked exactly like a healthy one. + +The job therefore falls back to placeholders whenever a secret is empty. That is safe because the +lane runs `generateEmojiList flipcashTestDebug :apps:flipcash:app:lintDebug` — it compiles, +unit-tests and lints, and never contacts Firebase, Bugsnag, Mixpanel or Coinbase. + +## `google-services.placeholder.json` + +A **fake** Firebase config, committed on purpose. Structurally valid, deliberately meaningless: +project number `000000000000`, project id `flipcash-ci-placeholder`, zero-filled API key. + +`client[].client_info.android_client_info.package_name` must stay in sync with the app's +`applicationId` (`com.flipcash.app.android`) or the plugin errors with "No matching client found +for package name". + +## `local.properties` placeholders + +Not a file — the fallbacks are inline in the workflow's `Write local.properties` step, zero-filled +for `BUGSNAG_API_KEY`, `MIXPANEL_API_KEY`, `COINBASE_ONRAMP_API_KEY` and +`GOOGLE_CLOUD_PROJECT_NUMBER`. Each key is substituted independently, so a run with only some +secrets available still uses the real ones it has, and the job logs a notice naming every key it +faked. + +## Do not put real keys here + +Anything that needs real credentials — release builds, upload lanes, the Maestro E2E suite — reads +them from secrets in its own workflow. If a job in `ci.yml` ever needs the *real* values on +Dependabot PRs, add them under **Settings → Secrets and variables → Dependabot** (repo admin +required) under the same names `ci.yml` already references. diff --git a/.github/ci/google-services.placeholder.json b/.github/ci/google-services.placeholder.json new file mode 100644 index 0000000000..ea1617b46e --- /dev/null +++ b/.github/ci/google-services.placeholder.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "flipcash-ci-placeholder", + "storage_bucket": "flipcash-ci-placeholder.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "com.flipcash.app.android" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyA0000000000000000000000000000000000" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f80bfb141..c94448f60a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,11 @@ jobs: with: fetch-depth: 1 + # Cheap supply-chain guard: fails the run if gradle/wrapper/gradle-wrapper.jar + # is not a byte-for-byte match for a jar published by Gradle. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Setup Java env uses: actions/setup-java@v3 with: @@ -42,26 +47,70 @@ jobs: ruby-version: 2.7.2 bundler-cache: true - - name: Decode Google Services JSON file - uses: timheuer/base64-to-file@v1 - id: google_services_json_file - with: - fileName: google-services.json - fileDir: ./apps/flipcash/app/src - encodedString: ${{ secrets.FLIPCASH2_GOOGLE_SERVICES }} - - - - name: Setup BugSnag API Key - run: echo BUGSNAG_API_KEY=\"${{ secrets.FLIPCASH_BUGSNAG_API_KEY }}\" > ./local.properties - - - name: Setup Google Cloud Project Number - run: echo GOOGLE_CLOUD_PROJECT_NUMBER=${{ secrets.GOOGLE_CLOUD_PROJECT_NUMBER }} >> ./local.properties - - - name: Setup Mixpanel API Key - run: echo MIXPANEL_API_KEY=\"${{ secrets.FLIPCASH_MIXPANEL_API_KEY }}\" >> ./local.properties + # GitHub does not expose regular Actions secrets to `pull_request` runs opened by + # Dependabot (it reads from a separate Dependabot secret store) or by forks, so every + # `secrets.*` below resolves to an empty string on those runs. This lane only builds and + # runs unit tests + lint — it never talks to Firebase, Bugsnag, Mixpanel or Coinbase — so + # it falls back to a committed placeholder config instead of failing. That keeps dependency + # PRs honestly red or green on the bump itself rather than uniformly red on missing config. + # + # If a future job in this workflow ever needs the *real* values on Dependabot PRs, add them + # under Settings -> Secrets and variables -> Dependabot (repo admin required); the names are + # the same ones referenced here. + - name: Provision google-services.json + env: + FLIPCASH2_GOOGLE_SERVICES: ${{ secrets.FLIPCASH2_GOOGLE_SERVICES }} + run: | + set -euo pipefail + dest=apps/flipcash/app/src/google-services.json + mkdir -p "$(dirname "$dest")" + if [ -n "${FLIPCASH2_GOOGLE_SERVICES:-}" ]; then + printf '%s' "$FLIPCASH2_GOOGLE_SERVICES" | base64 --decode > "$dest" + echo "Wrote google-services.json from the FLIPCASH2_GOOGLE_SERVICES secret." + else + cp .github/ci/google-services.placeholder.json "$dest" + echo "::notice title=Using placeholder Firebase config::Repository secrets are not available on this run (Dependabot or fork PR). Copied .github/ci/google-services.placeholder.json instead; unit tests and lint do not need real Firebase credentials." + fi - - name: Setup Coinbase OnRamp API Key - run: echo COINBASE_ONRAMP_API_KEY=${{ secrets.COINBASE_ONRAMP_API_KEY }} >> ./local.properties + # The secrets Gradle plugin copies every entry in local.properties into BuildConfig + # verbatim, so an *empty* value emits `public static final String X = ;` and the app fails + # to compile — writing empty strings is not a safe fallback. Each key therefore gets a + # zero-filled placeholder when its secret is unavailable (Dependabot / fork PRs); nothing + # in this lane calls out to Bugsnag, Mixpanel or Coinbase. + # + # Values are passed through the environment rather than interpolated into the script so a + # secret containing a quote or backtick cannot break (or escape) the shell. The quoting of + # each line is kept exactly as it was, since BUGSNAG_API_KEY is also read verbatim into a + # manifest placeholder. + - name: Write local.properties + env: + BUGSNAG_API_KEY: ${{ secrets.FLIPCASH_BUGSNAG_API_KEY }} + GOOGLE_CLOUD_PROJECT_NUMBER: ${{ secrets.GOOGLE_CLOUD_PROJECT_NUMBER }} + MIXPANEL_API_KEY: ${{ secrets.FLIPCASH_MIXPANEL_API_KEY }} + COINBASE_ONRAMP_API_KEY: ${{ secrets.COINBASE_ONRAMP_API_KEY }} + run: | + set -euo pipefail + # Substitute an obviously-fake value for any key whose secret came through empty. + placeheld="" + for spec in \ + "BUGSNAG_API_KEY=00000000000000000000000000000000" \ + "GOOGLE_CLOUD_PROJECT_NUMBER=000000000000" \ + "MIXPANEL_API_KEY=00000000000000000000000000000000" \ + "COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000" + do + name=${spec%%=*} + if [ -z "${!name:-}" ]; then + printf -v "$name" '%s' "${spec#*=}" + placeheld="$placeheld $name" + fi + done + { + echo "BUGSNAG_API_KEY=\"$BUGSNAG_API_KEY\"" + echo "GOOGLE_CLOUD_PROJECT_NUMBER=$GOOGLE_CLOUD_PROJECT_NUMBER" + echo "MIXPANEL_API_KEY=\"$MIXPANEL_API_KEY\"" + echo "COINBASE_ONRAMP_API_KEY=$COINBASE_ONRAMP_API_KEY" + } > ./local.properties + [ -z "$placeheld" ] || echo "::notice title=Using placeholder API keys::Secrets are not available on this run (Dependabot or fork PR); zero-filled placeholders used for:$placeheld" - name: Run Flipcash tests run: bundle exec fastlane android flipcash_tests