Skip to content

Merge pull request #87651 from Krishna2323/krishna2323/issue/87644 #5417

Merge pull request #87651 from Krishna2323/krishna2323/issue/87644

Merge pull request #87651 from Krishna2323/krishna2323/issue/87644 #5417

Workflow file for this run

name: Deploy code to staging or production
on:
push:
branches: [staging, production]
env:
IS_APP_REPO: ${{ github.repository == 'Expensify/App' }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
prep:
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
APP_VERSION: ${{ steps.getAppVersion.outputs.VERSION }}
TAG: ${{ steps.getTagName.outputs.TAG }}
# 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: ${{ github.ref == 'refs/heads/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
# v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
submodules: true
- 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=${{ github.ref == 'refs/heads/production' && steps.getAppVersion.outputs.VERSION || format('{0}-staging', steps.getAppVersion.outputs.VERSION) }}" >> "$GITHUB_OUTPUT"
- name: Create and push tag
run: |
git tag ${{ steps.getTagName.outputs.TAG }}
git push origin --tags
cd Mobile-Expensify
git tag ${{ steps.getTagName.outputs.TAG }}
git push origin --tags
# 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
with:
script: |
const commitMessages = context.payload.commits.map((commit) => commit.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: ${{ github.ref == 'refs/heads/staging' }}
needs: prep
secrets: inherit
androidBuild:
name: Build Android HybridApp
needs: [prep]
if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }}
uses: ./.github/workflows/buildAndroid.yml
with:
ref: ${{ github.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
# v6
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
- 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.229.0
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252
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() && github.ref == 'refs/heads/production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }}
steps:
- name: Checkout
# v6
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
- name: Setup Ruby
# v1.229.0
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252
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() }}
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
with:
status: custom
custom_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> 💥`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
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 }}
androidUploadApplause:
name: Upload Android to Applause
needs: [prep, androidBuild]
runs-on: blacksmith-2vcpu-ubuntu-2404
if: ${{ github.repository == 'Expensify/App' && github.ref == 'refs/heads/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
# v2
uses: 1password/load-secrets-action@581a835fb51b8e7ec56b71cf2ffddd7e68bb25e0
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: ${{ github.sha }}
variant: Release
secrets: inherit
iosUploadTestflight:
name: Upload iOS to TestFlight
needs: [prep, iosBuild]
runs-on: macos-15-xlarge
if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }}
env:
DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer
steps:
- name: Checkout
# v6
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
with:
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.229.0
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252
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: macos-15-xlarge
if: ${{ always() && !cancelled() && github.ref == 'refs/heads/production' && needs.iosBuild.result != 'failure' && needs.iosUploadTestflight.result != 'failure' }}
env:
DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer
steps:
- name: Checkout
# v6
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
- name: Setup Ruby
# v1.229.0
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252
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
- 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() }}
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
with:
status: custom
custom_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>. 💥`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
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 }}
iosUploadApplause:
name: Upload iOS to Applause
needs: [prep, iosBuild]
runs-on: blacksmith-2vcpu-ubuntu-2404
if: ${{ github.repository == 'Expensify/App' && github.ref == 'refs/heads/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
# v2
uses: 1password/load-secrets-action@581a835fb51b8e7ec56b71cf2ffddd7e68bb25e0
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: ${{ github.sha }}
environment: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }}
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
# v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ github.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://${{ github.ref == 'refs/heads/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: ${{ github.ref == 'refs/heads/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: ${{ github.ref == 'refs/heads/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
# v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ github.sha }}
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- name: Build storybook docs
run: |
if [ "${{ github.ref }}" == "refs/heads/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]
steps:
- name: Checkout
# v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Post Slack message on failure
uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
checkDeploymentSuccess:
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }}
IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }}
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]
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 [ "${{ github.ref }}" == "refs/heads/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 [ "${{ github.ref }}" == "refs/heads/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"
createRelease:
runs-on: blacksmith-2vcpu-ubuntu-2404
if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }}
needs: [prep, checkDeploymentSuccess]
permissions:
contents: write
steps:
# v4.2.1
- name: Download all workflow run artifacts
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e
- 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 }}" ${{ github.ref == 'refs/heads/staging' && '--prerelease' || '' }} \
--repo "${{ github.repository }}" \
--title "${{ needs.prep.outputs.TAG }}" \
${{ github.ref == 'refs/heads/production' && format('--notes-start-tag {0}', steps.get_last_prod_version.outputs.LAST_PROD_VERSION) || '' }} \
--generate-notes \
--verify-tag \
--target "${{ github.ref }}"; then
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: Warn deployers if deploy failed
if: ${{ failure() }}
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
with:
status: custom
custom_payload: |
{
channel: '#deployer',
attachments: [{
color: "#DB4545",
pretext: `<!subteam^S4TJJ3PSL>`,
text: `💥 NewDot ${{ github.ref == 'refs/heads/staging' && 'staging' || 'production' }} deploy failed. 💥`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
# 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_AT_LEAST_ONE_PLATFORM_DEPLOYED) && github.ref == 'refs/heads/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: [prep, androidUploadGooglePlay, androidSubmit, iosUploadTestflight, iosSubmit, webDeploy, checkDeploymentSuccess, createRelease]
steps:
- name: 'Announces the deploy in the #announce Slack room'
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
with:
status: custom
custom_payload: |
{
channel: '#announce',
attachments: [{
color: 'good',
text: `🎉️ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} 🎉️`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: 'Announces the deploy in the #deployer Slack room'
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
with:
status: custom
custom_payload: |
{
channel: '#deployer',
attachments: [{
color: 'good',
text: `🎉️ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} 🎉️`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: 'Announces a production deploy in the #expensify-open-source Slack room'
# v3
uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e
if: ${{ github.ref == 'refs/heads/production' }}
with:
status: custom
custom_payload: |
{
channel: '#expensify-open-source',
attachments: [{
color: 'good',
text: `🎉️ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ needs.prep.outputs.TAG }}|${{ needs.prep.outputs.TAG }}> to production 🎉️`,
}]
}
env:
GITHUB_TOKEN: ${{ github.token }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
postGithubComments:
uses: ./.github/workflows/postDeployComments.yml
if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }}
needs: [prep, checkDeploymentSuccess, createRelease, androidBuild, iosBuild]
secrets: inherit
with:
version: ${{ needs.prep.outputs.APP_VERSION }}
env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }}
android: ${{ needs.checkDeploymentSuccess.outputs.ANDROID_RESULT }}
ios: ${{ needs.checkDeploymentSuccess.outputs.IOS_RESULT }}
web: ${{ needs.checkDeploymentSuccess.outputs.WEB_RESULT }}
android_sentry_url: ${{ needs.androidBuild.outputs.SENTRY_URL }}
ios_sentry_url: ${{ needs.iosBuild.outputs.SENTRY_URL }}