Merge pull request #98352 from Expensify/rory/fix-prebuilt-artifacts-… #5967
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: Deploy code to staging or production | |
| on: | |
| push: | |
| branches: [staging, production] | |
| workflow_dispatch: | |
| inputs: | |
| ENVIRONMENT: | |
| description: 'Environment to deploy' | |
| required: true | |
| type: choice | |
| options: [staging, production] | |
| env: | |
| IS_APP_REPO: ${{ github.repository == 'Expensify/App' }} | |
| # For push triggers, fall back to deriving environment from the branch name. | |
| # For workflow_dispatch, use the explicit input. | |
| DEPLOY_ENV: ${{ inputs.ENVIRONMENT || (github.ref == 'refs/heads/production' && 'production' || 'staging') }} | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ inputs.ENVIRONMENT || (github.ref == 'refs/heads/production' && 'production' || 'staging') }} | |
| cancel-in-progress: true | |
| jobs: | |
| prep: | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| outputs: | |
| APP_VERSION: ${{ steps.getAppVersion.outputs.VERSION }} | |
| TAG: ${{ steps.getTagName.outputs.TAG }} | |
| DEPLOY_SHA: ${{ steps.getDeploySHA.outputs.SHA }} | |
| DEPLOY_ENV: ${{ steps.getDeploySHA.outputs.DEPLOY_ENV }} | |
| # Is this deploy for a cherry-pick? | |
| IS_CHERRY_PICK: ${{ steps.isCherryPick.outputs.IS_CHERRY_PICK }} | |
| # Should we build native apps? (only on staging or cherry-pick, not production) | |
| SHOULD_BUILD_NATIVE: ${{ env.DEPLOY_ENV == 'staging' || fromJSON(steps.isCherryPick.outputs.IS_CHERRY_PICK) }} | |
| VERSION_CODE: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} | |
| IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| with: | |
| ref: ${{ inputs.ENVIRONMENT || github.sha }} | |
| token: ${{ secrets.OS_BOTIFY_TOKEN }} | |
| submodules: true | |
| - name: Get deploy SHA and environment | |
| id: getDeploySHA | |
| run: | | |
| echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "DEPLOY_ENV=${{ env.DEPLOY_ENV }}" >> "$GITHUB_OUTPUT" | |
| - name: Validate actor | |
| id: validateActor | |
| uses: ./.github/actions/composite/validateActor | |
| with: | |
| OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} | |
| - name: Setup git for OSBotify | |
| uses: Expensify/GitHub-Actions/setupGitForOSBotify@main | |
| id: setupGitForOSBotify | |
| with: | |
| OP_VAULT: ${{ vars.OP_VAULT }} | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} | |
| OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} | |
| - name: Get app version | |
| id: getAppVersion | |
| run: echo "VERSION=$(jq -r .version < package.json)" >> "$GITHUB_OUTPUT" | |
| - name: Get tag | |
| id: getTagName | |
| run: echo "TAG=${{ env.DEPLOY_ENV == 'production' && steps.getAppVersion.outputs.VERSION || format('{0}-staging', steps.getAppVersion.outputs.VERSION) }}" >> "$GITHUB_OUTPUT" | |
| - name: Create and push tag | |
| run: | | |
| # Idempotent tag creation: if the tag already exists at HEAD (e.g. a prior deploy | |
| # attempt tagged successfully before failing later), skip creation so that a manual | |
| # re-trigger of the workflow doesn't abort in prep. If the tag exists but points | |
| # to a different commit, fail loudly to avoid deploying the wrong code. | |
| create_tag_idempotent() { | |
| local tag="$1" | |
| local head_sha | |
| head_sha=$(git rev-parse HEAD) | |
| # Fetch the tag from remote before checking locally. useblacksmith/checkout uses | |
| # fetch-depth=1 and fetch-tags=false by default, so a previously pushed tag | |
| # won't be present in the workspace. Without this fetch, git tag "$tag" would | |
| # succeed locally and then git push would fail because the tag already exists | |
| # on the remote, causing the idempotent path below to be unreachable. | |
| git fetch origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null || true | |
| if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then | |
| local existing_sha | |
| existing_sha=$(git rev-parse "refs/tags/$tag") | |
| if [ "$existing_sha" = "$head_sha" ]; then | |
| echo "Tag $tag already exists at $head_sha, skipping creation." | |
| else | |
| echo "ERROR: Tag $tag exists at $existing_sha, expected $head_sha" | |
| exit 1 | |
| fi | |
| else | |
| git tag "$tag" | |
| git push origin --tags | |
| fi | |
| } | |
| create_tag_idempotent ${{ steps.getTagName.outputs.TAG }} | |
| cd Mobile-Expensify | |
| create_tag_idempotent ${{ steps.getTagName.outputs.TAG }} | |
| # We use JS here instead of bash/jq because inlining potentially large json into a bash command is non-trivial. | |
| # JS is better at handling JSON: https://stackoverflow.com/questions/72953526/github-actions-how-to-pass-tojson-result-to-shell-commands | |
| - name: Check if this deploy was triggered by a cherry-pick | |
| id: isCherryPick | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| HEAD_COMMIT_SHA: ${{ steps.getDeploySHA.outputs.SHA }} | |
| DEPLOY_TAG: ${{ steps.getTagName.outputs.TAG }} | |
| with: | |
| script: | | |
| let commitMessages; | |
| if (context.payload.commits) { | |
| // push trigger: all pushed commits are in the payload | |
| commitMessages = context.payload.commits.map((commit) => commit.message); | |
| } else { | |
| // workflow_dispatch: payload has no commits array. | |
| // We must inspect the full commit range since the previous deploy, not just HEAD. | |
| // Two cases where HEAD alone misses the marker: | |
| // 1. Linear push: Mobile-Expensify cherry-picks amend the marker onto the | |
| // version-bump commit, then push a final unmarked submodule-update commit on top. | |
| // 2. Merge commit: a conflict-resolution PR creates a merge commit at HEAD; the | |
| // cherry-picked commit with the marker is an inner commit, not HEAD itself. | |
| // compareCommits(prevTag, HEAD) follows all parents and covers both cases. | |
| const headSha = process.env.HEAD_COMMIT_SHA; | |
| const currentTag = process.env.DEPLOY_TAG; | |
| const isStaging = currentTag.endsWith('-staging'); | |
| // Paginate tags (max 100 per page) until we find the most recent prior tag | |
| // on the same branch. A single page of 20 isn't enough: a production deploy | |
| // that follows many staging releases may have the last production tag beyond | |
| // the first page, causing prevTag to be undefined and falling back to HEAD only. | |
| let prevTag = null; | |
| for (let page = 1; !prevTag; page++) { | |
| const { data: tags } = await github.rest.repos.listTags({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100, | |
| page, | |
| }); | |
| if (tags.length === 0) break; | |
| // Find the most recent prior tag on the same branch (staging or production) | |
| // to bound the commit range and avoid false positives from prior cherry-picks. | |
| prevTag = tags.find((t) => { | |
| if (t.name === currentTag) return false; | |
| return isStaging ? t.name.endsWith('-staging') : !t.name.endsWith('-staging'); | |
| }); | |
| if (tags.length < 100) break; | |
| } | |
| if (prevTag) { | |
| const { data: comparison } = await github.rest.repos.compareCommits({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| base: prevTag.commit.sha, | |
| head: headSha, | |
| }); | |
| commitMessages = comparison.commits.map((c) => c.commit.message); | |
| } else { | |
| // No previous tag found: fall back to HEAD only | |
| const { data: headCommit } = await github.rest.git.getCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: headSha, | |
| }); | |
| commitMessages = [headCommit.message]; | |
| } | |
| } | |
| const isCherryPick = commitMessages.some((message) => /.*\(cherry-picked to .* by .*\)$/.test(message)); | |
| console.log('Is cherry pick?', isCherryPick); | |
| core.setOutput( | |
| 'IS_CHERRY_PICK', | |
| isCherryPick, | |
| ); | |
| - name: Get Android native version | |
| id: getAndroidVersion | |
| run: echo "VERSION_CODE=$(grep -oP 'android:versionCode="\K[0-9]+' Mobile-Expensify/Android/AndroidManifest.xml)" >> "$GITHUB_OUTPUT" | |
| - name: Get iOS native version | |
| id: getIOSVersion | |
| run: echo "IOS_VERSION=$(echo '${{ steps.getAppVersion.outputs.VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT" | |
| # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform | |
| deployChecklist: | |
| name: Create or update deploy checklist | |
| uses: ./.github/workflows/createDeployChecklist.yml | |
| if: ${{ needs.prep.outputs.DEPLOY_ENV == 'staging' }} | |
| needs: prep | |
| with: | |
| REF: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| secrets: inherit | |
| androidBuild: | |
| name: Build Android HybridApp | |
| needs: [prep] | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| uses: ./.github/workflows/buildAndroid.yml | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| variant: Release | |
| secrets: inherit | |
| androidUploadGooglePlay: | |
| name: Upload Android to Google Play | |
| needs: [prep, androidBuild] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| - name: Download Android build artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: androidBuild-artifact | |
| path: ./ | |
| - name: Set aabPath for Fastlane | |
| run: | | |
| AAB_PATH="$(pwd)/${{ needs.androidBuild.outputs.AAB_FILENAME }}" | |
| if [ ! -f "$AAB_PATH" ]; then | |
| echo "::error::Expected AAB not found at $AAB_PATH" | |
| exit 1 | |
| fi | |
| echo "aabPath=$AAB_PATH" >> "$GITHUB_ENV" | |
| - name: Setup Ruby | |
| # v1.310.0 | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f | |
| with: | |
| bundler-cache: true | |
| - name: Install New Expensify Gems | |
| run: bundle install | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load Google Play credentials from 1Password | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| run: | | |
| op read "op://${{ vars.OP_VAULT }}/android-fastlane-json-key.json/android-fastlane-json-key.json" --force --out-file ./android-fastlane-json-key.json | |
| - name: Upload Android app to Google Play | |
| run: bundle exec fastlane android ${{ vars.ANDROID_UPLOAD_COMMAND }} | |
| env: | |
| VERSION: ${{ needs.prep.outputs.VERSION_CODE }} | |
| ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME }} | |
| androidSubmit: | |
| name: Submit Android for production rollout | |
| needs: [prep, androidBuild, androidUploadGooglePlay] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| - name: Setup Ruby | |
| # v1.310.0 | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f | |
| with: | |
| bundler-cache: true | |
| - name: Install New Expensify Gems | |
| run: bundle install | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load Google Play credentials from 1Password | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| run: | | |
| op read "op://${{ vars.OP_VAULT }}/android-fastlane-json-key.json/android-fastlane-json-key.json" --force --out-file ./android-fastlane-json-key.json | |
| - name: Get current Android rollout percentage | |
| id: getAndroidRolloutPercentage | |
| uses: ./.github/actions/javascript/getAndroidRolloutPercentage | |
| with: | |
| GOOGLE_KEY_FILE: ./android-fastlane-json-key.json | |
| PACKAGE_NAME: org.me.mobiexpensifyg | |
| # Complete the previous version rollout if the current rollout percentage is not -1 (no rollout in progress) or 1 (fully rolled out) | |
| - name: Submit previous production build to 100% | |
| if: ${{ !contains(fromJSON('["1", "-1"]'), steps.getAndroidRolloutPercentage.outputs.CURRENT_ROLLOUT_PERCENTAGE) }} | |
| run: bundle exec fastlane android complete_hybrid_rollout | |
| continue-on-error: true | |
| - name: Submit production build for Google Play review and a slow rollout | |
| run: bundle exec fastlane android upload_google_play_production_hybrid_rollout | |
| env: | |
| VERSION: ${{ needs.prep.outputs.VERSION_CODE }} | |
| - name: Warn deployers if Android production deploy failed | |
| if: ${{ failure() }} | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#deployer", | |
| "attachments": [{ | |
| "color": "#DB4545", | |
| "pretext": "<!subteam^S4TJJ3PSL>", | |
| "text": "💥 Android HybridApp production <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|deploy run> failed. Please <https://stackoverflowteams.com/c/expensify/questions/5738|manually submit> ${{ needs.prep.outputs.APP_VERSION }} in the <https://play.google.com/console/u/0/developers/8765590895836334604/app/4974129597497161901/releases/overview|Google Play Store> 💥" | |
| }] | |
| } | |
| androidUploadBrowserStack: | |
| name: Upload Android to BrowserStack | |
| needs: [prep, androidBuild] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| continue-on-error: true | |
| steps: | |
| - name: Download Android APK artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: android-apk-artifact | |
| path: ./ | |
| - name: Find APK path | |
| id: find-apk | |
| run: | | |
| APK_PATH="$(pwd)/${{ needs.androidBuild.outputs.APK_FILENAME }}" | |
| if [ ! -f "$APK_PATH" ]; then | |
| echo "::error::Expected APK not found at $APK_PATH" | |
| exit 1 | |
| fi | |
| echo "APK_PATH=$APK_PATH" >> "$GITHUB_OUTPUT" | |
| - name: Upload Android build to BrowserStack | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@${{ steps.find-apk.outputs.APK_PATH }}" | |
| env: | |
| BROWSERSTACK: ${{ secrets.BROWSERSTACK }} | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load BrowserStack Automate credentials from 1Password | |
| id: load-browserstack-automate | |
| # v4.0.0 | |
| uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| BROWSERSTACK_AUTOMATE: op://${{ vars.OP_VAULT }}/BROWSERSTACK_AUTOMATE/notesPlain | |
| - name: Upload Android build to BrowserStack Automate | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: curl -u "${{ steps.load-browserstack-automate.outputs.BROWSERSTACK_AUTOMATE }}" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@${{ steps.find-apk.outputs.APK_PATH }}" | |
| androidUploadApplause: | |
| name: Upload Android to Applause | |
| needs: [prep, androidBuild] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ github.repository == 'Expensify/App' && needs.prep.outputs.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} | |
| continue-on-error: true | |
| steps: | |
| - name: Download Android APK artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: android-apk-artifact | |
| path: ./ | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load Applause API key from 1Password | |
| id: load-credentials | |
| # v4.0.0 | |
| uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| APPLAUSE_API_KEY: op://${{ vars.OP_VAULT }}/Applause-API-Key/password | |
| - name: Find APK path | |
| id: find-apk | |
| run: | | |
| APK_PATH="$(pwd)/${{ needs.androidBuild.outputs.APK_FILENAME }}" | |
| if [ ! -f "$APK_PATH" ]; then | |
| echo "::error::Expected APK not found at $APK_PATH" | |
| exit 1 | |
| fi | |
| echo "APK_PATH=$APK_PATH" >> "$GITHUB_OUTPUT" | |
| - name: Upload Android build to Applause | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: | | |
| APPLAUSE_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.') | |
| curl -F "file=@${{ steps.find-apk.outputs.APK_PATH }}" \ | |
| "https://api.applause.com/v2/builds?name=Expensify_$APPLAUSE_VERSION&productId=36008" \ | |
| -H "X-Api-Key: ${{ steps.load-credentials.outputs.APPLAUSE_API_KEY }}" | |
| iosBuild: | |
| name: Build iOS HybridApp | |
| needs: [prep] | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| uses: ./.github/workflows/buildIOS.yml | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| variant: Release | |
| secrets: inherit | |
| iosUploadTestflight: | |
| name: Upload iOS to TestFlight | |
| needs: [prep, iosBuild] | |
| runs-on: blacksmith-12vcpu-macos-latest | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| env: | |
| DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer | |
| steps: | |
| - name: Checkout | |
| # Upstream checkout on macOS - Blacksmith's git-mirror cache can't work there (see workflows/README.md) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| token: ${{ secrets.OS_BOTIFY_TOKEN }} | |
| submodules: true | |
| - name: Download iOS build artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: iosBuild-artifact | |
| path: ./ | |
| - name: Download iOS dSYM artifact | |
| id: download-dsym | |
| continue-on-error: true | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: ios-dsym-artifact | |
| path: ./ | |
| - name: Log dSYM download failure | |
| if: steps.download-dsym.outcome == 'failure' | |
| run: echo "::error::Failed to download dSYM artifact – symbolication data may be missing for this build" | |
| - name: Set artifact paths for Fastlane | |
| run: | | |
| IPA_PATH="$(pwd)/${{ needs.iosBuild.outputs.IPA_FILENAME }}" | |
| if [ ! -f "$IPA_PATH" ]; then | |
| echo "::error::Expected IPA not found at $IPA_PATH" | |
| exit 1 | |
| fi | |
| echo "ipaPath=$IPA_PATH" >> "$GITHUB_ENV" | |
| DSYM_PATH="$(pwd)/${{ needs.iosBuild.outputs.DSYM_FILENAME }}" | |
| if [ ! -f "$DSYM_PATH" ]; then | |
| echo "::warning::Expected dSYM not found at $DSYM_PATH" | |
| fi | |
| echo "dsymPath=$DSYM_PATH" >> "$GITHUB_ENV" | |
| - name: Setup Ruby | |
| # v1.310.0 | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f | |
| with: | |
| bundler-cache: true | |
| - name: Install New Expensify Gems | |
| run: bundle install | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load iOS credentials from 1Password | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| run: | | |
| op read "op://${{ vars.OP_VAULT }}/ios-fastlane-json-key.json/ios-fastlane-json-key.json" --force --out-file ./ios-fastlane-json-key.json | |
| - name: Upload release build to TestFlight | |
| run: bundle exec fastlane ios upload_testflight_hybrid | |
| env: | |
| APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} | |
| APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} | |
| APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} | |
| APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} | |
| APPLE_ID: ${{ vars.APPLE_ID }} | |
| iosSubmit: | |
| name: Submit iOS for production rollout | |
| needs: [prep, iosBuild, iosUploadTestflight] | |
| runs-on: blacksmith-12vcpu-macos-latest | |
| if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.iosBuild.result != 'failure' && needs.iosUploadTestflight.result != 'failure' }} | |
| env: | |
| DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer | |
| steps: | |
| - name: Checkout | |
| # Upstream checkout on macOS - Blacksmith's git-mirror cache can't work there (see workflows/README.md) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| - name: Setup Ruby | |
| # v1.310.0 | |
| uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f | |
| with: | |
| bundler-cache: true | |
| - name: Install New Expensify Gems | |
| run: bundle install | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load iOS credentials from 1Password | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| run: | | |
| op read "op://${{ vars.OP_VAULT }}/ios-fastlane-json-key.json/ios-fastlane-json-key.json" --force --out-file ./ios-fastlane-json-key.json | |
| - name: Submit previous production build to 100% | |
| run: bundle exec fastlane ios complete_hybrid_rollout | |
| continue-on-error: true | |
| env: | |
| APPLE_ID: ${{ vars.APPLE_ID }} | |
| - name: Submit production build for App Store review and a slow rollout | |
| run: bundle exec fastlane ios submit_hybrid_for_rollout | |
| env: | |
| VERSION: ${{ needs.prep.outputs.IOS_VERSION }} | |
| APPLE_ID: ${{ vars.APPLE_ID }} | |
| - name: Warn deployers if iOS production deploy failed | |
| if: ${{ failure() }} | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#deployer", | |
| "attachments": [{ | |
| "color": "#DB4545", | |
| "pretext": "<!subteam^S4TJJ3PSL>", | |
| "text": "💥 iOS HybridApp production deploy failed. Please <https://stackoverflowteams.com/c/expensify/questions/5740|manually submit> ${{ needs.prep.outputs.IOS_VERSION }} in the <https://appstoreconnect.apple.com/apps/471713959/appstore|App Store>. 💥" | |
| }] | |
| } | |
| iosUploadBrowserStack: | |
| name: Upload iOS to BrowserStack | |
| needs: [prep, iosBuild] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} | |
| continue-on-error: true | |
| steps: | |
| - name: Download iOS build artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: iosBuild-artifact | |
| path: ./ | |
| - name: Find IPA path | |
| id: find-ipa | |
| run: | | |
| IPA_PATH="$(pwd)/${{ needs.iosBuild.outputs.IPA_FILENAME }}" | |
| if [ ! -f "$IPA_PATH" ]; then | |
| echo "::error::Expected IPA not found at $IPA_PATH" | |
| exit 1 | |
| fi | |
| echo "IPA_PATH=$IPA_PATH" >> "$GITHUB_OUTPUT" | |
| - name: Upload iOS build to BrowserStack | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@${{ steps.find-ipa.outputs.IPA_PATH }}" | |
| env: | |
| BROWSERSTACK: ${{ secrets.BROWSERSTACK }} | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load BrowserStack Automate credentials from 1Password | |
| id: load-browserstack-automate | |
| # v4.0.0 | |
| uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| BROWSERSTACK_AUTOMATE: op://${{ vars.OP_VAULT }}/BROWSERSTACK_AUTOMATE/notesPlain | |
| - name: Upload iOS build to BrowserStack Automate | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: curl -u "${{ steps.load-browserstack-automate.outputs.BROWSERSTACK_AUTOMATE }}" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@${{ steps.find-ipa.outputs.IPA_PATH }}" | |
| iosUploadApplause: | |
| name: Upload iOS to Applause | |
| needs: [prep, iosBuild] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ github.repository == 'Expensify/App' && needs.prep.outputs.DEPLOY_ENV == 'staging' && !fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} | |
| continue-on-error: true | |
| steps: | |
| - name: Download iOS build artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: iosBuild-artifact | |
| path: ./ | |
| - name: Setup 1Password CLI | |
| uses: Expensify/GitHub-Actions/setup-certificate-1p@main | |
| with: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SHOULD_LOAD_SSL_CERTIFICATES: 'false' | |
| - name: Load Applause API key from 1Password | |
| id: load-credentials | |
| # v4.0.0 | |
| uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| APPLAUSE_API_KEY: op://${{ vars.OP_VAULT }}/Applause-API-Key/password | |
| - name: Find IPA path | |
| id: find-ipa | |
| run: | | |
| IPA_PATH="$(pwd)/${{ needs.iosBuild.outputs.IPA_FILENAME }}" | |
| if [ ! -f "$IPA_PATH" ]; then | |
| echo "::error::Expected IPA not found at $IPA_PATH" | |
| exit 1 | |
| fi | |
| echo "IPA_PATH=$IPA_PATH" >> "$GITHUB_OUTPUT" | |
| - name: Upload iOS build to Applause | |
| if: ${{ fromJSON(env.IS_APP_REPO) }} | |
| run: | | |
| APPLAUSE_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.') | |
| curl -F "file=@${{ steps.find-ipa.outputs.IPA_PATH }}" \ | |
| "https://api.applause.com/v2/builds?name=Expensify_$APPLAUSE_VERSION&productId=36005" \ | |
| -H "X-Api-Key: ${{ steps.load-credentials.outputs.APPLAUSE_API_KEY }}" | |
| webBuild: | |
| name: Build Web | |
| needs: [prep] | |
| uses: ./.github/workflows/buildWeb.yml | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| environment: ${{ needs.prep.outputs.DEPLOY_ENV }} | |
| secrets: inherit | |
| victoryChartRendererBuild: | |
| name: Build victory-chart-renderer (Linux x64) | |
| needs: [prep] | |
| uses: ./.github/workflows/buildVictoryChartRenderer.yml | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| release-tag: ${{ needs.prep.outputs.TAG }} | |
| secrets: inherit | |
| webDeploy: | |
| name: Deploy Web to S3 | |
| needs: [prep, webBuild, buildStorybook] | |
| if: ${{ always() && needs.webBuild.result == 'success' }} | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| - name: Download web build artifact | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: web-build-tar-gz-artifact | |
| path: ./ | |
| - name: Extract web build | |
| run: tar -xzvf "${{ needs.webBuild.outputs.TAR_FILENAME }}" | |
| - name: Download storybook docs artifact | |
| continue-on-error: true | |
| # v7 | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| with: | |
| name: storybook-docs-artifact | |
| path: ./dist/docs | |
| - name: Setup Cloudflare CLI | |
| run: pip3 install cloudflare==2.19.0 | |
| - name: Configure AWS Credentials | |
| # v6 | |
| uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 | |
| with: | |
| aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| aws-region: us-east-1 | |
| - name: Deploy to S3 | |
| run: | | |
| aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_BUCKET }}/ | |
| aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association | |
| aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_BUCKET }}/.well-known/apple-app-site-association ${{ env.S3_BUCKET }}/apple-app-site-association | |
| env: | |
| S3_BUCKET: s3://${{ env.DEPLOY_ENV == 'staging' && 'staging-' || '' }}${{ vars.PRODUCTION_S3_BUCKET }} | |
| - name: Purge Cloudflare cache | |
| run: | | |
| /home/runner/.local/bin/cli4 --verbose --delete hosts=["$HOST"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache | |
| env: | |
| CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} | |
| HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} | |
| - name: Verify deploy | |
| run: | | |
| APP_VERSION=$(jq -r .version < package.json) | |
| ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION" | |
| env: | |
| HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} | |
| buildStorybook: | |
| name: Build storybook docs | |
| needs: [prep] | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| continue-on-error: true | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| with: | |
| ref: ${{ needs.prep.outputs.DEPLOY_SHA }} | |
| - name: Setup Node | |
| uses: ./.github/actions/composite/setupNode | |
| - name: Build storybook docs | |
| run: | | |
| if [ "${{ env.DEPLOY_ENV }}" == "production" ]; then | |
| npm run storybook-build | |
| else | |
| npm run storybook-build-staging | |
| fi | |
| - name: Upload storybook docs artifact | |
| # v6 | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f | |
| with: | |
| name: storybook-docs-artifact | |
| path: ./dist/docs | |
| postSlackMessageOnFailure: | |
| name: Post a Slack message when any platform fails to build or deploy | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ failure() }} | |
| needs: | |
| [ | |
| androidBuild, | |
| androidUploadGooglePlay, | |
| androidUploadBrowserStack, | |
| androidUploadApplause, | |
| androidSubmit, | |
| iosBuild, | |
| iosUploadTestflight, | |
| iosUploadBrowserStack, | |
| iosUploadApplause, | |
| iosSubmit, | |
| webBuild, | |
| webDeploy, | |
| victoryChartRendererBuild, | |
| ] | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| - name: Post Slack message on failure | |
| uses: ./.github/actions/composite/announceFailedWorkflowInSlack | |
| with: | |
| SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} | |
| postSlackMessageOnCancellation: | |
| name: Post a Slack message when the deploy is cancelled manually | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ cancelled() }} | |
| needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy] | |
| steps: | |
| - name: Check whether a newer deploy run exists | |
| id: check | |
| run: | | |
| # If a newer run exists, the cherry-pick workflow already announced this. Skip to avoid double-posting. | |
| LATEST=$(gh api \ | |
| "repos/${{ github.repository }}/actions/workflows/deploy.yml/runs?branch=${{ github.ref_name }}&per_page=1" \ | |
| --jq '.workflow_runs[0].id') | |
| if [ "$LATEST" != "${{ github.run_id }}" ]; then | |
| echo "SKIP=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| - name: Post Slack message on manual cancellation | |
| if: ${{ steps.check.outputs.SKIP != 'true' }} | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#deployer", | |
| "attachments": [{ | |
| "color": "warning", | |
| "text": "⚠️ ${{ env.DEPLOY_ENV == 'production' && 'Production' || 'Staging' }} <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|deploy> cancelled manually" | |
| }] | |
| } | |
| checkDeploymentSuccess: | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| outputs: | |
| IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }} | |
| IS_RELEASE_READY: ${{ steps.checkReleaseReady.outputs.IS_RELEASE_READY }} | |
| VCR_RESULT: ${{ needs.victoryChartRendererBuild.result }} | |
| ANDROID_RESULT: ${{ steps.platformResults.outputs.ANDROID_RESULT }} | |
| IOS_RESULT: ${{ steps.platformResults.outputs.IOS_RESULT }} | |
| WEB_RESULT: ${{ steps.platformResults.outputs.WEB_RESULT }} | |
| needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, victoryChartRendererBuild] | |
| if: ${{ always() }} | |
| steps: | |
| # Determine effective result for each platform | |
| - name: Determine platform results | |
| id: platformResults | |
| run: | | |
| # Android: use submit result for production, upload result for staging. | |
| # On production cherry-picks, propagate build/upload failures that caused submit to be skipped. | |
| if [ "${{ env.DEPLOY_ENV }}" == "production" ]; then | |
| if [ "${{ needs.androidBuild.result }}" == "failure" ] || [ "${{ needs.androidUploadGooglePlay.result }}" == "failure" ]; then | |
| echo "ANDROID_RESULT=failure" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "ANDROID_RESULT=${{ needs.androidSubmit.result }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| elif [ "${{ needs.androidBuild.result }}" == "failure" ]; then | |
| echo "ANDROID_RESULT=failure" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "ANDROID_RESULT=${{ needs.androidUploadGooglePlay.result }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| # iOS: use submit result for production, upload result for staging. | |
| # On production cherry-picks, propagate build/upload failures that caused submit to be skipped. | |
| if [ "${{ env.DEPLOY_ENV }}" == "production" ]; then | |
| if [ "${{ needs.iosBuild.result }}" == "failure" ] || [ "${{ needs.iosUploadTestflight.result }}" == "failure" ]; then | |
| echo "IOS_RESULT=failure" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "IOS_RESULT=${{ needs.iosSubmit.result }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| elif [ "${{ needs.iosBuild.result }}" == "failure" ]; then | |
| echo "IOS_RESULT=failure" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Web: propagate build failure even when deploy is skipped | |
| if [ "${{ needs.webBuild.result }}" == "failure" ]; then | |
| echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Check deployment success on at least one platform | |
| id: checkDeploymentSuccessOnAtLeastOnePlatform | |
| run: | | |
| isAtLeastOnePlatformDeployed="false" | |
| if [ "${{ steps.platformResults.outputs.IOS_RESULT }}" == "success" ] || \ | |
| [ "${{ steps.platformResults.outputs.ANDROID_RESULT }}" == "success" ] || \ | |
| [ "${{ steps.platformResults.outputs.WEB_RESULT }}" == "success" ]; then | |
| isAtLeastOnePlatformDeployed="true" | |
| fi | |
| echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=$isAtLeastOnePlatformDeployed" >> "$GITHUB_OUTPUT" | |
| echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED is $isAtLeastOnePlatformDeployed" | |
| - name: Check deployment success on all platforms | |
| id: checkDeploymentSuccessOnAllPlatforms | |
| run: | | |
| isAllPlatformsDeployed="false" | |
| if [ "${{ steps.platformResults.outputs.IOS_RESULT }}" == "success" ] && \ | |
| [ "${{ steps.platformResults.outputs.ANDROID_RESULT }}" == "success" ] && \ | |
| [ "${{ steps.platformResults.outputs.WEB_RESULT }}" == "success" ]; then | |
| isAllPlatformsDeployed="true" | |
| fi | |
| echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" | |
| echo "IS_ALL_PLATFORMS_DEPLOYED is $isAllPlatformsDeployed" | |
| - name: Check release readiness | |
| id: checkReleaseReady | |
| run: | | |
| isReleaseReady="false" | |
| if [ "${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }}" == "true" ] && \ | |
| [ "${{ needs.victoryChartRendererBuild.result }}" == "success" ]; then | |
| isReleaseReady="true" | |
| fi | |
| echo "IS_RELEASE_READY=$isReleaseReady" >> "$GITHUB_OUTPUT" | |
| echo "IS_RELEASE_READY is $isReleaseReady (VCR result: ${{ needs.victoryChartRendererBuild.result }})" | |
| autoRetestRequestForCP: | |
| name: File retest request for cherry-picked deploy-blocker fixes | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| # Only for a cherry-pick to staging, once every platform is on staging. | |
| if: ${{ github.repository == 'Expensify/App' && needs.prep.outputs.DEPLOY_ENV == 'staging' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} | |
| needs: [prep, checkDeploymentSuccess] | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| - name: Setup Node | |
| uses: ./.github/actions/composite/setupNode | |
| - name: Load retest webhook from 1Password | |
| id: loadWebhook | |
| # v4.0.0 | |
| uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| SLACK_RETEST_WEBHOOK: op://${{ vars.OP_VAULT }}/Repository-Secrets/SLACK_RETEST_WEBHOOK | |
| - name: File retest request | |
| run: npx bun scripts/createRetestRequestForCP.ts --deploy-sha=${{ needs.prep.outputs.DEPLOY_SHA }} --deploy-tag=${{ needs.prep.outputs.TAG }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} | |
| SLACK_RETEST_WEBHOOK: ${{ steps.loadWebhook.outputs.SLACK_RETEST_WEBHOOK }} | |
| createRelease: | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} | |
| needs: [prep, checkDeploymentSuccess, victoryChartRendererBuild] | |
| permissions: | |
| contents: write | |
| steps: | |
| # v7 | |
| - name: Download all workflow run artifacts | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 | |
| - name: Verify victory-chart-renderer artifact is present | |
| run: test -f ./victory-chart-renderer-linux-x64-artifact/victory-chart-renderer-linux-x64 | |
| - name: Get last production release | |
| id: get_last_prod_version | |
| run: echo "LAST_PROD_VERSION=$(gh release list --repo ${{ github.repository }} --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')" >> "$GITHUB_OUTPUT" | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| - name: 🚀 Create release 🚀 | |
| run: | | |
| # Check if the release already exists | |
| if gh release view "${{ needs.prep.outputs.TAG }}" --repo "${{ github.repository }}" &> /dev/null; then | |
| echo "Release ${{ needs.prep.outputs.TAG }} already exists, skipping creating it again." | |
| else | |
| echo "Release ${{ needs.prep.outputs.TAG }} does not exist, creating it now." | |
| readonly CREATE_MAX_RETRIES=5 | |
| for ((i = 0; i <= CREATE_MAX_RETRIES; i++)); do | |
| if gh release create "${{ needs.prep.outputs.TAG }}" ${{ env.DEPLOY_ENV == 'staging' && '--prerelease' || '' }} \ | |
| --repo "${{ github.repository }}" \ | |
| --title "${{ needs.prep.outputs.TAG }}" \ | |
| ${{ env.DEPLOY_ENV == 'production' && format('--notes-start-tag {0}', steps.get_last_prod_version.outputs.LAST_PROD_VERSION) || '' }} \ | |
| --generate-notes \ | |
| --verify-tag \ | |
| --target "${{ env.DEPLOY_ENV }}"; then | |
| break | |
| fi | |
| # A transient GitHub error during create doesn't necessarily mean the release failed; it may have been created anyway, so a later "already exists" error means it's present. | |
| if gh release view "${{ needs.prep.outputs.TAG }}" --repo "${{ github.repository }}" &> /dev/null; then | |
| echo "Release ${{ needs.prep.outputs.TAG }} exists despite a create error (likely a transient 502), treating as success." | |
| break | |
| fi | |
| if [[ $i -lt $CREATE_MAX_RETRIES ]]; then | |
| echo "Failed to create release. Retrying in 3 seconds... ($((CREATE_MAX_RETRIES - i)) attempts left)" | |
| sleep 3 | |
| else | |
| echo "Failed to create release after $((CREATE_MAX_RETRIES + 1)) attempts" | |
| exit 1 | |
| fi | |
| done | |
| readonly VIEW_MAX_RETRIES=10 | |
| for ((i = 0; i <= VIEW_MAX_RETRIES; i++)); do | |
| if gh release view "${{ needs.prep.outputs.TAG }}" --repo "${{ github.repository }}" &> /dev/null; then | |
| break | |
| fi | |
| if [[ $i -lt $VIEW_MAX_RETRIES ]]; then | |
| echo "Release ${{ needs.prep.outputs.TAG }} not yet visible after creation. Retrying... ($((VIEW_MAX_RETRIES - i)) attempts left)" | |
| sleep 1 | |
| else | |
| echo "Release ${{ needs.prep.outputs.TAG }} never became visible after $((VIEW_MAX_RETRIES + 1)) checks" | |
| exit 1 | |
| fi | |
| done | |
| fi | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| - name: Rename web sourcemaps artifacts before assets upload in order to have unique ReleaseAsset.name | |
| continue-on-error: true | |
| run: | | |
| mv ./web-sourcemaps-artifact/merged-source-map.js.map ./web-sourcemaps-artifact/web-merged-source-map.js.map | |
| - name: Upload artifacts to GitHub Release | |
| continue-on-error: true | |
| run: | | |
| # Release asset name should follow the template: fileNameOnRunner#fileNameInRelease | |
| files=" | |
| ./androidBuild-artifact/Expensify-release.aab#android.aab | |
| ./android-apk-artifact/Expensify.apk#android.apk | |
| ./android-sourcemap-artifact/index.android.bundle.map#android-sourcemap.js.map | |
| ./iosBuild-artifact/Expensify.ipa#ios.ipa | |
| ./ios-sourcemap-artifact/main.jsbundle.map#ios-sourcemap.js.map | |
| ./web-sourcemaps-artifact/web-merged-source-map.js.map#web-sourcemap.js.map | |
| ./web-build-tar-gz-artifact/webBuild.tar.gz#web.tar.gz | |
| ./web-build-zip-artifact/webBuild.zip#web.zip | |
| " | |
| # Loop through each file and upload individually (so if one fails, we still have other platforms uploaded) | |
| # Note: Not all of these files are present for production releases, because we don't build the native apps for prod deploys. That's expected. | |
| echo -e "$files" | xargs -I {} --max-procs=4 bash -c ' | |
| if gh release upload ${{ needs.prep.outputs.TAG }} --repo ${{ github.repository }} --clobber {}; then | |
| echo "✅ Successfully uploaded {}" | |
| else | |
| echo "❌ Failed to upload {}" | |
| fi | |
| ' | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| - name: Upload victory-chart-renderer to GitHub Release | |
| run: | | |
| gh release upload "${{ needs.prep.outputs.TAG }}" --repo "${{ github.repository }}" --clobber \ | |
| "./victory-chart-renderer-linux-x64-artifact/victory-chart-renderer-linux-x64#victory-chart-renderer-linux-x64" | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| - name: Warn deployers if deploy failed | |
| if: ${{ failure() }} | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#deployer", | |
| "attachments": [{ | |
| "color": "#DB4545", | |
| "pretext": "<!subteam^S4TJJ3PSL>", | |
| "text": "💥 NewDot ${{ env.DEPLOY_ENV }} deploy failed. 💥" | |
| }] | |
| } | |
| # Upload the production-bound binary to Sentry for size analysis. | |
| # - Cherry-pick deploys build native in this run, so we pull the .aab/.ipa from the build artifact. | |
| # - Re-promotion deploys don't build native, so we download from the matching `<TAG>-staging` | |
| # GitHub Release populated during the staging deploy of that version. | |
| androidUploadSentry: | |
| name: Upload Android build to Sentry for size analysis | |
| needs: [prep, androidBuild, checkDeploymentSuccess] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: >- | |
| ${{ always() | |
| && needs.prep.outputs.DEPLOY_ENV == 'production' | |
| && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} | |
| continue-on-error: true | |
| timeout-minutes: 10 | |
| outputs: | |
| SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| - name: Upload to Sentry for size analysis | |
| id: sentry-upload | |
| uses: ./.github/actions/composite/uploadSentrySizeAnalysis | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| with: | |
| platform: Android | |
| asset-name: Expensify-release.aab | |
| artifact-name: androidBuild-artifact | |
| staging-release-tag: ${{ needs.prep.outputs.TAG }}-staging | |
| is-cherry-pick: ${{ needs.prep.outputs.IS_CHERRY_PICK }} | |
| iosUploadSentry: | |
| name: Upload iOS build to Sentry for size analysis | |
| needs: [prep, iosBuild, checkDeploymentSuccess] | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: >- | |
| ${{ always() | |
| && needs.prep.outputs.DEPLOY_ENV == 'production' | |
| && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} | |
| continue-on-error: true | |
| timeout-minutes: 10 | |
| outputs: | |
| SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} | |
| steps: | |
| - name: Checkout | |
| uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 | |
| - name: Upload to Sentry for size analysis | |
| id: sentry-upload | |
| uses: ./.github/actions/composite/uploadSentrySizeAnalysis | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | |
| with: | |
| platform: iOS | |
| asset-name: Expensify.ipa | |
| artifact-name: iosBuild-artifact | |
| staging-release-tag: ${{ needs.prep.outputs.TAG }}-staging | |
| is-cherry-pick: ${{ needs.prep.outputs.IS_CHERRY_PICK }} | |
| # Why is this necessary for CP-to-prod? Consider this scenario: | |
| # 1. You close a checklist and we create a new staging version `9.0.34-0` and a new checklist | |
| # 2. You then CP a PR to production, and in the process create `9.0.35-0` | |
| # 3. You close the new checklist, and we try to ship `9.0.34-0` to production. This won't work, because we already submitted a higher version `9.0-35-0` | |
| # | |
| # To address this, we'll: | |
| # 1. Bump the version on main again | |
| # 2. CP that version bump to staging | |
| cherryPickExtraVersionBump: | |
| needs: [prep, checkDeploymentSuccess] | |
| if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) && needs.prep.outputs.DEPLOY_ENV == 'production' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) }} | |
| uses: ./.github/workflows/cherryPick.yml | |
| secrets: inherit | |
| with: | |
| # Note: by omitting PULL_REQUEST_URL, we are just doing a version bump and CP'ing it to staging | |
| TARGET: staging | |
| postSlackMessageOnSuccess: | |
| name: Post a Slack message when all platforms deploy successfully | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) && needs.victoryChartRendererBuild.result == 'success' && needs.createRelease.result == 'success' }} | |
| needs: [prep, androidUploadGooglePlay, androidSubmit, iosUploadTestflight, iosSubmit, webDeploy, checkDeploymentSuccess, victoryChartRendererBuild, createRelease] | |
| steps: | |
| - name: 'Announces the deploy in the #announce Slack room' | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#announce", | |
| "attachments": [{ | |
| "color": "good", | |
| "text": "🎉️ Successfully deployed ${{ github.repository }} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to ${{ env.DEPLOY_ENV }} 🎉️" | |
| }] | |
| } | |
| - name: 'Announces the deploy in the #deployer Slack room' | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#deployer", | |
| "attachments": [{ | |
| "color": "good", | |
| "text": "🎉️ Successfully deployed ${{ github.repository }} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to ${{ env.DEPLOY_ENV }} 🎉️" | |
| }] | |
| } | |
| - name: 'Announces a production deploy in the #expensify-open-source Slack room' | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| if: ${{ env.DEPLOY_ENV == 'production' }} | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK }} | |
| webhook-type: incoming-webhook | |
| payload: | | |
| { | |
| "channel": "#expensify-open-source", | |
| "attachments": [{ | |
| "color": "good", | |
| "text": "🎉️ Successfully deployed ${{ github.repository }} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to production 🎉️" | |
| }] | |
| } | |
| postGithubComments: | |
| uses: ./.github/workflows/postDeployComments.yml | |
| if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} | |
| needs: [prep, checkDeploymentSuccess, createRelease, androidBuild, iosBuild, androidUploadSentry, iosUploadSentry] | |
| secrets: inherit | |
| with: | |
| version: ${{ needs.prep.outputs.APP_VERSION }} | |
| env: ${{ needs.prep.outputs.DEPLOY_ENV }} | |
| android: ${{ needs.checkDeploymentSuccess.outputs.ANDROID_RESULT }} | |
| ios: ${{ needs.checkDeploymentSuccess.outputs.IOS_RESULT }} | |
| web: ${{ needs.checkDeploymentSuccess.outputs.WEB_RESULT }} | |
| android_sentry_url: ${{ needs.androidUploadSentry.outputs.SENTRY_URL }} | |
| ios_sentry_url: ${{ needs.iosUploadSentry.outputs.SENTRY_URL }} |