Skip to content

Merge pull request #98352 from Expensify/rory/fix-prebuilt-artifacts-… #5967

Merge pull request #98352 from Expensify/rory/fix-prebuilt-artifacts-…

Merge pull request #98352 from Expensify/rory/fix-prebuilt-artifacts-… #5967

Workflow file for this run

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 }}