Workflow file for this run
This file contains 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: Build and deploy android, desktop, iOS, and web clients | |
# This workflow is run when a release or prerelease is created | |
on: | |
release: | |
types: [prereleased, released] | |
env: | |
SHOULD_DEPLOY_PRODUCTION: ${{ github.event.action == 'released' }} | |
concurrency: | |
group: ${{ github.workflow }}-${{ github.event.action }} | |
cancel-in-progress: true | |
jobs: | |
validateActor: | |
runs-on: ubuntu-latest | |
outputs: | |
IS_DEPLOYER: ${{ fromJSON(steps.isUserDeployer.outputs.IS_DEPLOYER) || github.actor == 'OSBotify' || github.actor == 'os-botify[bot]' }} | |
steps: | |
- name: Check if user is deployer | |
id: isUserDeployer | |
run: | | |
if gh api /orgs/Expensify/teams/mobile-deployers/memberships/${{ github.actor }} --silent; then | |
echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT" | |
else | |
echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT" | |
fi | |
env: | |
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} | |
# 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.event.action != 'released' }} | |
needs: validateActor | |
secrets: inherit | |
android: | |
# WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly | |
name: Build and deploy Android | |
needs: validateActor | |
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} | |
runs-on: ubuntu-latest-xl | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Configure MapBox SDK | |
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} | |
- name: Setup Node | |
uses: ./.github/actions/composite/setupNode | |
- name: Setup Java | |
uses: actions/setup-java@v3 | |
with: | |
distribution: 'oracle' | |
java-version: '17' | |
- name: Setup Ruby | |
uses: ruby/setup-ruby@v1.187.0 | |
with: | |
ruby-version: '2.7' | |
bundler-cache: true | |
- name: Decrypt keystore | |
run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Decrypt json key | |
run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Set version in ENV | |
run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_ENV" | |
- name: Run Fastlane | |
run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} | |
env: | |
RUBYOPT: '-rostruct' | |
MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} | |
MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} | |
VERSION: ${{ env.VERSION_CODE }} | |
- name: Upload Android build to Browser Stack | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" | |
env: | |
BROWSERSTACK: ${{ secrets.BROWSERSTACK }} | |
- name: Upload Android sourcemaps to GitHub Release | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: gh release upload ${{ github.event.release.tag_name }} android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map#android-sourcemap-${{ github.event.release.tag_name }} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Upload Android build to GitHub Release | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: gh release upload ${{ github.event.release.tag_name }} android/app/build/outputs/bundle/productionRelease/app-production-release.aab | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Warn deployers if Android production deploy failed | |
if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
uses: 8398a7/action-slack@v3 | |
with: | |
status: custom | |
custom_payload: | | |
{ | |
channel: '#deployer', | |
attachments: [{ | |
color: "#DB4545", | |
pretext: `<!subteam^S4TJJ3PSL>`, | |
text: `💥 Android production deploy failed. Please manually submit ${{ github.event.release.tag_name }} in the <https://play.google.com/console/u/0/developers/8765590895836334604/app/4973041797096886180/releases/overview|Google Play Store>. 💥`, | |
}] | |
} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} | |
desktop: | |
# WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly | |
name: Build and deploy Desktop | |
needs: validateActor | |
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} | |
runs-on: macos-14-large | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Setup Node | |
uses: ./.github/actions/composite/setupNode | |
- name: Decrypt Developer ID Certificate | |
run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg | |
env: | |
DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }} | |
- name: Build desktop app | |
run: | | |
if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then | |
npm run desktop-build | |
else | |
npm run desktop-build-staging | |
fi | |
env: | |
CSC_LINK: ${{ secrets.CSC_LINK }} | |
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | |
APPLE_ID: ${{ secrets.APPLE_ID }} | |
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} | |
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
GCP_GEOLOCATION_API_KEY: $${{ secrets.GCP_GEOLOCATION_API_KEY_PRODUCTION }} | |
- name: Upload desktop sourcemaps to GitHub Release | |
run: gh release upload ${{ github.event.release.tag_name }} desktop/dist/www/merged-source-map.js.map#desktop-sourcemap-${{ github.event.release.tag_name }} --clobber | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Upload desktop build to GitHub Release | |
run: gh release upload ${{ github.event.release.tag_name }} desktop-build/NewExpensify.dmg --clobber | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
iOS: | |
# WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly | |
name: Build and deploy iOS | |
needs: validateActor | |
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} | |
env: | |
DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer | |
runs-on: macos-13-xlarge | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Configure MapBox SDK | |
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} | |
- name: Setup Node | |
id: setup-node | |
uses: ./.github/actions/composite/setupNode | |
- name: Setup Ruby | |
uses: ruby/setup-ruby@v1.187.0 | |
with: | |
ruby-version: '2.7' | |
bundler-cache: true | |
- name: Cache Pod dependencies | |
uses: actions/cache@v4 | |
id: pods-cache | |
with: | |
path: ios/Pods | |
key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} | |
- name: Compare Podfile.lock and Manifest.lock | |
id: compare-podfile-and-manifest | |
run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" | |
- name: Install cocoapods | |
uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 | |
if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' | |
with: | |
timeout_minutes: 10 | |
max_attempts: 5 | |
command: scripts/pod-install.sh | |
- name: Decrypt AppStore profile | |
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Decrypt AppStore Notification Service profile | |
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Decrypt certificate | |
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Decrypt App Store Connect API key | |
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg | |
env: | |
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} | |
- name: Set iOS version in ENV | |
run: echo "IOS_VERSION=$(echo '${{ github.event.release.tag_name }}' | tr '-' '.')" >> "$GITHUB_ENV" | |
- name: Run Fastlane | |
run: bundle exec fastlane ios ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'beta' }} | |
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 }} | |
VERSION: ${{ env.IOS_VERSION }} | |
- name: Upload iOS build to Browser Stack | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" | |
env: | |
BROWSERSTACK: ${{ secrets.BROWSERSTACK }} | |
- name: Upload iOS sourcemaps to GitHub Release | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: gh release upload ${{ github.event.release.tag_name }} main.jsbundle.map#ios-sourcemap-${{ github.event.release.tag_name }} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Upload iOS build to GitHub Release | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: gh release upload ${{ github.event.release.tag_name }} /Users/runner/work/App/App/New\ Expensify.ipa | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Warn deployers if iOS production deploy failed | |
if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
uses: 8398a7/action-slack@v3 | |
with: | |
status: custom | |
custom_payload: | | |
{ | |
channel: '#deployer', | |
attachments: [{ | |
color: "#DB4545", | |
pretext: `<!subteam^S4TJJ3PSL>`, | |
text: `💥 iOS production deploy failed. Please manually submit ${{ env.IOS_VERSION }} in the <https://appstoreconnect.apple.com/apps/1530278510/appstore|App Store>. 💥`, | |
}] | |
} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} | |
web: | |
# WARNING: getDeployPullRequestList depends on this job name. do not change job name without adjusting that action accordingly | |
name: Build and deploy Web | |
needs: validateActor | |
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }} | |
runs-on: ubuntu-latest-xl | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Setup Node | |
uses: ./.github/actions/composite/setupNode | |
- name: Setup Cloudflare CLI | |
run: pip3 install cloudflare==2.19.0 | |
- name: Configure AWS Credentials | |
uses: aws-actions/configure-aws-credentials@v4 | |
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: Build web | |
run: | | |
if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then | |
npm run build | |
else | |
npm run build-staging | |
fi | |
- name: Build storybook docs | |
continue-on-error: true | |
run: | | |
if [[ ${{ env.SHOULD_DEPLOY_PRODUCTION }} == 'true' ]]; then | |
npm run storybook-build | |
else | |
npm run storybook-build-staging | |
fi | |
- name: Deploy to S3 | |
run: | | |
aws s3 cp --recursive --acl public-read "$GITHUB_WORKSPACE"/dist ${{ env.S3_URL }}/ | |
aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{ env.S3_URL }}/.well-known/apple-app-site-association | |
aws s3 cp --acl public-read --content-type 'application/json' --metadata-directive REPLACE ${{ env.S3_URL }}/.well-known/apple-app-site-association ${{env.S3_URL }}/apple-app-site-association | |
env: | |
S3_URL: s3://${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging-' || '' }}expensify-cash | |
- name: Purge Cloudflare cache | |
run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["${{ env.SHOULD_DEPLOY_PRODUCTION != 'true' && 'staging.' || '' }}new.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache | |
env: | |
CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} | |
- name: Verify staging deploy | |
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: | | |
sleep 5 | |
DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://staging.new.expensify.com/version.json | jq -r '.version')" | |
if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then | |
echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." | |
exit 1 | |
fi | |
- name: Verify production deploy | |
if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
run: | | |
sleep 5 | |
DOWNLOADED_VERSION="$(wget -q -O /dev/stdout https://new.expensify.com/version.json | jq -r '.version')" | |
if [[ '${{ github.event.release.tag_name }}' != "$DOWNLOADED_VERSION" ]]; then | |
echo "Error: deployed version $DOWNLOADED_VERSION does not match local version ${{ github.event.release.tag_name }}. Something went wrong..." | |
exit 1 | |
fi | |
- name: Upload web sourcemaps to GitHub Release | |
run: gh release upload ${{ github.event.release.tag_name }} dist/merged-source-map.js.map#web-sourcemap-${{ github.event.release.tag_name }} --clobber | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
- name: Upload web build to GitHub Release | |
run: | | |
tar -czvf webBuild.tar.gz dist | |
zip -r webBuild.zip dist | |
gh release upload ${{ github.event.release.tag_name }} webBuild.tar.gz webBuild.zip --clobber | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
postSlackMessageOnFailure: | |
name: Post a Slack message when any platform fails to build or deploy | |
runs-on: ubuntu-latest | |
if: ${{ failure() }} | |
needs: [android, desktop, iOS, web] | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Post Slack message on failure | |
uses: ./.github/actions/composite/announceFailedWorkflowInSlack | |
with: | |
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} | |
# Build a version of iOS and Android HybridApp if we are deploying to staging | |
hybridApp: | |
runs-on: ubuntu-latest | |
needs: validateActor | |
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) && github.event.action != 'released' }} | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: 'Deploy HybridApp' | |
run: gh workflow run --repo Expensify/Mobile-Deploy deploy.yml -f force_build=true -f build_version="$(npm run print-version --silent)" | |
env: | |
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} | |
postSlackMessageOnSuccess: | |
name: Post a Slack message when all platforms deploy successfully | |
runs-on: ubuntu-latest | |
if: ${{ success() }} | |
needs: [android, desktop, iOS, web] | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Set version | |
run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" | |
- name: 'Announces the deploy in the #announce Slack room' | |
uses: 8398a7/action-slack@v3 | |
with: | |
status: custom | |
custom_payload: | | |
{ | |
channel: '#announce', | |
attachments: [{ | |
color: 'good', | |
text: `🎉️ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ env.VERSION }}|${{ env.VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} 🎉️`, | |
}] | |
} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} | |
- name: 'Announces the deploy in the #deployer Slack room' | |
uses: 8398a7/action-slack@v3 | |
with: | |
status: custom | |
custom_payload: | | |
{ | |
channel: '#deployer', | |
attachments: [{ | |
color: 'good', | |
text: `🎉️ Successfully deployed ${process.env.AS_REPO} <https://github.com/Expensify/App/releases/tag/${{ env.VERSION }}|${{ env.VERSION }}> to ${{ fromJSON(env.SHOULD_DEPLOY_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' | |
uses: 8398a7/action-slack@v3 | |
if: ${{ fromJSON(env.SHOULD_DEPLOY_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/${{ env.VERSION }}|${{ env.VERSION }}> to production 🎉️`, | |
}] | |
} | |
env: | |
GITHUB_TOKEN: ${{ github.token }} | |
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} | |
postGithubComment: | |
name: Post a GitHub comment when platforms are done building and deploying | |
runs-on: ubuntu-latest | |
if: ${{ !cancelled() }} | |
needs: [android, desktop, iOS, web] | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Setup Node | |
uses: ./.github/actions/composite/setupNode | |
- name: Set version | |
run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" | |
- name: Get Release Pull Request List | |
id: getReleasePRList | |
uses: ./.github/actions/javascript/getDeployPullRequestList | |
with: | |
TAG: ${{ env.VERSION }} | |
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} | |
IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
- name: Comment on issues | |
uses: ./.github/actions/javascript/markPullRequestsAsDeployed | |
with: | |
PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} | |
IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} | |
DEPLOY_VERSION: ${{ env.VERSION }} | |
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} | |
ANDROID: ${{ needs.android.result }} | |
DESKTOP: ${{ needs.desktop.result }} | |
IOS: ${{ needs.iOS.result }} | |
WEB: ${{ needs.web.result }} |